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");
+ });
+});