fix(sw): kill-switch SW to unstick users with broken cached bundle (t-paliad-043 step 1)

Emergency: t-paliad-042 shipped a service worker that cached a broken
/assets/projects.js (crashes on init with "d is not a function"), making
/projects show the empty state. Mobile Safari users have no devtools to
manually unregister the SW.

Replace sw.js with a self-destructing variant: on activate, delete every
cache, unregister itself, and force every open client to navigate to a
fresh page. /sw.js is served with no-cache headers so browsers refetch
on the next navigation and propagate the kill-switch automatically.

Step 2 (separate commit): fix the projects.js bundle bug, then ship a
properly versioned SW that evicts stale caches on every deploy.
This commit is contained in:
m
2026-04-26 14:25:49 +02:00
parent 4e06a5db39
commit dc70114d92

View File

@@ -1,70 +1,39 @@
// Paliad service worker — minimal cache-first for /assets/* and /icons/*,
// network-first for /api/*, network passthrough for everything else.
// Bumping CACHE_VERSION purges the previous cache on activation.
const CACHE_VERSION = "paliad-v1";
const STATIC_CACHE = `${CACHE_VERSION}-static`;
// Paliad service worker — KILL-SWITCH (t-paliad-043).
// The previous SW (t-paliad-042 paliad-v1) cached a broken /assets/projects.js
// bundle that crashes on init ("d is not a function"), making /projects appear
// empty. Mobile Safari users have no devtools to unregister manually, so this
// SW is shipped specifically to self-destruct on activation:
// 1. unregister itself
// 2. delete every cache it ever opened
// 3. force every open client to reload from the network
//
// Browsers re-fetch sw.js on every navigation (we send Cache-Control: no-cache
// from the Go handler), so this propagates the moment a user opens any page.
// A proper versioned SW will land in a follow-up commit (Step 2) once the
// underlying bundle bug is fixed.
self.addEventListener("install", (event) => {
// Activate the new SW as soon as it is installed; matters when
// CACHE_VERSION changes so users don't keep stale assets.
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k.startsWith("paliad-"))
.map((k) => caches.delete(k)),
);
await self.clients.claim();
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((n) => caches.delete(n)));
await self.registration.unregister();
const clientsList = await self.clients.matchAll({ type: "window" });
for (const client of clientsList) {
try {
client.navigate(client.url);
} catch {
// Some browsers reject navigate() on cross-origin or detached clients —
// safe to ignore; next manual reload will pick up the unregistered state.
}
}
})(),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
event.respondWith(cacheFirst(req));
return;
}
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirst(req));
return;
}
// HTML navigations + everything else: pass through to the network.
});
async function cacheFirst(req) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
if (res && res.ok) cache.put(req, res.clone());
return res;
} catch (err) {
if (cached) return cached;
throw err;
}
}
async function networkFirst(req) {
try {
return await fetch(req);
} catch (err) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
throw err;
}
}
// Fetch handler intentionally absent: with no cache and pending unregister,
// every request goes straight to the network.