Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på fanen. Det halve af pointen med at få besked er at få den, mens man laver noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc. Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og kender tallet, og service-workeren, når den ikke er — og det er netop dér, mærket er det, der siger, at der er sket noget. Tallet kommer med i push-beskeden frem for at blive talt op af service-workeren. Den husker ingenting mellem to opvågninger og kunne derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en tælling, der fejler, udelader feltet frem for at sende et nul — så står mærket, som det stod, i stedet for at blive tørret af en optælling, der ikke lykkedes. Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud — det står i Dock'en, også når ingen er logget ind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
7.0 KiB
JavaScript
198 lines
7.0 KiB
JavaScript
/// <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';
|
|
// Mærket på appens ikon — i Dock'en på en Mac, på proceslinjen på en pc.
|
|
//
|
|
// Sat her, fordi det her er det eneste, der kører, når appen ikke er åben: det
|
|
// er netop dér, et tal på ikonet er det, der siger, at der er noget nyt. Tallet
|
|
// kommer med beskeden frem for at blive talt op her; en service-worker husker
|
|
// ingenting mellem to opvågninger, så et mærke, den selv talte, ville kun kunne
|
|
// gå opad.
|
|
//
|
|
// Kan browseren ikke sætte mærker, sker der ingenting. Det er ikke en fejl at
|
|
// melde om: beskeden selv kom frem, og den er hovedsagen.
|
|
if (typeof payload.unread === 'number' && 'setAppBadge' in self.navigator) {
|
|
self.navigator.setAppBadge(payload.unread).catch(() => {});
|
|
}
|
|
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);
|
|
})()
|
|
);
|
|
});
|