/** * Freezmo service worker (production only). * * Strategy, deliberately conservative: * - API routes are never cached and never intercepted. * - Navigations: network first, falling back to the cached shell, then /offline. * - /_next/static + icons: stale-while-revalidate (fast, self-healing). * - Everything else: straight to the network. * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ const CACHE_VERSION = "freezmo-v73"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; const SHELL_ASSETS = [ "/", "/offline", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/apple-touch-icon.png", ]; self.addEventListener("install", (event) => { event.waitUntil( (async () => { const cache = await caches.open(SHELL_CACHE); await Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(new Request(url, { cache: "reload" })))); await self.skipWaiting(); })(), ); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { const names = await caches.keys(); await Promise.all( names.filter((name) => !name.startsWith(CACHE_VERSION)).map((name) => caches.delete(name)), ); await self.clients.claim(); })(), ); }); function isCacheableAsset(url) { return ( url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/") || url.pathname === "/manifest.webmanifest" || /\.(?:css|js|png|jpg|jpeg|svg|webp|woff2?)$/.test(url.pathname) ); } /* ------------------------------------------------------------------ */ /* Push reminders (opt-in; only active when VAPID is configured) */ /* ------------------------------------------------------------------ */ self.addEventListener("push", (event) => { let data = {}; try { data = event.data ? event.data.json() : {}; } catch { data = {}; } event.waitUntil( self.registration.showNotification(data.title || "Freezmo", { body: data.body || "", icon: "/icons/icon-192.png", badge: "/icons/icon-192.png", data: { url: data.url || "/" }, }), ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); const url = (event.notification.data && event.notification.data.url) || "/"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { for (const client of clients) { if ("focus" in client) { client.navigate(url).catch(() => {}); return client.focus(); } } return self.clients.openWindow(url); }), ); }); self.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; // Never touch the API: keys, notes and generated decks must always be live. if (url.pathname.startsWith("/api/")) return; if (request.mode === "navigate") { event.respondWith( (async () => { try { const response = await fetch(request); const cache = await caches.open(SHELL_CACHE); cache.put("/", response.clone()).catch(() => {}); return response; } catch { const cache = await caches.open(SHELL_CACHE); return (await cache.match("/")) ?? (await cache.match("/offline")) ?? Response.error(); } })(), ); return; } if (isCacheableAsset(url)) { event.respondWith( (async () => { const cache = await caches.open(ASSET_CACHE); const cached = await cache.match(request); const network = fetch(request) .then((response) => { if (response.ok) cache.put(request, response.clone()).catch(() => {}); return response; }) .catch(() => cached); return cached ?? network; })(), ); } });