/* 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 } */ self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()), ); // 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); })(), ); });