- push digest queries now exclude _duplicate channel like every other visibility query (bookmarks list/count and NewClassifiedSince) - advance push watermark to newest scanned story, not pass-start now, so stories arriving during a long send pass aren't re-counted next pass - replace hand-rolled escapeHTMLText with stdlib html.EscapeString - drop em-dashes from user-facing copy; bump PWA CACHE_VERSION so clients pick up the changed shell assets
194 lines
7.0 KiB
JavaScript
194 lines
7.0 KiB
JavaScript
// 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 = "v3";
|
|
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(
|
|
"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>" +
|
|
"<title>Offline · Pete</title>" +
|
|
"<div style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;text-align:center;color:#3a2f1a\">" +
|
|
"<div style=font-size:3rem>🦆</div>" +
|
|
"<h1 style=font-size:1.4rem>You're offline</h1>" +
|
|
"<p style=opacity:.7>Pete can't reach the news right now. Articles you've already opened are still readable, so head back and try one of those.</p>" +
|
|
"</div>",
|
|
{ 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);
|
|
})
|
|
);
|
|
});
|