diff options
| -rw-r--r-- | src/app/store.svelte.ts | 3 | ||||
| -rw-r--r-- | src/app/uuid.test.ts | 31 | ||||
| -rw-r--r-- | src/app/uuid.ts | 65 |
3 files changed, 98 insertions, 1 deletions
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 914e682..bd3f82f 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -17,6 +17,7 @@ import type { ConversationCache } from "../features/conversation-cache"; import { createConversationCache } from "../features/conversation-cache"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; +import { randomId } from "./uuid"; export interface AppStore { readonly catalog: ProtocolState["catalog"]; @@ -83,7 +84,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; - const conversationId = opts?.conversationId ?? crypto.randomUUID(); + const conversationId = opts?.conversationId ?? randomId(); const cache: ConversationCache = createConversationCache( createIdbChunkStore({ indexedDB: indexedDBFactory }), diff --git a/src/app/uuid.test.ts b/src/app/uuid.test.ts new file mode 100644 index 0000000..bd8e306 --- /dev/null +++ b/src/app/uuid.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { randomId } from "./uuid"; + +const V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe("randomId", () => { + it("returns a v4-shaped uuid", () => { + const id = randomId(); + expect(id).toMatch(V4_RE); + }); + + it("returns distinct values across calls", () => { + const ids = new Set<string>(); + for (let i = 0; i < 200; i++) { + ids.add(randomId()); + } + expect(ids.size).toBe(200); + }); + + it("works without crypto.randomUUID (getRandomValues branch)", () => { + const origRandomUUID = crypto.randomUUID; + try { + // Remove randomUUID so the getRandomValues branch is taken + delete (crypto as { randomUUID?: () => string }).randomUUID; + const id = randomId(); + expect(id).toMatch(V4_RE); + } finally { + crypto.randomUUID = origRandomUUID; + } + }); +}); diff --git a/src/app/uuid.ts b/src/app/uuid.ts new file mode 100644 index 0000000..ae39d4d --- /dev/null +++ b/src/app/uuid.ts @@ -0,0 +1,65 @@ +const HEX = "0123456789abcdef"; + +function hexChar(n: number): string { + return HEX.charAt(n & 0xf); +} + +function hexFromBytes(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i] as number; + out += hexChar(b >> 4); + out += hexChar(b); + } + return out; +} + +function formatV4(rand: Uint8Array): string { + const h = hexFromBytes(rand); + return ( + h.slice(0, 8) + + "-" + + h.slice(8, 12) + + "-4" + + h.slice(13, 16) + + "-" + + ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") + + h.slice(18, 20) + + "-" + + h.slice(20, 32) + ); +} + +function uuidFromGetRandomValues(): string { + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + buf[6] = ((buf[6] as number) & 0x0f) | 0x40; + buf[8] = ((buf[8] as number) & 0x3f) | 0x80; + return formatV4(buf); +} + +function uuidFromMathRandom(): string { + let s = ""; + for (let i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + s += "-"; + } else if (i === 14) { + s += "4"; + } else if (i === 19) { + s += hexChar(Math.floor(Math.random() * 4) + 8); + } else { + s += hexChar(Math.floor(Math.random() * 16)); + } + } + return s; +} + +export function randomId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + return uuidFromGetRandomValues(); + } + return uuidFromMathRandom(); +} |
