/* Prabhupāda Says — Web Push service worker. * * Handles incoming push messages (shows a notification) and clicks (focuses * an existing same-origin tab and navigates it, or opens a new one). The push * payload is the JSON the BFF sends: * { title, body, url, icon, tag, require_interaction } */ // --- Offline caching (Save-offline feature) --------------------------------- // Bump CACHE_VERSION to invalidate old asset/page caches on the next activate. const CACHE_VERSION = "v1"; const PAGES_CACHE = `offline-pages-${CACHE_VERSION}`; // saved page HTML documents const ASSETS_CACHE = `offline-assets-${CACHE_VERSION}`; // /_next/* + static files const OFFLINE_CACHES = [PAGES_CACHE, ASSETS_CACHE]; // The library page doubles as the navigation fallback when offline. const OFFLINE_FALLBACK = "/downloads"; self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (event) => event.waitUntil( (async () => { // Drop stale offline caches from previous CACHE_VERSIONs. const names = await caches.keys(); await Promise.all( names .filter( (n) => n.startsWith("offline-") && !OFFLINE_CACHES.includes(n), ) .map((n) => caches.delete(n)), ); await self.clients.claim(); })(), ), ); // Treat content-hashed Next assets and static files as immutable → cache-first. function isCacheableAsset(url) { if (url.origin !== self.location.origin) return false; if (url.pathname.startsWith("/_next/")) return true; return /\.(?:js|css|woff2?|ttf|otf|png|jpe?g|webp|avif|svg|ico|gif)$/.test( url.pathname, ); } self.addEventListener("fetch", (event) => { const req = event.request; if (req.method !== "GET") return; // never touch POSTs (push subscribe etc.) const url = new URL(req.url); // 1. Page navigations. // ONLINE: do NOT intercept — let the browser navigate natively. Proxying // a redirected response (the app server-`redirect()`s every book chapter // index to its first verse) or Next's streamed SSR through the SW makes // iOS/WebKit standalone PWAs render a blank page. Native handling is // identical to how the app behaved before this SW had a fetch handler. // OFFLINE: serve a saved copy, then the offline library, then a stub — // all plain stored/constructed Responses, which WebKit accepts. if (req.mode === "navigate") { if (self.navigator.onLine) return; event.respondWith( (async () => { const pages = await caches.open(PAGES_CACHE); const saved = (await pages.match(req.url)) || (await pages.match(url.pathname)); if (saved) return saved; const fallback = await pages.match(OFFLINE_FALLBACK); if (fallback) return fallback; return new Response( "Offline

You're offline

This page hasn't been saved for offline use.

", { headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 }, ); })(), ); return; } // 2. Immutable same-origin assets: cache-first so the app shell boots offline. if (isCacheableAsset(url)) { event.respondWith( (async () => { const cache = await caches.open(ASSETS_CACHE); const hit = await cache.match(req); if (hit) return hit; const res = await fetch(req); // Only cache full, same-origin 200s. Guard put() so a 206/opaque/quota // rejection can never fail the request (which could drop a JS chunk). try { if (res && res.status === 200 && res.type === "basic") { await cache.put(req, res.clone()); } } catch { /* serve the response uncached */ } return res; })(), ); return; } // 3. Everything else (BFF data, RSC, /api): straight to network. }); // The page registers us as /sw.js?bff= so subscription management // works without a client tab open (the auth cookie rides on // credentials:"include"). Same-origin deployments fall back to our origin. const BFF_BASE = new URL(self.location.href).searchParams.get("bff") || self.location.origin; /* Push services rotate subscriptions (expiry, key migration). Without this * handler the server keeps pushing to the dead endpoint and the user silently * stops getting notifications — so re-subscribe with the same application * server key and swap the registration on the BFF. */ self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil( (async () => { const old = event.oldSubscription; let sub = event.newSubscription || null; if (!sub) { const appKey = old && old.options && old.options.applicationServerKey; if (!appKey) return; // nothing to re-subscribe with sub = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: appKey, }); } const json = sub.toJSON(); await fetch(`${BFF_BASE}/api/v1/push/subscribe`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }), }); if (old && old.endpoint && old.endpoint !== sub.endpoint) { await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: old.endpoint }), }).catch(() => { /* best-effort — the server prunes dead endpoints on send anyway */ }); } })(), ); }); self.addEventListener("push", (event) => { event.waitUntil( (async () => { let data = {}; if (event.data) { try { // event.data.json() is synchronous; text() is a Promise — only // awaited in the fallback. data = event.data.json(); } catch { data = { body: await event.data.text() }; } } const title = data.title || "Prabhupāda Says"; const options = { body: data.body || "", icon: data.icon || "/icon-192.png", badge: "/icon-192.png", // A tag dedupes a series (e.g. daily reminders replace the previous // one); renotify re-alerts when a tagged notification is replaced. tag: data.tag || undefined, renotify: !!data.tag, // Reminders set this so they stay until acted on (desktop otherwise // auto-dismisses). Defaults off for everything else. requireInteraction: data.require_interaction === true, vibrate: [100, 50, 100], data: { url: data.url || "/" }, actions: [{ action: "open", title: "Open" }], }; await self.registration.showNotification(title, options); })(), ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); // The only action is "open"; the default body click behaves the same. const url = (event.notification.data && event.notification.data.url) || "/"; const target = new URL(url, self.location.origin).href; event.waitUntil( (async () => { const list = await self.clients.matchAll({ type: "window", includeUncontrolled: true, }); // Reuse a single existing same-origin tab — focus it and navigate only // that one (not every matched window). for (const client of list) { if (client.url.startsWith(self.location.origin)) { await client.focus(); if ("navigate" in client) { try { await client.navigate(target); } catch { /* cross-document navigation can reject; focus already happened */ } } return; } } if (self.clients.openWindow) await self.clients.openWindow(target); })(), ); });