fix(ui): revoke stale offline entries when responses become private

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-09-19 08:57:49 -07:00
1 parent 34e13e5439
commit 8509dc3f07
2 files changed
+52 -7

No files matched your search

+29 -4
View File
@@ -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();
+23 -3
View File
@@ -7,17 +7,18 @@ function worker() {
const handlers = new Map<string, (event: unknown) => 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();
});
});