Merge pull request #55 from kristianwind/offline-cache

feat(offline): hold through a short network drop with a caching worker
This commit is contained in:
Kristian Wind
2026-09-04 08:57:38 +02:00
committed by GitHub
7 changed files with 270 additions and 76 deletions
+58 -4
View File
@@ -298,11 +298,65 @@ 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.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('/');
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);
}
});
});
/**
+6
View File
@@ -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
+10
View File
@@ -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 = '/';
}
+5 -5
View File
@@ -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
+7 -3
View File
@@ -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;
}
+184
View File
@@ -0,0 +1,184 @@
/// <reference types="@sveltejs/kit" />
/* 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);
})()
);
});
-64
View File
@@ -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);
})()
);
});