// Pete's service worker: an installable-PWA shell, an offline reader, and the // Web Push receiver. Served from the root (/sw.js) so its scope is the whole // origin โ€” it can intercept navigations and /api/article the same as any page. // // Bump CACHE_VERSION whenever the precached shell assets change; activate() // drops every cache that doesn't match the current version. var CACHE_VERSION = "v2"; var SHELL_CACHE = "pete-shell-" + CACHE_VERSION; var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION; // App shell: the static assets every page needs. Versioned by URL content only // loosely, so we lean on network-first for HTML and cache-first for these. var SHELL_ASSETS = [ "/static/css/output.css", "/static/js/prefs.js", "/static/js/weather.js", "/static/js/weather-forecast.js", "/static/js/search.js", "/static/js/settings.js", "/static/js/reader.js", "/static/js/pwa.js", "/static/img/pete.avif", "/static/img/icon-192.png", "/static/img/icon-512.png", ]; // How many visited-article responses to keep for offline reading before the // oldest are evicted. Reader articles are small JSON blobs. var RUNTIME_MAX = 60; self.addEventListener("install", function (event) { event.waitUntil( caches.open(SHELL_CACHE).then(function (cache) { // addAll is atomic-ish: if one asset 404s the whole install fails, so keep // this list to assets we know are served. Individual failures are tolerated // by falling back to per-asset puts. return Promise.all( SHELL_ASSETS.map(function (url) { return cache.add(url).catch(function () {}); }) ); }).then(function () { return self.skipWaiting(); }) ); }); self.addEventListener("activate", function (event) { event.waitUntil( caches.keys().then(function (keys) { return Promise.all( keys.map(function (k) { if (k !== SHELL_CACHE && k !== RUNTIME_CACHE) return caches.delete(k); }) ); }).then(function () { return self.clients.claim(); }) ); }); // trimCache evicts the oldest entries once a runtime cache passes its cap. function trimCache(cacheName, max) { caches.open(cacheName).then(function (cache) { cache.keys().then(function (keys) { if (keys.length <= max) return; for (var i = 0; i < keys.length - max; i++) cache.delete(keys[i]); }); }); } // A minimal offline page for navigations we have nothing cached for. function offlineFallback() { return new Response( "" + "Offline ยท Pete" + "
" + "
๐Ÿฆ†
" + "

You're offline

" + "

Pete can't reach the news right now. Articles you've already opened are still readable โ€” head back and try one of those.

" + "
", { headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 } ); } self.addEventListener("fetch", function (event) { var req = event.request; if (req.method !== "GET") return; var url = new URL(req.url); if (url.origin !== self.location.origin) return; // never touch cross-origin // Visited articles: network-first so text stays fresh, but cache every success // so the reader still works offline for stories the user has already opened. if (url.pathname === "/api/article") { event.respondWith( fetch(req).then(function (res) { if (res && res.ok) { var copy = res.clone(); caches.open(RUNTIME_CACHE).then(function (cache) { cache.put(req, copy); trimCache(RUNTIME_CACHE, RUNTIME_MAX); }); } return res; }).catch(function () { return caches.match(req).then(function (hit) { return hit || new Response(JSON.stringify({ error: "offline" }), { status: 503, headers: { "Content-Type": "application/json" }, }); }); }) ); return; } // Static assets: cache-first (they're versioned by deploy), fill the cache on // first miss so a later offline visit has them. if (url.pathname.indexOf("/static/") === 0) { event.respondWith( caches.match(req).then(function (hit) { return hit || fetch(req).then(function (res) { if (res && res.ok) { var copy = res.clone(); caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); }); } return res; }); }) ); return; } // Page navigations: network-only, falling back to the offline card when the // network is unreachable. We deliberately do NOT cache HTML responses: pages // are personalized (they embed the signed-in user's name/email and a "For you" // rail), and the runtime cache is shared across everyone who uses this // installed PWA. Caching a navigation would let a signed-out visitor โ€” or a // second person on the same device โ€” be served the previous user's identity // and personalized stories offline. Offline reading still works: the reader // fetches cached /api/article JSON on top of the cached static shell. if (req.mode === "navigate") { event.respondWith( fetch(req).catch(function () { return offlineFallback(); }) ); return; } // Everything else (other /api/* calls): straight to the network. These are // personalized/stateful and must not be served stale. }); // ---- Web Push ------------------------------------------------------------- // The server sends a JSON payload {title, body, url, tag}. Missing fields fall // back to sensible defaults so a malformed push still shows something useful. self.addEventListener("push", function (event) { var data = {}; if (event.data) { try { data = event.data.json(); } catch (e) { data = { body: event.data.text() }; } } var title = data.title || "Pete"; var options = { body: data.body || "New stories are waiting.", icon: "/static/img/icon-192.png", badge: "/static/img/icon-192.png", tag: data.tag || "pete-digest", renotify: true, data: { url: data.url || "/" }, }; event.waitUntil(self.registration.showNotification(title, options)); }); self.addEventListener("notificationclick", function (event) { event.notification.close(); var target = (event.notification.data && event.notification.data.url) || "/"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) { for (var i = 0; i < clients.length; i++) { var c = clients[i]; // Focus an existing Pete tab and route it to the target if we can. if ("focus" in c) { c.focus(); if ("navigate" in c && target !== "/") { try { c.navigate(target); } catch (e) {} } return; } } if (self.clients.openWindow) return self.clients.openWindow(target); }) ); });