fix(FN-0000): prevent stale dashboard service worker reload traps
This commit is contained in:
@@ -67,6 +67,32 @@ describe("PWA configuration", () => {
|
||||
expect(swSource).toMatch(/fusion-cache-v\d+/);
|
||||
});
|
||||
|
||||
it("service worker bypasses SSE requests instead of trying to cache them", () => {
|
||||
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
|
||||
|
||||
expect(swSource).toContain('text/event-stream');
|
||||
expect(swSource).toContain('url.pathname === "/api/events"');
|
||||
expect(swSource).toContain('url.pathname.startsWith("/api/events/")');
|
||||
expect(swSource).toContain("if (isEventStreamRequest) {");
|
||||
expect(swSource).toContain("return;");
|
||||
});
|
||||
|
||||
it("service worker revalidates navigation requests so index.html cannot stay stale", () => {
|
||||
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
|
||||
|
||||
expect(swSource).toContain('request.mode === "navigate"');
|
||||
expect(swSource).toContain('request.destination === "document"');
|
||||
expect(swSource).toContain('url.pathname === "/index.html"');
|
||||
expect(swSource).toContain('[sw] navigation cache put failed');
|
||||
});
|
||||
|
||||
it("service worker activates updated code immediately", () => {
|
||||
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
|
||||
|
||||
expect(swSource).toContain("await self.skipWaiting()");
|
||||
expect(swSource).toContain("await self.clients.claim()");
|
||||
});
|
||||
|
||||
describe("logo assets", () => {
|
||||
it("logo.svg uses ring + swoosh geometry matching Header.tsx brand mark", () => {
|
||||
const logoSvg = readFileSync(resolve(__dirname, "../public/logo.svg"), "utf8");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "fusion-cache-v1";
|
||||
const CACHE_NAME = "fusion-cache-v2";
|
||||
const APP_SHELL_URLS = [
|
||||
"/",
|
||||
"/index.html",
|
||||
@@ -13,6 +13,7 @@ self.addEventListener("install", (event) => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.addAll(APP_SHELL_URLS);
|
||||
await self.skipWaiting();
|
||||
} catch (error) {
|
||||
console.warn("[sw] install cache warmup failed", error);
|
||||
}
|
||||
@@ -43,7 +44,50 @@ self.addEventListener("fetch", (event) => {
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
const isApiRequest = url.pathname.startsWith("/api/");
|
||||
const isEventStreamRequest =
|
||||
accept.includes("text/event-stream") ||
|
||||
url.pathname === "/api/events" ||
|
||||
url.pathname.startsWith("/api/events/");
|
||||
const isNavigationRequest =
|
||||
request.mode === "navigate" ||
|
||||
request.destination === "document" ||
|
||||
url.pathname === "/" ||
|
||||
url.pathname === "/index.html";
|
||||
|
||||
// EventSource requests stay open indefinitely. Waiting on cache.put() for an
|
||||
// infinite response body prevents the browser from ever receiving the stream
|
||||
// and leaks the underlying connection across reloads. Let SSE bypass the
|
||||
// service worker entirely so the browser talks to the network directly.
|
||||
if (isEventStreamRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always revalidate the HTML shell so navigation picks up the latest hashed
|
||||
// asset names instead of getting stuck on a cached index.html that points at
|
||||
// a stale bundle.
|
||||
if (isNavigationRequest) {
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put(request, networkResponse.clone());
|
||||
} catch (cacheError) {
|
||||
console.warn("[sw] navigation cache put failed", cacheError);
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (networkError) {
|
||||
const fallback = await caches.match(request);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
throw networkError;
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isApiRequest) {
|
||||
event.respondWith((async () => {
|
||||
|
||||
@@ -498,7 +498,6 @@ export function createSSE(
|
||||
// --- Cleanup (all handlers are defined above, safe to reference) ---
|
||||
|
||||
let cleaned = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
let clientStaleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function resetClientStaleTimer(): void {
|
||||
@@ -517,7 +516,7 @@ export function createSSE(
|
||||
activeConnections--;
|
||||
console.log(`[sse] - connection (active=${activeConnections})`);
|
||||
if (clientStaleTimer) clearTimeout(clientStaleTimer);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
clearInterval(heartbeat);
|
||||
store.off("task:created", onCreated);
|
||||
store.off("task:moved", onMoved);
|
||||
store.off("task:updated", onUpdated);
|
||||
@@ -684,7 +683,7 @@ export function createSSE(
|
||||
});
|
||||
resetClientStaleTimer();
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
const heartbeat = setInterval(() => {
|
||||
send("event: heartbeat\ndata: \n\n");
|
||||
}, 30_000);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user