mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
fix(ui): revoke stale offline entries when responses become private
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
34e13e5439
commit
8509dc3f07
2 files changed
+52
-7
No files matched your search
+29
-4
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user