diff --git a/ui/public/sw.js b/ui/public/sw.js index 4f9da070a7a..12a8c73c5d9 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -6,6 +6,15 @@ // dev, where HMR (not the worker) drives refreshes. const BUILD_ID = "__PAPERCLIP_BUILD_ID__"; const CACHE_NAME = `paperclip-${BUILD_ID}`; +const privateRequests = new Set(); +const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i; + +async function evictRequest(request) { + await Promise.all((await caches.keys()).map(async (key) => { + const cache = await caches.open(key); + await cache.delete(request, { ignoreVary: true }); + })); +} self.addEventListener("install", () => { self.skipWaiting(); @@ -26,28 +35,44 @@ self.addEventListener("fetch", (event) => { // Explicitly private requests must bypass BOTH cache writes and offline // fallback, including extension endpoints outside the host /api namespace. - if (request.method !== "GET" || url.pathname.startsWith("/api") || request.cache === "no-store") { + if (request.method !== "GET" || url.pathname.startsWith("/api")) { + return; + } + if (request.cache === "no-store") { + privateRequests.add(request.url); + event.waitUntil(evictRequest(request).catch(() => {})); return; } // Network-first for everything — cache is only an offline fallback event.respondWith( fetch(request) - .then((response) => { + .then(async (response) => { const cacheControl = response.headers.get("cache-control") ?? ""; - if (response.ok && url.origin === self.location.origin && !/(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i.test(cacheControl)) { + if (privateCacheControl.test(cacheControl)) { + // Revoke earlier cacheable responses too. Keep an in-memory denylist + // if storage is unavailable so offline fallback still fails closed. + privateRequests.add(request.url); + await evictRequest(request).catch(() => {}); + } else if (response.ok && url.origin === self.location.origin && !privateRequests.has(request.url)) { const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + await caches.open(CACHE_NAME).then(async (cache) => { + await cache.put(request, clone); + // A concurrent response may have revoked this URL during put(). + if (privateRequests.has(request.url)) await cache.delete(request, { ignoreVary: true }); + }).catch(() => {}); } return response; }) .catch(async () => { + if (privateRequests.has(request.url)) return Response.error(); // caches.match() resolves undefined on a miss (and the promise itself // is always truthy, so `||` can never supply a fallback). respondWith // must always receive a real Response — resolving undefined breaks // the navigation with "Failed to convert value to 'Response'" instead // of showing anything. if (request.mode === "navigate") { + if (privateRequests.has(new URL("/", self.location.origin).href)) return new Response("Offline", { status: 503 }); return (await caches.match("/")) ?? new Response("Offline", { status: 503 }); } return (await caches.match(request)) ?? Response.error(); diff --git a/ui/src/lib/service-worker-cache-policy.test.ts b/ui/src/lib/service-worker-cache-policy.test.ts index 802be4ef178..9dca26cc7f4 100644 --- a/ui/src/lib/service-worker-cache-policy.test.ts +++ b/ui/src/lib/service-worker-cache-policy.test.ts @@ -7,17 +7,18 @@ function worker() { const handlers = new Map void>(); const put = vi.fn(); const match = vi.fn(); + const remove = vi.fn().mockResolvedValue(true); const fetch = vi.fn().mockResolvedValue(new Response("public asset")); vm.runInNewContext(readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8"), { self: { location: { origin: "https://example.test" }, addEventListener: (type: string, fn: (event: unknown) => void) => handlers.set(type, fn) }, - URL, Response, fetch, caches: { open: async () => ({ put }), match }, + URL, Response, fetch, caches: { keys: async () => ["paperclip-old", "paperclip-current"], open: async () => ({ put, delete: remove }), match }, }); const request = (cache: RequestCache = "default") => { const respondWith = vi.fn(); - handlers.get("fetch")!({ request: new Request("https://example.test/extension/history", { cache }), respondWith }); + handlers.get("fetch")!({ request: new Request("https://example.test/extension/history", { cache }), respondWith, waitUntil: vi.fn() }); return respondWith; }; - return { request, fetch, put, match }; + return { request, fetch, put, match, remove }; } describe("service worker privacy boundaries", () => { @@ -40,4 +41,23 @@ describe("service worker privacy boundaries", () => { await w.request().mock.calls[0]![0]; expect(w.put).toHaveBeenCalledOnce(); }); + it.each(["private", "no-store"])("evicts stale entries when a response becomes %s and blocks offline reuse", async directive => { + const w = worker(); + w.match.mockResolvedValue(new Response("stale personal content")); + w.fetch.mockResolvedValue(new Response("fresh", { headers: { "cache-control": directive } })); + await w.request().mock.calls[0]![0]; + expect(w.remove).toHaveBeenCalledTimes(2); + expect(w.remove).toHaveBeenCalledWith(expect.any(Request), { ignoreVary: true }); + w.fetch.mockRejectedValue(new Error("offline")); + const offline = await w.request().mock.calls[0]![0]; + expect(offline.type).toBe("error"); expect(w.match).not.toHaveBeenCalled(); + }); + it("does not serve stale data when cache eviction itself fails", async () => { + const w = worker(); w.remove.mockRejectedValue(new Error("cache unavailable")); + w.fetch.mockResolvedValue(new Response("fresh", { headers: { "cache-control": "private" } })); + expect(await (await w.request().mock.calls[0]![0]).text()).toBe("fresh"); + w.fetch.mockRejectedValue(new Error("offline")); + expect((await w.request().mock.calls[0]![0]).type).toBe("error"); + expect(w.match).not.toHaveBeenCalled(); + }); });