diff --git a/src/components/MethodInvoker.tsx b/src/components/MethodInvoker.tsx index 2655bcb..9ecc379 100644 --- a/src/components/MethodInvoker.tsx +++ b/src/components/MethodInvoker.tsx @@ -8,6 +8,7 @@ import { fromCurl, rpcParams, toCurl } from '../lib/curl'; import { formatBytes, formatDuration, jsonByteSize } from '../lib/format'; import { createRequest, type JsonRpcParams, type JsonRpcRequest } from '../lib/rpc'; import type { JsonSchema } from '../lib/smdToJsonSchema'; +import { randomUUID } from '../lib/uuid'; import { useStore } from '../store/store'; import { FormFromSchema } from './FormFromSchema'; import { JsonViewer } from './JsonViewer'; @@ -65,7 +66,7 @@ export function MethodInvoker({ schema, method, endpoint, headers }: MethodInvok setMeta({ durationMs: performance.now() - startedAtRef.current, size: jsonByteSize(resp) }); setLastResult({ value: resp.error ?? resp.result, errored: Boolean(resp.error) }); addHistory({ - id: crypto.randomUUID(), + id: randomUUID(), method, params, response: resp.result, @@ -77,7 +78,7 @@ export function MethodInvoker({ schema, method, endpoint, headers }: MethodInvok onError: (err) => { if (isAbort(err)) return; addHistory({ - id: crypto.randomUUID(), + id: randomUUID(), method, params, error: { message: String(err) }, diff --git a/src/lib/__tests__/uuid.test.ts b/src/lib/__tests__/uuid.test.ts new file mode 100644 index 0000000..d6ab246 --- /dev/null +++ b/src/lib/__tests__/uuid.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, afterEach } from 'vitest'; + +import { randomUUID } from '../uuid'; + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('randomUUID', () => { + const original = crypto.randomUUID; + + afterEach(() => { + Object.defineProperty(crypto, 'randomUUID', { value: original, configurable: true, writable: true }); + }); + + it('returns a valid RFC 4122 v4 UUID', () => { + expect(randomUUID()).toMatch(V4); + }); + + it('returns unique values', () => { + expect(randomUUID()).not.toBe(randomUUID()); + }); + + it('falls back to getRandomValues when crypto.randomUUID is unavailable (insecure context)', () => { + // Simulate a non-secure context (plain HTTP) where randomUUID is missing. + Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true, writable: true }); + expect(randomUUID()).toMatch(V4); + }); +}); diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index 0bb3ea3..7eef5ba 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -4,6 +4,8 @@ * adding a request timeout and cancellation (see UX_AUDIT.md 3.8). */ +import { randomUUID } from './uuid'; + export type JsonRpcParams = Record | unknown[]; export interface JsonRpcRequest { @@ -28,7 +30,7 @@ export interface JsonRpcResponse { /** Builds a JSON-RPC 2.0 request envelope with a fresh id. */ export function createRequest(method: string, params: JsonRpcParams = {}): JsonRpcRequest { - return { id: crypto.randomUUID(), jsonrpc: '2.0', method, params }; + return { id: randomUUID(), jsonrpc: '2.0', method, params }; } /** Request headers with the JSON content type applied. */ diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 0000000..6f8283f --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,25 @@ +/** + * Returns an RFC 4122 version 4 UUID. + * + * `crypto.randomUUID()` is only exposed in secure contexts (HTTPS or + * `http://localhost`). SMDBox is frequently served over plain HTTP on an IP + * address (e.g. straight from a JSON-RPC backend during development), where + * `crypto.randomUUID` is `undefined` and calling it throws + * `TypeError: crypto.randomUUID is not a function`, crashing the app. + * + * `crypto.getRandomValues()` is available in insecure contexts too, so we fall + * back to it when `randomUUID` is missing. + */ +export function randomUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const at = (i: number): number => bytes[i] ?? 0; + bytes[6] = (at(6) & 0x0f) | 0x40; // version 4 + bytes[8] = (at(8) & 0x3f) | 0x80; // variant 10xx + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/src/store/store.ts b/src/store/store.ts index 30918cf..512ba58 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { writeState } from '../lib/persist'; import type { JsonRpcParams } from '../lib/rpc'; +import { randomUUID } from '../lib/uuid'; // Soft cap to keep IndexedDB bounded; the old hard limit of 10 is gone. const HISTORY_LIMIT = 500; @@ -224,7 +225,7 @@ export const useStore = create((set) => ({ saveRequest: (name, method, params) => set((s) => ({ - saved: [...s.saved, { id: crypto.randomUUID(), name, method, params, ts: Date.now() }], + saved: [...s.saved, { id: randomUUID(), name, method, params, ts: Date.now() }], })), deleteSaved: (id) => set((s) => ({ saved: s.saved.filter((r) => r.id !== id) })), @@ -249,7 +250,7 @@ export const useStore = create((set) => ({ set((s) => ({ savedResponses: [ ...s.savedResponses, - { id: crypto.randomUUID(), name, method, response, ts: Date.now() }, + { id: randomUUID(), name, method, response, ts: Date.now() }, ], })), @@ -308,7 +309,7 @@ export const useStore = create((set) => ({ activeEnvironmentId: existing.id, }; } - const id = crypto.randomUUID(); + const id = randomUUID(); return { environments: [...s.environments, { id, name: trimmed, ...snapshot }], activeEnvironmentId: id,