diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d80980f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.idea + +dist +node_modules +coverage + +bun.lockb diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5235e95 --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", + "files": { + "include": ["src"], + "ignore": ["node_modules"] + }, + "linter": { + "include": ["src"], + "ignore": ["node_modules"], + "rules": { + "suspicious": { + "useValidTypeof": "off", + "noExplicitAny": "off" + } + } + }, + "formatter": { + "include": ["src"], + "ignore": ["node_modules"] + }, + "organizeImports": { + "include": ["src"], + "ignore": ["node_modules"] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..51d71bc --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "id", + "module": "index.ts", + "type": "module", + "scripts": { + "test": "bun test", + "build": "bun build src/index.ts --outdir ./dist --format esm --sourcemap=external --minify", + "check": "bunx @biomejs/biome check --apply ./" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "5.4.5" + } +} diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..bd49a79 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { createId } from "./index.js"; + +const id16 = () => createId({ length: 16 }); +const id32 = () => createId({ length: 32 }); +const id64 = () => createId({ length: 64 }); + +test("createId", async () => { + expect(await id16()).toHaveLength(16); + expect(await id32()).toHaveLength(32); + expect(await id64()).toHaveLength(64); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d674d96 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,199 @@ +declare const window: any; +declare const global: any; + +const BUFFER_SIZE = 128; + +const COUNT_SIZE = 4; +const TIMESTAMP_SIZE = 8; +const SALT_SIZE = 52; +const FINGERPRINT_SIZE = 32; +const EXTERNAL_SIZE = 32; + +const SALT_START = COUNT_SIZE + TIMESTAMP_SIZE; +const SALT_END = SALT_START + SALT_SIZE; + +const FINGERPRINT_START = SALT_END; +const FINGERPRINT_END = FINGERPRINT_START + FINGERPRINT_SIZE; + +const EXTERNAL_START = FINGERPRINT_END; +const EXTERNAL_END = EXTERNAL_START + EXTERNAL_SIZE; + +const DEFAULT_LENGTH = 32; + +const counter = new Uint32Array(1); + +// seed counter with random value +crypto.getRandomValues(counter); + +/** + * Hashes buffer to Uint8Array using SHA-512 + * + * @param {BufferSource} buffer - Buffer to be hashed + * + * @return {Promise} Promise that resolves with Uint8Array + */ +const hashBufferToArray = async (buffer: BufferSource): Promise => { + return new Uint8Array(await crypto.subtle.digest("SHA-512", buffer)); +}; + +/** + * Hashes buffer to base36 using SHA-512. + * + * @param {BufferSource} buffer - Buffer to be hashed + * + * @return {Promise} Promise that resolves with base36 string + */ +const hashBufferToBase36 = async (buffer: BufferSource): Promise => { + const hashed = await hashBufferToArray(buffer); + + // append bytes to BigInt + let bigint = BigInt(0); + for (let i = 0; i < hashed.length; ++i) { + bigint = (bigint << BigInt(8)) + BigInt(hashed[i]); + } + + // return base36 converted from BigInt + return bigint.toString(36); +}; + +/** + * Transforms string into Uint8Array. + * It also asserts that string is ASCII. + * + * @param {string} value - Value to be transformed + * + * @return {Uint8Array} Array containing char codes of the input value + */ +const asciiToArray = (value: string): Uint8Array => { + const length = value.length; + const buffer = new Uint8Array(length); + + for (let i = 0; i < length; ++i) { + const code = value.charCodeAt(i); + + if (code > 0x7f) { + throw Error("Value has to be ASCII string"); + } + + buffer[i] = code; + } + + return buffer; +}; + +/** + * Slices the original value at a random start position. + * + * @param {string | Uint8Array} value - Value to be sliced + * @param {number} size - Desired size of the slice + * + * @return {string | Uint8Array} Sliced value + */ +const getRandomSlice = <$Value extends string | Uint8Array>( + value: $Value, + size: number, +): $Value => { + const position = new Uint8Array(1); + crypto.getRandomValues(position); + + const start = Math.round((position[0] / 255) * (value.length - size)); + + return value.slice(start, start + size) as $Value; +}; + +/** + * Creates a random ID. + * + * @param {Object} props - Optional properties to tweak ID generation + * @param {string} props.length - Length of the returned ID (between 1 and 96) + * @param {string} props.external - External (fingerprint) data to be hashed + * + * @return {Promise} Promise that resolves with ID + */ +export const createId = async ( + props: { + length?: number; + external?: string; + } = {}, +): Promise => { + props.length ??= DEFAULT_LENGTH; + props.external ??= ""; + + if (props.length > 96 || props.length < 1) { + throw Error("Length has to be between 1 and 96"); + } + + const buffer = new ArrayBuffer(BUFFER_SIZE); + const array = new Uint8Array(buffer); + const count = counter[0]++; + const timestamp = Date.now(); + const fingerprint = createFingerprint(); + + // set uint32 count + array[0] = count & 0xff; + array[1] = (count >> 8) & 0xff; + array[2] = (count >> 16) & 0xff; + array[3] = (count >> 24) & 0xff; + + // set uint64 timestamp + array[4] = timestamp & 0xff; + array[5] = (timestamp >> 8) & 0xff; + array[6] = (timestamp >> 16) & 0xff; + array[7] = (timestamp >> 24) & 0xff; + array[8] = (timestamp >> 32) & 0xff; + array[9] = (timestamp >> 40) & 0xff; + array[10] = (timestamp >> 48) & 0xff; + array[11] = (timestamp >> 56) & 0xff; + + // fill salt slice with random values + crypto.getRandomValues(array.subarray(SALT_START, SALT_END)); + + // fill fingerprint slice with random values + if (fingerprint === "") { + crypto.getRandomValues(array.subarray(FINGERPRINT_START, FINGERPRINT_END)); + } + + // fill fingerprint slice with hash of fingerprint value + else { + array.set( + getRandomSlice( + await hashBufferToArray(asciiToArray(fingerprint)), + FINGERPRINT_SIZE, + ), + FINGERPRINT_START, + ); + } + + // fill external slice with random values + if (props.external === "") { + crypto.getRandomValues(array.subarray(EXTERNAL_START, EXTERNAL_END)); + } + + // fill external slice with hash of external value + else { + array.set( + getRandomSlice( + await hashBufferToArray(asciiToArray(props.external)), + EXTERNAL_SIZE, + ), + EXTERNAL_START, + ); + } + + return getRandomSlice(await hashBufferToBase36(buffer), props.length); +}; + +/** + * Creates a fingerprint based on the runtime's globals. + * + * @return {string} Concatenated array of keys of globals. + */ +export const createFingerprint = (): string => { + return Object.keys( + typeof global !== "undefined" + ? global + : typeof window !== "undefined" + ? window + : {}, + ).join(""); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..553d978 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "lib": ["ESNext", "WebWorker"], + "types": ["bun-types"], + "moduleResolution": "NodeNext", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "isolatedModules": true, + "strict": true + }, + "include": ["src"], + "exclude": ["node_modules"] +}