diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-08-07 17:17:32 +0400 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-08-07 17:17:32 +0400 |
| commit | e4e84c7aad9fb887150ba1fcfaf64463765419c9 (patch) | |
| tree | cdafa934aa61d8b646435d2a7b4ed0d7ac5c9f55 /javascript/src/snowflake.ts | |
| parent | e0f9cc69f9f7f524610d65b288eecdbf2ad958d6 (diff) | |
Diffstat (limited to 'javascript/src/snowflake.ts')
| -rw-r--r-- | javascript/src/snowflake.ts | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/javascript/src/snowflake.ts b/javascript/src/snowflake.ts new file mode 100644 index 0000000..5da7683 --- /dev/null +++ b/javascript/src/snowflake.ts @@ -0,0 +1,169 @@ +const _crypto: Crypto = globalThis.crypto ?? (globalThis as any).crypto ?? (await import("node:crypto")).webcrypto; + +const ID_BYTE_SIZE = 16; +const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const BASE = BigInt(ALPHABET.length); + +class InvalidEncodingError extends Error { + constructor(message = "snowflake: invalid encoding string") { + super(message); + this.name = "InvalidEncodingError"; + } +} + +class InvalidIDLengthError extends Error { + constructor(message = "snowflake: invalid ID byte length") { + super(message); + this.name = "InvalidIDLengthError"; + } +} + +const DECODE_LUT = new Map<string, number>(); +for (let i = 0; i < ALPHABET.length; i++) { + DECODE_LUT.set(ALPHABET[i], i); +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + let result = 0n; + for (const b of bytes) { + result = (result << 8n) | BigInt(b); + } + return result; +} + +function bigIntToBytes(value: bigint, length: number): Uint8Array { + const bytes = new Uint8Array(length); + for (let i = length - 1; i >= 0; i--) { + bytes[i] = Number(value & 0xffn); + value >>= 8n; + } + return bytes; +} + +class Snowflake { + readonly bytes: Uint8Array; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + } + + static generate(): Snowflake { + const bytes = new Uint8Array(ID_BYTE_SIZE); + _crypto.getRandomValues(bytes); + return new Snowflake(bytes); + } + + static zero(): Snowflake { + return new Snowflake(new Uint8Array(ID_BYTE_SIZE)); + } + + isZero(): boolean { + for (const b of this.bytes) { + if (b !== 0) return false; + } + return true; + } + + equals(other: Snowflake): boolean { + for (let i = 0; i < ID_BYTE_SIZE; i++) { + if (this.bytes[i] !== other.bytes[i]) return false; + } + return true; + } + + toString(): string { + if (this.isZero()) return "0"; + + let value = bytesToBigInt(this.bytes); + let result = ""; + + while (value > 0n) { + const remainder = Number(value % BASE); + result = ALPHABET[remainder] + result; + value /= BASE; + } + + return result; + } + + static fromString(s: string): Snowflake { + if (s.length === 0) return Snowflake.zero(); + + let value = 0n; + for (const ch of s) { + const idx = DECODE_LUT.get(ch); + if (idx === undefined) { + throw new InvalidEncodingError(); + } + value = value * BASE + BigInt(idx); + } + + if (value >= (1n << 128n)) { + throw new InvalidEncodingError(); + } + + return new Snowflake(bigIntToBytes(value, ID_BYTE_SIZE)); + } + + toHex(): string { + return Array.from(this.bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + static fromHex(h: string): Snowflake { + if (!/^[0-9a-f]+$/i.test(h)) { + throw new InvalidEncodingError(); + } + if (h.length !== ID_BYTE_SIZE * 2) { + throw new InvalidIDLengthError(); + } + + const bytes = new Uint8Array(ID_BYTE_SIZE); + for (let i = 0; i < ID_BYTE_SIZE; i++) { + bytes[i] = parseInt(h.substring(i * 2, i * 2 + 2), 16); + } + return new Snowflake(bytes); + } + + toJSON(): string { + return this.toString(); + } + + static fromJSON(json: string): Snowflake { + if (!json.startsWith('"') || !json.endsWith('"')) { + throw new InvalidEncodingError(); + } + return Snowflake.fromString(json.slice(1, -1)); + } + + toBytes(): Uint8Array { + return new Uint8Array(this.bytes); + } + + static fromBytes(source: Uint8Array | string): Snowflake { + let bytes: Uint8Array; + if (source instanceof Uint8Array) { + bytes = source; + } else { + bytes = new Uint8Array(source.length); + for (let i = 0; i < source.length; i++) { + bytes[i] = source.charCodeAt(i) & 0xff; + } + } + + if (bytes.length !== ID_BYTE_SIZE) { + throw new InvalidIDLengthError(); + } + + return new Snowflake(new Uint8Array(bytes)); + } +} + +export { + Snowflake, + InvalidEncodingError, + InvalidIDLengthError, + ID_BYTE_SIZE, + ALPHABET, +}; |