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); - })() - ); -});