Newer
Older
gnexus-creds / frontend / public / sw.js
/* 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 });
  }
}