From 2f42baf8a1800c15564f4da78a4f1795c5481747 Mon Sep 17 00:00:00 2001 From: RuoJi6 <79234113+RuoJi6@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:22:24 -0700 Subject: [PATCH] fix: support Repeater workspace over HTTP LAN origins --- web/src/components/repeater/repeater-state.ts | 8 ++++- .../repeater/repeater-store.test.mjs | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/web/src/components/repeater/repeater-state.ts b/web/src/components/repeater/repeater-state.ts index 8279147..5fa8bff 100644 --- a/web/src/components/repeater/repeater-state.ts +++ b/web/src/components/repeater/repeater-state.ts @@ -41,8 +41,14 @@ export type TabSeed = Partial< >; export type TabOptions = { groupId?: string; title?: string; activate?: boolean }; +let fallbackIdSequence = 0; + export function newId(): string { - return crypto.randomUUID(); + // Local workspace IDs must also work on HTTP LAN origins without randomUUID. + return ( + globalThis.crypto?.randomUUID?.() ?? + `repeater-${Date.now().toString(36)}-${(++fallbackIdSequence).toString(36)}-${Math.random().toString(36).slice(2, 10)}` + ); } export function emptyTab(groupId = DEFAULT_GROUP_ID, title = "Tab 1"): RepeaterTab { diff --git a/web/src/components/repeater/repeater-store.test.mjs b/web/src/components/repeater/repeater-store.test.mjs index a0f47c9..0ff16be 100644 --- a/web/src/components/repeater/repeater-store.test.mjs +++ b/web/src/components/repeater/repeater-store.test.mjs @@ -15,6 +15,7 @@ const { MAX_HISTORY, MAX_RESPONSE_CHARS, MAX_STORAGE_BYTES, + newId, seedWorkspace, serializeWorkspace, truncateResponse, @@ -357,3 +358,38 @@ test("migrated HTTP helpers retain raw request and response formatting", () => { assert.equal(headersFromReqHead("\r\nGET / HTTP/1.1\r\nHost: example.test\r\n"), "Host: example.test"); assert.equal(buildRawResponse(response()), "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nok"); }); + +test("workspace initializes and adds distinct tabs on HTTP origins without randomUUID", (t) => { + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + Object.defineProperty(globalThis, "crypto", { configurable: true, value: {} }); + t.after(() => { + if (originalCrypto) Object.defineProperty(globalThis, "crypto", originalCrypto); + else delete globalThis.crypto; + }); + const workspace = defaultWorkspace(); + const updated = addTab(workspace, { url: "https://example.test/" }); + assert.equal(updated.tabs.length, 2); + assert.notEqual(updated.tabs[0].id, updated.tabs[1].id); + assert.equal(deserializeWorkspace(null).tabs.length, 1); + assert.equal(new Set(Array.from({ length: 100 }, () => newId())).size, 100); +}); + +test("fallback IDs stay distinct without crypto even when clock and randomness repeat", (t) => { + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined }); + t.after(() => { + if (originalCrypto) Object.defineProperty(globalThis, "crypto", originalCrypto); + else delete globalThis.crypto; + }); + t.mock.method(Date, "now", () => 1700000000000); + t.mock.method(Math, "random", () => 0); + const ids = Array.from({ length: 100 }, () => newId()); + assert.ok(ids.every((id) => typeof id === "string" && id.length > 0)); + assert.equal(new Set(ids).size, ids.length); +}); + +test("secure origins retain native UUID generation", (t) => { + const native = t.mock.method(globalThis.crypto, "randomUUID", () => "native-uuid"); + assert.equal(newId(), "native-uuid"); + assert.equal(native.mock.callCount(), 1); +});