// 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 = "v1";
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-first, fall back to a cached copy of the same page,
// then to the offline card. Successful HTML is cached so revisits work offline.
if (req.mode === "navigate") {
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 || 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);
})
);
});