diff --git a/packages/app/src/shell/api-client/create-client-runtime-helpers.ts b/packages/app/src/shell/api-client/create-client-runtime-helpers.ts index 0bb36c4..9dee1a0 100644 --- a/packages/app/src/shell/api-client/create-client-runtime-helpers.ts +++ b/packages/app/src/shell/api-client/create-client-runtime-helpers.ts @@ -21,8 +21,45 @@ export const supportsRequestInitExt = (): boolean => ( && typeof process.versions["undici"] === "string" ) +const fallbackRandomIdLength = 9 +const fallbackRandomHexBytes = 5 +let fallbackRequestCounter = 0 + +const truncateRandomId = (value: string): string => value.slice(0, fallbackRandomIdLength) + +const webCrypto = (): Crypto | undefined => ( + "crypto" in globalThis ? globalThis.crypto : undefined +) + +const randomIdFromUuid = (): string | undefined => { + const cryptoApi = webCrypto() + return typeof cryptoApi?.randomUUID === "function" + ? truncateRandomId(cryptoApi.randomUUID().replaceAll("-", "")) + : undefined +} + +const randomIdFromGetRandomValues = (): string | undefined => { + const cryptoApi = webCrypto() + if (typeof cryptoApi?.getRandomValues !== "function") { + return undefined + } + + const bytes = new Uint8Array(fallbackRandomHexBytes) + cryptoApi.getRandomValues(bytes) + return truncateRandomId(Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("")) +} + +const randomIdFromClock = (): string => { + const timestamp = Date.now().toString(16) + const counter = fallbackRequestCounter.toString(16).padStart(2, "0") + fallbackRequestCounter = (fallbackRequestCounter + 1) % 256 + return `${timestamp}${counter}`.slice(-fallbackRandomIdLength).padStart(fallbackRandomIdLength, "0") +} + export const randomID = (): string => ( - globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 9) + randomIdFromUuid() + ?? randomIdFromGetRandomValues() + ?? randomIdFromClock() ) const isQuerySerializerOptions = ( diff --git a/packages/app/tests/api-client/create-client-effect.test.ts b/packages/app/tests/api-client/create-client-effect.test.ts index 84157bf..9340007 100644 --- a/packages/app/tests/api-client/create-client-effect.test.ts +++ b/packages/app/tests/api-client/create-client-effect.test.ts @@ -9,7 +9,7 @@ // COMPLEXITY: O(1) per test import { Effect, Either } from "effect" -import { describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import type { paths } from "../../src/core/api/openapi.js" import type { ClientOptions } from "../../src/shell/api-client/create-client-types.js" @@ -34,6 +34,11 @@ const createMockFetch = ( const createFailingFetch = (message: string) => (_request: Request) => Effect.runPromise(Effect.fail(new Error(message))) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + describe("createClientEffect", () => { it("returns success envelope for login 200", () => Effect.gen(function*() { @@ -147,4 +152,56 @@ describe("createClientEffect", () => { } } }).pipe(Effect.runPromise)) + + it("uses getRandomValues when randomUUID is unavailable", () => + Effect.gen(function*() { + const capturedIds: Array = [] + vi.stubGlobal("crypto", { + getRandomValues: (values: Uint8Array): Uint8Array => { + values.set([0x10, 0x32, 0x54, 0x76, 0x98]) + return values + } + }) + + const apiClientEffect = createClientEffect({ + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(200, { "content-type": "application/json" }, JSON.stringify({ ok: true })) + }) + + apiClientEffect.use({ + onRequest: ({ id }) => { + capturedIds.push(id) + } + }) + + const result = yield* apiClientEffect.GET("/api/auth/me") + + expect(result.status).toBe(200) + expect(capturedIds).toEqual(["103254769"]) + }).pipe(Effect.runPromise)) + + it("falls back to a clock-based request id when Web Crypto is missing", () => + Effect.gen(function*() { + const capturedIds: Array = [] + vi.stubGlobal("crypto", undefined) + vi.spyOn(Date, "now").mockReturnValue(0x1234567) + + const apiClientEffect = createClientEffect({ + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(200, { "content-type": "application/json" }, JSON.stringify({ ok: true })) + }) + + apiClientEffect.use({ + onRequest: ({ id }) => { + capturedIds.push(id) + } + }) + + const result = yield* apiClientEffect.GET("/api/auth/me") + + expect(result.status).toBe(200) + expect(capturedIds).toEqual(["123456700"]) + }).pipe(Effect.runPromise)) })