From 95287f743536e05a978c29140b3c486eef6cd7b1 Mon Sep 17 00:00:00 2001 From: moran Date: Tue, 11 Aug 2026 15:40:13 +0800 Subject: [PATCH] fix(webui): harden PWA service worker caching and registration - Exclude /webui/* endpoints from the service worker cache; the /webui/bootstrap endpoint issues fresh gateway credentials on every load and must never be cached or replayed offline. - Restrict cache-first handling to hashed /assets/ files (served immutable). Un-hashed brand icons and the favicon stay network-first so future icon swaps reach installed clients. - Prune stale hashed assets whenever the app shell refreshes, so old build assets cannot accumulate even when sw.js itself is unchanged. - Serve the cached app shell for offline deep-link navigations. - Ignore service worker registration failures; add unit tests for the service worker and the main-entry registration. --- webui/public/sw.js | 85 +++++- webui/src/main.tsx | 11 +- ...main-pwa-registration-unavailable.test.tsx | 39 +++ .../src/tests/main-pwa-registration.test.tsx | 44 +++ webui/src/tests/sw.test.ts | 288 ++++++++++++++++++ 5 files changed, 451 insertions(+), 16 deletions(-) create mode 100644 webui/src/tests/main-pwa-registration-unavailable.test.tsx create mode 100644 webui/src/tests/main-pwa-registration.test.tsx create mode 100644 webui/src/tests/sw.test.ts diff --git a/webui/public/sw.js b/webui/public/sw.js index 4ebaa63b4..1a0b1e7cf 100644 --- a/webui/public/sw.js +++ b/webui/public/sw.js @@ -8,15 +8,52 @@ self.addEventListener("install", (event) => { self.skipWaiting(); }); +// Collect same-origin paths referenced by the given HTML document. +function referencedAssetPaths(html) { + const refs = new Set(); + const re = /(?:src|href)="(\/[^"]*)"/g; + let match; + while ((match = re.exec(html))) { + const url = new URL(match[1], self.location.origin); + if (url.origin === self.location.origin) refs.add(url.pathname + url.search); + } + return refs; +} + +// Drop cached entries that the current index.html no longer references. +// CACHE_NAME is stable across deployments, so without this, hashed assets from +// previous builds would pile up in the same cache forever. The cached +// index.html is the latest one this client saw (navigation is network-first +// and overwrites it on every successful visit), so pruning against it keeps +// the offline shell consistent with the last loaded build. +async function pruneStaleEntries() { + const cache = await caches.open(CACHE_NAME); + const cachedIndex = await cache.match("/"); + if (!cachedIndex) return; + const refs = referencedAssetPaths(await cachedIndex.text()); + const keys = await cache.keys(); + await Promise.all( + keys.map(async (request) => { + const url = new URL(request.url); + if (url.pathname === "/" || url.pathname === "/manifest.json") return; + if (refs.has(url.pathname + url.search)) return; + await cache.delete(request); + }) + ); +} + self.addEventListener("activate", (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys - .filter((k) => k !== CACHE_NAME) - .map((k) => caches.delete(k)) + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE_NAME) + .map((k) => caches.delete(k)) + ) ) - ) + .then(() => pruneStaleEntries()) ); self.clients.claim(); }); @@ -24,24 +61,37 @@ self.addEventListener("activate", (event) => { self.addEventListener("fetch", (event) => { const { request } = event; - // Only handle same-origin GET requests + // Only handle same-origin GET requests. Requests are handed to fetch() as-is + // (never reconstructed), so their credentials mode is preserved and gateway + // auth cookies flow through on every path we touch. WebSocket upgrades are + // never dispatched to a service worker's fetch handler, so the WS endpoint + // cannot be cached; the /__nanobot exclusion below still protects its HTTP + // polling/socket bootstrap endpoints. if (request.method !== "GET") return; if (new URL(request.url).origin !== self.location.origin) return; const url = new URL(request.url); const path = url.pathname; - // Never cache API, auth, WebSocket, or HMR paths + // Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In + // particular /webui/bootstrap issues fresh gateway credentials on every page + // load and must never be cached or replayed offline. The /auth prefix covers + // the default token endpoint; custom token_issue_path values should be kept + // under one of these prefixes. if ( path.startsWith("/api") || path.startsWith("/auth") || - path.startsWith("/__nanobot") + path.startsWith("/__nanobot") || + path.startsWith("/webui") ) { return; } - // Static assets: cache-first (immutable by gateway) - if (/\.(js|css|png|webp|ico|svg|woff2?|ttf|eot)$/.test(path)) { + // Static assets: cache-first. Only files under /assets/ carry content hashes + // (the gateway serves them immutable); brand icons, the favicon and other + // un-hashed files can change between releases and stay on the network-first + // path below so updates reach installed clients. + if (path.startsWith("/assets/")) { event.respondWith( caches.match(request).then((cached) => { if (cached) return cached; @@ -57,16 +107,25 @@ self.addEventListener("fetch", (event) => { return; } - // Everything else: network-first (index.html, manifest, etc.) + // Everything else: network-first (index.html, manifest, brand assets, etc.) event.respondWith( fetch(request) .then((response) => { if (response.ok) { const clone = response.clone(); caches.open(CACHE_NAME).then((c) => c.put(request, clone)); + // The shell just changed; prune entries the new index.html no longer + // references so hashed assets from old builds do not accumulate even + // when sw.js itself is unchanged between deployments. + if (path === "/") pruneStaleEntries(); } return response; }) - .catch(() => caches.match(request)) + .catch(() => { + // Offline: serve the app shell for navigations (deep links resolve + // client-side), the last cached copy for everything else. + if (request.mode === "navigate") return caches.match("/"); + return caches.match(request); + }) ); }); diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 24fc02fbb..a8343c699 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -31,8 +31,13 @@ ReactDOM.createRoot(root).render(); if ("serviceWorker" in navigator) { window.addEventListener("load", () => { - navigator.serviceWorker.register("/sw.js", { - updateViaCache: "none", - }); + navigator.serviceWorker + .register("/sw.js", { + updateViaCache: "none", + }) + .catch(() => { + // Service workers are progressive enhancement; registration failures + // (unsupported proxies, blocked storage) must not break the app. + }); }); } diff --git a/webui/src/tests/main-pwa-registration-unavailable.test.tsx b/webui/src/tests/main-pwa-registration-unavailable.test.tsx new file mode 100644 index 000000000..d49c90999 --- /dev/null +++ b/webui/src/tests/main-pwa-registration-unavailable.test.tsx @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const render = vi.fn(); +const createRoot = vi.fn(() => ({ render })); + +vi.mock("react-dom/client", () => ({ + default: { createRoot }, +})); + +vi.mock("@/App", () => ({ + default: () => null, +})); + +describe("service worker registration when unsupported", () => { + const register = vi.fn(() => Promise.resolve()); + + beforeEach(() => { + vi.resetModules(); + createRoot.mockClear(); + render.mockClear(); + register.mockClear(); + document.body.innerHTML = '
'; + // happy-dom's Navigator has no serviceWorker member, so this file runs + // without defining one — the entry's `"serviceWorker" in navigator` guard + // must keep registration from being attempted. + }); + + afterEach(() => { + document.body.innerHTML = ""; + delete (navigator as Navigator & { serviceWorker?: unknown }).serviceWorker; + }); + + it("does not attempt registration when the service worker API is missing", async () => { + await import("../main"); + window.dispatchEvent(new Event("load")); + + expect(register).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/tests/main-pwa-registration.test.tsx b/webui/src/tests/main-pwa-registration.test.tsx new file mode 100644 index 000000000..6f8ee2213 --- /dev/null +++ b/webui/src/tests/main-pwa-registration.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const render = vi.fn(); +const createRoot = vi.fn(() => ({ render })); + +vi.mock("react-dom/client", () => ({ + default: { createRoot }, +})); + +vi.mock("@/App", () => ({ + default: () => null, +})); + +describe("service worker registration in main entry", () => { + const register = vi.fn(() => Promise.resolve()); + + beforeEach(() => { + vi.resetModules(); + createRoot.mockClear(); + render.mockClear(); + register.mockClear(); + document.body.innerHTML = '
'; + Object.defineProperty(navigator, "serviceWorker", { + value: { register }, + configurable: true, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + delete (navigator as Navigator & { serviceWorker?: unknown }).serviceWorker; + }); + + it("registers /sw.js with updateViaCache none only after window load", async () => { + await import("../main"); + + expect(register).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event("load")); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith("/sw.js", { updateViaCache: "none" }); + }); +}); diff --git a/webui/src/tests/sw.test.ts b/webui/src/tests/sw.test.ts new file mode 100644 index 000000000..3577a47ca --- /dev/null +++ b/webui/src/tests/sw.test.ts @@ -0,0 +1,288 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Vitest runs with the webui/ directory as the working directory. +const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); + +const ORIGIN = "https://nanobot.test"; +const CACHE_NAME = "nanobot-static-v1"; + +/** Minimal Cache-compatible in-memory store with SW-style URL normalization. */ +class FakeCacheStore { + entries = new Map(); + + private key(input: Request | string): string { + return new URL(typeof input === "string" ? input : input.url, ORIGIN).href; + } + + async addAll(urls: string[]): Promise { + for (const url of urls) { + this.entries.set(this.key(url), new Response("ok")); + } + } + + async match(input: Request | string): Promise { + return this.entries.get(this.key(input)); + } + + async put(input: Request | string, response: Response): Promise { + this.entries.set(this.key(input), response); + } + + async delete(input: Request | string): Promise { + return this.entries.delete(this.key(input)); + } + + async keys(): Promise { + return [...this.entries.keys()].map((url) => new Request(url)); + } +} + +interface LoadedSw { + store: FakeCacheStore; + deletedCacheNames: string[]; + fetchMock: ReturnType; + skipWaitingMock: ReturnType; + claimMock: ReturnType; + fire: (type: string, event?: Record) => Promise; +} + +function loadSw(): LoadedSw { + const listeners = new Map) => void>>(); + const skipWaitingMock = vi.fn(); + const claimMock = vi.fn(); + const self = { + location: { origin: ORIGIN }, + skipWaiting: skipWaitingMock, + clients: { claim: claimMock }, + addEventListener: (type: string, cb: (event: Record) => void) => { + listeners.set(type, [...(listeners.get(type) ?? []), cb]); + }, + }; + const store = new FakeCacheStore(); + const deletedCacheNames: string[] = []; + const caches = { + open: vi.fn(async () => store), + match: vi.fn((input: Request | string) => store.match(input)), + keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v0"]), + delete: vi.fn(async (name: string) => { + deletedCacheNames.push(name); + return true; + }), + }; + const fetchMock = vi.fn(); + // Execute the plain public/sw.js script inside a controlled scope so the + // service worker globals (self/caches/fetch) are fully mocked. + new Function("self", "caches", "fetch", SW_SCRIPT)(self, caches, fetchMock); + + const fire = async (type: string, event: Record = {}) => { + for (const cb of listeners.get(type) ?? []) { + let waitPromise: Promise = Promise.resolve(); + const wrapped = { + ...event, + waitUntil: (promise: Promise) => { + waitPromise = promise; + }, + }; + await cb(wrapped); + await waitPromise; + } + }; + + return { store, deletedCacheNames, fetchMock, skipWaitingMock, claimMock, fire }; +} + +function indexHtml(assetPaths: string[]): Response { + const refs = assetPaths + .map( + (path) => + ``, + ) + .join(""); + return new Response(`${refs}`); +} + +function fetchEvent(url: string) { + const respondWith = vi.fn(); + const event = { + request: new Request(url, { method: "GET" }), + respondWith, + }; + return { event, respondWith }; +} + +describe("service worker", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("precaches the shell and manifest on install", async () => { + const sw = loadSw(); + + await sw.fire("install"); + + expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1); + expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); + expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true); + }); + + it("removes stale cache names and prunes unreferenced static entries on activate", async () => { + const sw = loadSw(); + await sw.fire("install"); + // Simulate the latest loaded build: index.html references only v2 assets. + await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js", "/assets/index-v2.css"])); + await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2")); + await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css")); + await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1")); + await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css")); + + await sw.fire("activate"); + + expect(sw.deletedCacheNames).toEqual(["nanobot-static-v0"]); + expect(sw.claimMock).toHaveBeenCalledTimes(1); + expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); + expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true); + expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(true); + expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).toBe(true); + expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false); + expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false); + }); + + it("does not intercept API, auth, WebSocket, or WebUI endpoint requests", async () => { + const sw = loadSw(); + const paths = [ + "/api/v1/models", + "/auth/session", + "/__nanobot/ws", + "/__nanobot/socket.io", + "/api/chat/completions", + "/webui/bootstrap", + "/webui/session/list", + ]; + + for (const path of paths) { + const { respondWith } = fetchEvent(`${ORIGIN}${path}`); + await sw.fire("fetch", { request: new Request(`${ORIGIN}${path}`) }); + expect(respondWith, path).not.toHaveBeenCalled(); + } + expect(sw.fetchMock).not.toHaveBeenCalled(); + }); + + it("does not intercept non-GET or cross-origin requests", async () => { + const sw = loadSw(); + + const { respondWith: post } = fetchEvent(`${ORIGIN}/api/v1/models`); + await sw.fire("fetch", { + request: new Request(`${ORIGIN}/api/v1/models`, { method: "POST" }), + }); + expect(post).not.toHaveBeenCalled(); + + const { respondWith: cross } = fetchEvent("https://other.test/assets/app.js"); + await sw.fire("fetch", { + request: new Request("https://other.test/assets/app.js"), + }); + expect(cross).not.toHaveBeenCalled(); + expect(sw.fetchMock).not.toHaveBeenCalled(); + }); + + it("serves cached static assets without touching the network", async () => { + const sw = loadSw(); + const assetUrl = `${ORIGIN}/assets/index-a1b2c3.js`; + await sw.store.put(assetUrl, new Response("cached js")); + + const { event, respondWith } = fetchEvent(assetUrl); + await sw.fire("fetch", event); + + const response = (await respondWith.mock.calls[0][0]) as Response; + expect(response).toBeDefined(); + expect(await response.text()).toBe("cached js"); + expect(sw.fetchMock).not.toHaveBeenCalled(); + }); + + it("fetches static assets on miss and caches the response, passing the original request", async () => { + const sw = loadSw(); + const assetUrl = `${ORIGIN}/assets/index-x9y8z7.js`; + const originalRequest = new Request(assetUrl); + sw.fetchMock.mockResolvedValue(new Response("fresh js")); + + const event = { request: originalRequest, respondWith: vi.fn() }; + await sw.fire("fetch", event); + + const response = (await event.respondWith.mock.calls[0][0]) as Response; + expect(await response.text()).toBe("fresh js"); + // The exact request object is handed to fetch() — credentials mode and + // other properties are preserved, never reconstructed. + expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest); + expect(sw.store.entries.has(assetUrl)).toBe(true); + }); + + it("keeps un-hashed brand assets on the network-first path", async () => { + const sw = loadSw(); + const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`; + const originalRequest = new Request(iconUrl); + sw.fetchMock.mockResolvedValue(new Response("png bytes")); + + const event = { request: originalRequest, respondWith: vi.fn() }; + await sw.fire("fetch", event); + + const response = (await event.respondWith.mock.calls[0][0]) as Response; + expect(await response.text()).toBe("png bytes"); + // Unlike hashed /assets/ files, brand icons are fetched every load so a + // future icon swap reaches installed clients. + expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest); + expect(sw.store.entries.has(iconUrl)).toBe(true); + }); + + it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => { + const sw = loadSw(); + await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"])); + // Assets from an older build, still referenced by the cached v2 shell. + await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2")); + await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1")); + + // Offline: network rejects, cached shell is returned. + sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + const offlineEvent = { + request: new Request(`${ORIGIN}/`), + respondWith: vi.fn(), + }; + await sw.fire("fetch", offlineEvent); + const offlineResponse = (await offlineEvent.respondWith.mock.calls[0][0]) as Response; + expect(await offlineResponse.text()).toContain("assets/index-v2.js"); + + // Online: network response wins, refreshes the cached shell, and prunes + // entries the new index.html no longer references. + const freshShell = indexHtml(["/assets/index-v3.js"]); + sw.fetchMock.mockResolvedValue(freshShell); + const onlineEvent = { + request: new Request(`${ORIGIN}/`), + respondWith: vi.fn(), + }; + await sw.fire("fetch", onlineEvent); + const onlineResponse = (await onlineEvent.respondWith.mock.calls[0][0]) as Response; + expect(await onlineResponse.text()).toContain("assets/index-v3.js"); + // The prune is fire-and-forget; give its microtasks a chance to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); + expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false); + expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(false); + }); + + it("serves the cached app shell for offline deep-link navigations", async () => { + const sw = loadSw(); + await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"])); + sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + + // happy-dom's Request ignores `mode: "navigate"` in the constructor; + // force it so the service worker treats this as a navigation. + const request = new Request(`${ORIGIN}/settings`); + Object.defineProperty(request, "mode", { value: "navigate" }); + const event = { request, respondWith: vi.fn() }; + await sw.fire("fetch", event); + + const response = (await event.respondWith.mock.calls[0][0]) as Response; + expect(await response.text()).toContain("assets/index-v2.js"); + }); +});