From 57689ddfbbd20bc6885a7b40acba810767735767 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Thu, 3 Sep 2026 22:46:35 +0200 Subject: [PATCH 1/2] feat(offline): hold through a short network drop with a caching worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service worker did Web Push and nothing else, with a note that a cache serving yesterday's data was worse than an honest "cannot connect". This adds a cache that does not do that: network-first for everything that can go stale, so a reader online always gets the live answer and the cache is reached only when the network cannot answer. The task and note lists stay live; they just no longer go blank the moment the signal does — a reload through a tunnel, a lift or a base-station handover comes back to the app instead of the browser's offline page. Writes are untouched and still go straight to the network; api.js already retries them across the same short drop. The shell cache is versioned so a deploy lands in a fresh cache and the old one is dropped on activate; the data cache is cleared on sign-out so the next person on the browser does not inherit it. The old hand-registered /sw.js becomes SvelteKit's src/service-worker.js, which it registers itself, and the push handlers move across unchanged. Co-Authored-By: Claude Opus 4.8 --- web/e2e/smoke.spec.js | 56 +++++++- web/src/lib/components/Sidebar.svelte | 10 ++ web/src/lib/connectivity.svelte.js | 10 +- web/src/lib/push.js | 10 +- web/src/service-worker.js | 184 ++++++++++++++++++++++++++ web/static/sw.js | 64 --------- 6 files changed, 258 insertions(+), 76 deletions(-) create mode 100644 web/src/service-worker.js delete mode 100644 web/static/sw.js diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a0fab27..412f461 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -298,11 +298,59 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({ const appleIcon = await request.get('/apple-touch-icon.png'); expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy(); - // The service worker is what Web Push needs; without it a subscription cannot - // be created at all. - const worker = await request.get('/sw.js'); + // The service worker is what Web Push needs — and, since it also caches, what + // carries the app through a short network drop. SvelteKit serves it under this + // name and registers it itself; the old hand-registered /sw.js is gone. + const worker = await request.get('/service-worker.js'); expect(worker.ok()).toBeTruthy(); - expect(await worker.text()).toContain("addEventListener('push'"); + // The source is minified, so the quotes around the event names are whatever the + // minifier chose — match either kind rather than one it might change under us. + const source = await worker.text(); + expect(source).toMatch(/addEventListener\(['"`]push['"`]/); + expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/); +}); + +/** + * The app survives a network drop: reloaded with the network cut, it opens from + * the cache and still shows the tasks it last saw, instead of the browser's own + * "you are offline" page. + * + * This is the whole point of the service worker's cache — not offline authoring, + * which the app does not claim, but a reload through a tunnel or a handover coming + * back to the app rather than to a dead tab. The retry in api.js covers the write + * side of the same drop; this covers the read side. + */ +test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => { + // First load, online: the worker installs, and once it controls the page the + // shell and the reads it serves are in the cache. + await page.goto('/'); + await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible(); + + // Wait until the worker actually controls this page. Until it does, a reload + // still goes to the network and the test proves nothing — the first load + // registers the worker but the page that triggered the install is not itself + // controlled until it activates and claims. + await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, { + timeout: 15000 + }); + + // A controlled reload, still online: now the navigation and its API reads pass + // through the worker and land in the cache, ready for the drop. + await page.reload(); + await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible(); + + // The network is gone. + await context.setOffline(true); + try { + await page.reload(); + // The shell came from the cache — the sidebar is here, not a browser error + // page — and so did the task list the app renders into it. + await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 }); + await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible(); + } finally { + // Never leave the shared context offline for whatever test runs next. + await context.setOffline(false); + } }); /** diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index fd0cccf..8a831ad 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -31,6 +31,16 @@ async function signOut() { await api.logout(); + // Empty the offline data cache before leaving: the service worker keeps the + // last-seen task and note lists so a network drop does not blank the app, and + // the next person to sign in on this browser must not find them. + try { + navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' }); + await caches?.delete('verdande-api'); + } catch { + // No service worker / no Cache API (older browser, private mode): nothing + // was cached, so nothing to clear. + } location.href = '/'; } diff --git a/web/src/lib/connectivity.svelte.js b/web/src/lib/connectivity.svelte.js index 57d380e..ec2e31c 100644 --- a/web/src/lib/connectivity.svelte.js +++ b/web/src/lib/connectivity.svelte.js @@ -1,11 +1,11 @@ /** * Connectivity, as the app experiences it — not as the browser reports it. * - * This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station - * handing over. It is deliberately NOT offline support. The app is already loaded - * and its data is already in memory, so there is nothing to cache and nothing to - * open from cold; what a blip needs is for the save you just made to survive it, - * not for the whole app to run without a server. + * This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a + * tunnel, a base station handing over. The READ side lives in the service worker, + * which keeps the last-seen task and note lists so a reload through a drop is not a + * blank screen. This store is the other half: it is what keeps the save you just made + * from being reverted under your hands while the network is briefly gone. * * The request layer drives this: it retries a failed request for a short window * (see api.js) and tells this store while it is trying, so the interface can say diff --git a/web/src/lib/push.js b/web/src/lib/push.js index 9375985..96c3273 100644 --- a/web/src/lib/push.js +++ b/web/src/lib/push.js @@ -26,9 +26,13 @@ export function permission() { } async function registration() { - // `ready` waits for an active worker; register() alone resolves while the - // worker is still installing, and subscribing against that fails. - await navigator.serviceWorker.register('/sw.js'); + // SvelteKit registers src/service-worker.js itself on load, so normally there + // is already a registration here. Register it by hand only if there is not yet + // one — a push subscribe that races the app's own registration would otherwise + // find nothing. `ready` waits for an active worker; register() alone resolves + // while the worker is still installing, and subscribing against that fails. + const existing = await navigator.serviceWorker.getRegistration(); + if (!existing) await navigator.serviceWorker.register('/service-worker.js'); return navigator.serviceWorker.ready; } diff --git a/web/src/service-worker.js b/web/src/service-worker.js new file mode 100644 index 0000000..2454969 --- /dev/null +++ b/web/src/service-worker.js @@ -0,0 +1,184 @@ +/// +/* global self, caches, fetch, Response */ + +/** + * verdande's service worker: Web Push, and a cache that holds through a short + * network drop. + * + * It used to do Push and nothing else, with a note that a cache serving yesterday's + * data was worse than an honest "cannot connect". The cache here does not do that. + * It is network-FIRST for everything that can go stale — a reader online always gets + * the live answer, and the cache is only reached for when the network is not — so it + * is not a copy of the app you work in offline, it is the app surviving a tunnel, a + * lift, a base-station handover. The five themes' worth of live task list stays live; + * it just no longer goes blank the moment the signal does. + * + * Writes are never touched here — they go straight to the network, and api.js already + * retries them across a short drop. Caching a write would mean a queue and conflicts, + * which is a different and much larger feature. + */ +import { build, files, version } from '$service-worker'; + +// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the +// old one is dropped on activate — the app can never be served half of two builds. +const SHELL = `verdande-shell-${version}`; +// Not versioned: the data does not belong to a build, and clearing it every deploy +// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out. +const API = 'verdande-api'; + +// The shell: every hashed build asset, the static files, and the SPA fallback so a +// navigation can be answered from the cache when the network cannot answer it. +const SHELL_ASSETS = [...build, ...files, '/']; + +self.addEventListener('install', (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(SHELL); + await cache.addAll(SHELL_ASSETS); + // Take over at the next opportunity rather than waiting for every tab to + // close — paired with clients.claim below and the versioned cache, a deploy + // reaches the user on their next load. + await self.skipWaiting(); + })() + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + for (const key of await caches.keys()) { + // Drop old shell versions; keep this shell and the data cache. + if (key !== SHELL && key !== API) await caches.delete(key); + } + await self.clients.claim(); + })() + ); +}); + +const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname); + +self.addEventListener('fetch', (event) => { + const { request } = event; + + // Writes go to the network, full stop — the app retries them itself, and a cached + // write is a promise this cannot keep. Cross-origin is none of our business. + if (request.method !== 'GET') return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + // Hashed build assets and static files never change for a given URL: cache-first, + // so a repeat load and an offline load are both instant. + if (isAsset(url.pathname)) { + event.respondWith(cacheFirst(request, SHELL)); + return; + } + + // The data: network-first so an online reader always sees the live list, with the + // last good copy kept for when the network is gone. + if (url.pathname.startsWith('/api/v1/')) { + event.respondWith(networkFirst(request, API)); + return; + } + + // A navigation (reload, or opening the app): the live page when there is one, the + // cached shell when there is not — so the app opens through a drop instead of + // showing the browser's dinosaur. + if (request.mode === 'navigate') { + event.respondWith(navigate(request)); + return; + } +}); + +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const hit = await cache.match(request); + if (hit) return hit; + const res = await fetch(request); + if (res.ok) cache.put(request, res.clone()); + return res; +} + +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const res = await fetch(request); + // Only a real answer is worth keeping — a 401 or a 500 must not overwrite the + // last good copy, or an expired session would be served back offline as fact. + if (res.ok) cache.put(request, res.clone()); + return res; + } catch (err) { + const hit = await cache.match(request); + if (hit) return hit; + throw err; + } +} + +async function navigate(request) { + try { + return await fetch(request); + } catch { + const cache = await caches.open(SHELL); + const shell = await cache.match('/'); + return shell ?? Response.error(); + } +} + +// Sign-out asks for the data cache to be emptied: the next person on this browser must +// not find the last one's tasks sitting in it. +self.addEventListener('message', (event) => { + if (event.data?.type === 'clear-api-cache') { + event.waitUntil(caches.delete(API)); + } +}); + +// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) -------- + +self.addEventListener('push', (event) => { + let payload = {}; + try { + payload = event.data ? event.data.json() : {}; + } catch { + // A push whose body is not our JSON still deserves to be shown: browsers show + // their own "site updated in the background" notice otherwise, which looks like + // a bug in the app. + payload = { title: 'verdande', body: event.data ? event.data.text() : '' }; + } + + const title = payload.title || 'verdande'; + event.waitUntil( + self.registration.showNotification(title, { + body: payload.body || '', + icon: '/icon.svg', + badge: '/icon.svg', + // The tag collapses repeats: five comments on one task should replace each + // other rather than stack into five separate notifications. + tag: payload.tag || payload.url || 'verdande', + data: { url: payload.url || '/' } + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = event.notification.data?.url || '/'; + + event.waitUntil( + (async () => { + const clientList = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + + // Focus a tab that already has the app open rather than opening a second one. + // Somebody with the app pinned does not want a new tab per reminder. + for (const client of clientList) { + if (new URL(client.url).origin === self.location.origin) { + await client.focus(); + if ('navigate' in client) await client.navigate(target); + return; + } + } + await self.clients.openWindow(target); + })() + ); +}); diff --git a/web/static/sw.js b/web/static/sw.js deleted file mode 100644 index ea86086..0000000 --- a/web/static/sw.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * verdande's service worker. - * - * It exists for Web Push and nothing else. There is deliberately no offline cache: - * the app is a live view of a task list that other people are editing, and a cached - * shell serving yesterday's data is worse than a browser saying it cannot connect. - * Adding one later means versioning and invalidating it, which is a real feature - * rather than a few lines here. - */ - -// Take over as soon as the browser is willing. Without these two, the first push -// after granting permission arrives with no worker in control to show it. -self.addEventListener('install', () => self.skipWaiting()); -self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); - -self.addEventListener('push', (event) => { - let payload = {}; - try { - payload = event.data ? event.data.json() : {}; - } catch { - // A push whose body is not our JSON still deserves to be shown: browsers - // display their own "site updated in the background" notice otherwise, which - // looks like a bug in the app. - payload = { title: 'verdande', body: event.data ? event.data.text() : '' }; - } - - const title = payload.title || 'verdande'; - event.waitUntil( - self.registration.showNotification(title, { - body: payload.body || '', - icon: '/icon.svg', - badge: '/icon.svg', - // The tag collapses repeats: five comments on one task should replace each - // other rather than stack into five separate notifications. - tag: payload.tag || payload.url || 'verdande', - data: { url: payload.url || '/' } - }) - ); -}); - -self.addEventListener('notificationclick', (event) => { - event.notification.close(); - const target = event.notification.data?.url || '/'; - - event.waitUntil( - (async () => { - const clientList = await self.clients.matchAll({ - type: 'window', - includeUncontrolled: true - }); - - // Focus a tab that already has the app open rather than opening a second - // one. Somebody with the app pinned does not want a new tab per reminder. - for (const client of clientList) { - if (new URL(client.url).origin === self.location.origin) { - await client.focus(); - if ('navigate' in client) await client.navigate(target); - return; - } - } - await self.clients.openWindow(target); - })() - ); -}); From 6db61b0cc75981d8300f7929f6f25f8118e1afda Mon Sep 17 00:00:00 2001 From: kristianwind Date: Fri, 4 Sep 2026 08:12:15 +0200 Subject: [PATCH 2/2] test(e2e): block the service worker except where the test is about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caching worker controls every page once it registers, and Playwright's page.route() does not see the fetches a worker makes — so the failed-load test, which forces a 500 to check the error path, got the real 200 through the worker and read as a broken feature. Block the worker by default and turn it back on only for the offline test, which is the one that needs a real one. Co-Authored-By: Claude Opus 4.8 --- web/e2e/smoke.spec.js | 8 +++++++- web/playwright.config.js | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 412f461..d70f44a 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -320,7 +320,12 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({ * back to the app rather than to a dead tab. The retry in api.js covers the write * side of the same drop; this covers the read side. */ -test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => { +test.describe('med service worker', () => { + // The rest of the suite blocks the worker (see playwright.config.js); this one + // test is about the worker, so it turns it back on for its own context. + test.use({ serviceWorkers: 'allow' }); + + test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => { // First load, online: the worker installs, and once it controls the page the // shell and the reads it serves are in the cache. await page.goto('/'); @@ -351,6 +356,7 @@ test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, c // Never leave the shared context offline for whatever test runs next. await context.setOffline(false); } + }); }); /** diff --git a/web/playwright.config.js b/web/playwright.config.js index 810aa7e..2994396 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -51,6 +51,12 @@ export default defineConfig({ use: { baseURL, + // Off by default. The app now ships a caching service worker, and once it + // controls a page Playwright's page.route() no longer sees the fetches it + // makes — so a test that forces a 500 to check the error path gets the real + // 200 through the worker instead, and reads as a broken feature. The one test + // that needs the real worker turns it back on for its own context. + serviceWorkers: 'block', trace: 'retain-on-failure', screenshot: 'only-on-failure', // Pinned, because the app reads both. The sign-up form sends