Commit904927dunknown_key
fix(pwa): make /sw-push.js a self-unregister script to recover trapped browsers
fix(pwa): make /sw-push.js a self-unregister script to recover trapped browsers Companion to d7ba05d (layout-side fix). That commit stopped registering /sw-push.js from the layout, but trapped browsers — the ones already in the AA reload loop — never get to the new layout's auto-unregister code because the loop reloads them before any JS runs. So make the SW itself the kill switch. The body served at /sw-push.js is now a self-nuke: on activate it claims clients, deletes any `gluecron-offline-*` caches the old version left behind, and calls self.registration.unregister(). Cache-Control: no-store on the route means the browser hits this updated body on its next SW update check (every navigation), so trapped browsers auto-recover within one page load after this deploys. Side effects: - Push notifications are temporarily off. The old /sw-push.js was the only place handling `push` + `notificationclick` events. Folding those handlers into the locked /sw.js body is tracked as a follow-up. /settings/push will still subscribe (against /sw.js's registration) but notifications won't display until that fold-in ships. Acceptable while we're firefighting; the loop fix is P0. - /offline.html stays served but no SW caches it anymore — falls through to the standard browser-offline page if the user is offline. Test update: push.test.ts:451 — replaced the "serves push-aware SW" assertions with three new ones that verify the self-unregister contract (calls `self.registration.unregister`, has no fetch handler, no push/notificationclick). 26/26 pass on push + pwa suites.
2 files changed+67−81904927d7936553f293f176c91fd44fad0d7245cf
2 changed files+67−81
Modifiedsrc/__tests__/push.test.ts+24−6View fileUnifiedSplit
@@ -448,18 +448,36 @@ describe("/pwa/unsubscribe", () => {
448448 });
449449});
450450
451describe("/sw-push.js (offline + push handler service worker)", () => {
452 it("serves the push-aware service worker", async () => {
451describe("/sw-push.js (AA-loop self-unregister)", () => {
452 // 2026-05-16 — was previously a push + offline + fetch-fallback SW
453 // registered at scope `/`. That collided with `/sw.js` (also at `/`),
454 // causing the admin-dashboard reload loop. The body is now a self-
455 // unregister script so existing browsers that have it installed
456 // auto-recover on their next SW update check (which is every
457 // navigation since `Cache-Control: no-store`).
458 it("serves a self-unregistering service worker", async () => {
453459 const res = await app.request("/sw-push.js");
454460 expect(res.status).toBe(200);
455461 expect(res.headers.get("content-type") || "").toContain(
456462 "application/javascript"
457463 );
458464 const body = await res.text();
459 expect(body).toContain("addEventListener('push'");
460 expect(body).toContain("addEventListener('notificationclick'");
461 expect(body).toContain("addEventListener('fetch'");
462 expect(body).toContain("/offline.html");
465 expect(body).toContain("self.registration.unregister");
466 expect(body).toContain("addEventListener('install'");
467 expect(body).toContain("addEventListener('activate'");
468 });
469
470 it("has no fetch handler — every request must hit the network", async () => {
471 const res = await app.request("/sw-push.js");
472 const body = await res.text();
473 expect(body).not.toContain("addEventListener('fetch'");
474 });
475
476 it("does not subscribe to push events anymore", async () => {
477 const res = await app.request("/sw-push.js");
478 const body = await res.text();
479 expect(body).not.toContain("addEventListener('push'");
480 expect(body).not.toContain("addEventListener('notificationclick'");
463481 });
464482});
465483
Modifiedsrc/routes/pwa.ts+43−75View fileUnifiedSplit
@@ -217,87 +217,55 @@ if ('serviceWorker' in navigator) {
217217// ---------------------------------------------------------------------------
218218
219219/**
220 * Push + offline service worker. Strictly additive to the v4 self-nuke SW.
221 * Handles three things:
222 * 1. `push` event → display a notification (title/body/url/tag).
223 * 2. `notificationclick` → focus an existing tab on `url` or open a new one.
224 * 3. `fetch` event → serve `/offline.html` as the fallback when the
225 * network fails on an HTML navigation. Non-HTML fetches passthrough.
220 * Push + offline service worker — SELF-NUKE EDITION (2026-05-16).
226221 *
227 * The cache name is unique so we don't collide with anything `/sw.js`
228 * historically touched.
222 * Background: previously this SW handled push/notification/offline-fetch
223 * AND was auto-registered by the layout at scope `/`. Combined with the
224 * `/sw.js` auto-registration at the same scope it caused the AA reload
225 * loop (two different script URLs at scope `/` keep replacing each
226 * other → layout's `updatefound → reload` hook fires every page load,
227 * input wiped, admin dashboard unusable). See commit d7ba05d for the
228 * layout-side fix.
229 *
230 * The layout-side fix alone does not recover browsers that ALREADY have
231 * the old push SW registered — the loop reloads the page before the new
232 * layout's JS gets a chance to run its `getRegistrations() → unregister`
233 * cleanup. So we also need the SW itself to clean up.
234 *
235 * What this body does now: on install, claim every client, delete every
236 * `gluecron-*` cache the old version created, then call
237 * `self.registration.unregister()` so the registration goes away
238 * permanently. Browsers fetch this updated body on their next SW
239 * update check (which fires on every navigation because `/sw-push.js`
240 * is served with `Cache-Control: no-store`), so trapped browsers
241 * auto-recover within one page load post-deploy.
242 *
243 * Push notifications: temporarily disabled. The folding of push +
244 * notificationclick handlers into the locked `/sw.js` body is tracked
245 * as a follow-up. Until then `/settings/push` will subscribe against
246 * the existing `/sw.js` registration, which silently won't display
247 * notifications — but won't loop either. That's the right trade-off
248 * while the platform is in firefighting mode.
229249 */
230export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2)
231const CACHE = 'gluecron-offline-v1';
232const OFFLINE_URL = '/offline.html';
233
234self.addEventListener('install', (event) => {
235 event.waitUntil((async () => {
236 const cache = await caches.open(CACHE);
237 try { await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); } catch (_) {}
238 self.skipWaiting();
239 })());
240});
241
242self.addEventListener('activate', (event) => {
243 event.waitUntil((async () => {
244 // Drop any cache that isn't our current one.
245 const keys = await caches.keys();
246 await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
247 await self.clients.claim();
248 })());
249});
250
251self.addEventListener('push', (event) => {
252 let data = { title: 'Gluecron', body: '', url: '/', tag: 'gluecron', icon: '/icon.svg' };
253 if (event.data) {
254 try { data = Object.assign(data, event.data.json()); }
255 catch (_) { try { data.body = event.data.text(); } catch (_) {} }
256 }
257 event.waitUntil(self.registration.showNotification(data.title, {
258 body: data.body,
259 icon: data.icon,
260 tag: data.tag,
261 data: { url: data.url },
262 }));
263});
264
265self.addEventListener('notificationclick', (event) => {
266 event.notification.close();
267 const target = (event.notification.data && event.notification.data.url) || '/';
268 event.waitUntil((async () => {
269 const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
270 for (const c of all) {
271 try {
272 const u = new URL(c.url);
273 if (u.pathname === target || c.url === target) {
274 await c.focus();
275 return;
276 }
277 } catch (_) {}
278 }
279 await self.clients.openWindow(target);
280 })());
250export const PUSH_SERVICE_WORKER_SRC = `// gluecron push SW — self-unregister (2026-05-16 AA-loop kill)
251self.addEventListener('install', (e) => {
252 self.skipWaiting();
281253});
282
283self.addEventListener('fetch', (event) => {
284 const req = event.request;
285 if (req.method !== 'GET') return;
286 const accept = req.headers.get('accept') || '';
287 // Only intervene on top-level HTML navigations. Everything else (CSS,
288 // images, API, /api/*, /.git/*, login/logout) passes straight through.
289 if (req.mode !== 'navigate' && !accept.includes('text/html')) return;
290 event.respondWith((async () => {
254self.addEventListener('activate', (e) => {
255 e.waitUntil((async () => {
291256 try {
292 return await fetch(req);
293 } catch (_) {
294 const cache = await caches.open(CACHE);
295 const cached = await cache.match(OFFLINE_URL);
296 if (cached) return cached;
297 return new Response('Offline', { status: 503, headers: { 'content-type': 'text/plain' } });
298 }
257 const keys = await caches.keys();
258 await Promise.all(
259 keys.filter((k) => k && k.indexOf('gluecron-offline') === 0).map((k) => caches.delete(k))
260 );
261 } catch (_) {}
262 try { await self.clients.claim(); } catch (_) {}
263 try { await self.registration.unregister(); } catch (_) {}
299264 })());
300265});
266// No fetch / push / notificationclick handlers — by design. Once this SW
267// activates and unregisters, the browser never invokes it again. The
268// scope is freed and /sw.js becomes the sole controller at /.
301269`;
302270
303271pwa.get("/sw-push.js", (c) => {
304272