File
Blob: src/worker/services/id-service.ts
| 1 | import { encodeBase64Url } from "@/worker/services/crypto"; |
| 2 | |
| 3 | const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; |
| 4 | const BASE62_WIDTH = 22; |
| 5 | const BASE62_LOOKUP = new Map(BASE62_ALPHABET.split("").map((character, index) => [character, BigInt(index)])); |
| 6 | |
| 7 | export type DurableEntityPrefix = "usr" | "prj" | "run" | "whk"; |
| 8 | |
| 9 | const bytesToBigInt = (bytes: Uint8Array): bigint => { |
| 10 | let value = 0n; |
| 11 | for (const byte of bytes) { |
| 12 | value = (value << 8n) | BigInt(byte); |
| 13 | } |
| 14 | return value; |
| 15 | }; |
| 16 | |
| 17 | const toBase62 = (value: bigint): string => { |
| 18 | if (value === 0n) { |
| 19 | return BASE62_ALPHABET[0].repeat(BASE62_WIDTH); |
| 20 | } |
| 21 | |
| 22 | let remainderValue = value; |
| 23 | let output = ""; |
| 24 | |
| 25 | while (remainderValue > 0n) { |
| 26 | const remainder = Number(remainderValue % 62n); |
| 27 | output = `${BASE62_ALPHABET[remainder]}${output}`; |
| 28 | remainderValue /= 62n; |
| 29 | } |
| 30 | |
| 31 | return output.padStart(BASE62_WIDTH, BASE62_ALPHABET[0]); |
| 32 | }; |
| 33 | |
| 34 | const fromBase62 = (value: string): bigint => { |
| 35 | let output = 0n; |
| 36 | |
| 37 | for (const character of value) { |
| 38 | const digit = BASE62_LOOKUP.get(character); |
| 39 | if (digit === undefined) { |
| 40 | throw new Error("Invalid base62 value."); |
| 41 | } |
| 42 | |
| 43 | output = output * 62n + digit; |
| 44 | } |
| 45 | |
| 46 | return output; |
| 47 | }; |
| 48 | |
| 49 | const createUuidV7Bytes = (timestampMs: number): Uint8Array => { |
| 50 | const bytes = new Uint8Array(16); |
| 51 | const randomness = crypto.getRandomValues(new Uint8Array(10)); |
| 52 | let remainingTimestamp = BigInt(timestampMs); |
| 53 | |
| 54 | for (let index = 5; index >= 0; index -= 1) { |
| 55 | bytes[index] = Number(remainingTimestamp & 0xffn); |
| 56 | remainingTimestamp >>= 8n; |
| 57 | } |
| 58 | |
| 59 | bytes[6] = 0x70 | (randomness[0] & 0x0f); |
| 60 | bytes[7] = randomness[1]; |
| 61 | bytes[8] = 0x80 | (randomness[2] & 0x3f); |
| 62 | bytes.set(randomness.slice(3), 9); |
| 63 | |
| 64 | return bytes; |
| 65 | }; |
| 66 | |
| 67 | export const generateDurableEntityId = (prefix: DurableEntityPrefix, timestampMs = Date.now()): string => { |
| 68 | const suffix = toBase62(bytesToBigInt(createUuidV7Bytes(timestampMs))); |
| 69 | return `${prefix}_${suffix}`; |
| 70 | }; |
| 71 | |
| 72 | export const generateOpaqueToken = (byteLength: number): string => { |
| 73 | const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); |
| 74 | return encodeBase64Url(bytes); |
| 75 | }; |
| 76 | |
| 77 | export const hashSha256 = async (value: string): Promise<Uint8Array> => { |
| 78 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); |
| 79 | return new Uint8Array(digest); |
| 80 | }; |
| 81 | |
| 82 | export const extractTimestampFromDurableEntityId = (id: string): number | null => { |
| 83 | const separatorIndex = id.indexOf("_"); |
| 84 | if (separatorIndex === -1) { |
| 85 | return null; |
| 86 | } |
| 87 | |
| 88 | const suffix = id.slice(separatorIndex + 1); |
| 89 | if (suffix.length !== BASE62_WIDTH) { |
| 90 | return null; |
| 91 | } |
| 92 | |
| 93 | try { |
| 94 | const value = fromBase62(suffix); |
| 95 | const bytes = new Uint8Array(16); |
| 96 | let remainder = value; |
| 97 | |
| 98 | for (let index = 15; index >= 0; index -= 1) { |
| 99 | bytes[index] = Number(remainder & 0xffn); |
| 100 | remainder >>= 8n; |
| 101 | } |
| 102 | |
| 103 | let timestamp = 0n; |
| 104 | for (let index = 0; index < 6; index += 1) { |
| 105 | timestamp = (timestamp << 8n) | BigInt(bytes[index]); |
| 106 | } |
| 107 | |
| 108 | return Number(timestamp); |
| 109 | } catch { |
| 110 | return null; |
| 111 | } |
| 112 | }; |