// UUIDs per RFC 9562: version 4 (random, section 5.4), version 5 (name-based with SHA-1, // section 5.5) and version 7 (Unix-time ordered, section 5.7). import { toHex } from './encoding'; import { randomBytes } from './random'; export const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; /** Any RFC 9562 UUID of the given version (lowercase, hyphenated). */ export const uuidPattern = (version: number) => new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`); /** The namespace IDs predefined in RFC 9562, section 6.6. */ export const UUID_NAMESPACES = { DNS: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', URL: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', OID: '6ba7b812-9dad-11d1-80b4-00c04fd430c8', X500: '6ba7b814-9dad-11d1-80b4-00c04fd430c8', } as const; const ANY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export const isUuid = (value: string) => ANY_UUID.test(value.trim()); /** Formats 16 bytes as a UUID of `version`: sets the version and the variant (10) bits. */ export function uuidFromBytes(bytes: Uint8Array, version = 4): string { if (bytes.length !== 16) throw new RangeError(`a UUID needs 16 bytes, got ${bytes.length}`); const b = Uint8Array.from(bytes); b[6] = (b[6] & 0x0f) | (version << 4); b[8] = (b[8] & 0x3f) | 0x80; const hex = toHex(b); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } /** * One lowercase v4 UUID. crypto.randomUUID() where the browser provides it (secure contexts * only); otherwise the same construction from crypto.getRandomValues() bytes. Never Math.random(). */ export function uuidV4(): string { const source = globalThis.crypto; if (typeof source?.randomUUID === 'function') return source.randomUUID(); return uuidFromBytes(randomBytes(16)); } /** * Version 5: SHA-1 over the namespace ID bytes followed by the name (as UTF-8); the first 128 * bits become the UUID. Deterministic - the same namespace and name always give the same UUID - * so it is an identifier, never a secret. Needs crypto.subtle (secure contexts only). */ export async function uuidV5(namespace: string, name: string): Promise { if (!isUuid(namespace)) throw new RangeError(`namespace must be a UUID, got "${namespace}"`); const subtle = globalThis.crypto?.subtle; if (!subtle) throw new Error('UUID v5 needs crypto.subtle, available on secure (HTTPS) pages.'); const namespaceBytes = Uint8Array.from(namespace.replaceAll('-', '').match(/../g)!, (pair) => parseInt(pair, 16), ); const nameBytes = new TextEncoder().encode(name); const data = new Uint8Array(namespaceBytes.length + nameBytes.length); data.set(namespaceBytes); data.set(nameBytes, namespaceBytes.length); const digest = new Uint8Array(await subtle.digest('SHA-1', data)); return uuidFromBytes(digest.subarray(0, 16), 5); } /** * Version 7 from a Unix time in milliseconds and 16 random bytes: the time fills the first 48 * bits (big-endian), the random bytes the remaining 74 after version and variant. */ export function uuidV7FromParts(unixMs: number, random: Uint8Array): string { if (!Number.isInteger(unixMs) || unixMs < 0 || unixMs >= 2 ** 48) { throw new RangeError(`unixMs must be an integer in [0, 2^48), got ${unixMs}`); } const bytes = Uint8Array.from(random); let time = unixMs; for (let i = 5; i >= 0; i -= 1) { bytes[i] = time % 256; time = Math.floor(time / 256); } return uuidFromBytes(bytes, 7); } /** One version 7 UUID for the current time. */ export function uuidV7(now = Date.now()): string { return uuidV7FromParts(now, randomBytes(16)); } /** * A batch of version 7 UUIDs in ascending order. RFC 9562 (section 6.2) asks batch generators to * keep values increasing; sorting achieves that, because the text form sorts like the bytes. */ export function uuidV7Batch(count: number, now = Date.now()): string[] { return Array.from({ length: count }, () => uuidV7(now)).sort(); } /** The creation time encoded in a version 7 UUID. */ export function uuidV7Time(uuid: string): Date { return new Date(parseInt(uuid.replaceAll('-', '').slice(0, 12), 16)); }