/* GNEXUS CREDS service worker — hand-rolled, no build plugin.
*
* Security rule #1: API/auth/MCP traffic (cookie + secret payloads) is NEVER
* intercepted — the fetch handler returns before any Cache Storage call.
*
* Asset freshness: hashed /assets and public files use stale-while-revalidate
* — the cached copy is served immediately while a fresh copy is fetched in
* the background and replaces the cached entry only if bytes differ, so a
* server-side update propagates without a page reload.
*/
const VERSION = "gc-shell-v1";
const CACHE = `${VERSION}-shell`;
const SHELL = [
"/",
"/index.html",
"/logo.svg",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
];
const BYPASS_PREFIXES = ["/api/", "/auth/", "/mcp/", "/webhooks/", "/sw.js"];
function isBypass(url, request) {
if (request.method !== "GET") return true;
if (url.pathname === "/sw.js" || url.pathname === "/mcp-protocol") return true;
return BYPASS_PREFIXES.some((prefix) => url.pathname.startsWith(prefix));
}
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
await Promise.all(
SHELL.map((url) =>
cache.add(new Request(url, { cache: "reload" })).catch(() => {})
)
);
await self.skipWaiting();
})()
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
await self.clients.claim();
})()
);
});
self.addEventListener("message", (e) => {
if (e.data === "SKIP_WAITING") self.skipWaiting();
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || isBypass(url, event.request)) return;
if (event.request.mode === "navigate") {
event.respondWith(networkFirstNavigation(event));
} else if (
url.pathname.startsWith("/assets/") ||
url.pathname === "/logo.svg" ||
url.pathname.startsWith("/icons/") ||
url.pathname === "/manifest.webmanifest"
) {
event.respondWith(staleWhileRevalidate(event));
}
// everything else same-origin: default network handling
});
// Offline: network-first on navigations, cached shell as fallback.
const NAV_TIMEOUT_MS = 4000;
async function networkFirstNavigation(event) {
const request = event.request;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NAV_TIMEOUT_MS);
try {
const fresh = await fetch(request, { signal: controller.signal });
clearTimeout(timer);
const cache = await caches.open(CACHE);
// index.html is the canonical shell for every SPA route
cache.put("/", fresh.clone()).catch(() => {});
cache.put("/index.html", fresh.clone()).catch(() => {});
return fresh;
} catch {
clearTimeout(timer);
const cache = await caches.open(CACHE);
return (
(await cache.match("/")) ||
(await cache.match("/index.html")) ||
offlineFallbackPage()
);
}
}
function offlineFallbackPage() {
return new Response(
`<!doctype html><meta charset="utf-8"><title>GNEXUS CREDS</title>` +
`<body style="margin:0;background:#16161E;color:#C0CAF5;font-family:monospace;` +
`text-align:center;padding-top:40vh">` +
`<p style="font-size:18px;letter-spacing:.08em">GNEXUS CREDS — OFFLINE</p>` +
`<p>Reconnect to load the vault shell.</p>`,
{ status: 504, headers: { "Content-Type": "text/html; charset=utf-8" } }
);
}
function bufEqual(a, b) {
if (a.byteLength !== b.byteLength) return false;
const viewA = new Uint8Array(a);
const viewB = new Uint8Array(b);
for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
return true;
}
async function staleWhileRevalidate(event) {
const request = event.request;
const cache = await caches.open(CACHE);
const cached = await cache.match(request, {
ignoreSearch: request.url.includes("/assets/fonts/"),
});
// Serve a clone of `cached` and keep the original for the byte-compare:
// respondWith hands the clone to the page, which consumes its body, so
// cloning `cached` again below would race and throw "body is already used".
const served = cached ? cached.clone() : null;
// keep the background revalidation alive after we hand back a response
event.waitUntil(
(async () => {
try {
const fresh = await fetch(request);
if (!fresh.ok) return;
const body = await fresh.clone().arrayBuffer();
if (!cached || !bufEqual(await cached.arrayBuffer(), body)) {
await cache.put(request, fresh);
}
} catch {
// offline: keep whatever is cached
}
})()
);
if (served) return served;
try {
return await fetch(request);
} catch {
return new Response("", { status: 504 });
}
}