feat(web): the service worker, and subscribing to Web Push

The server side has been finished for a while — VAPID keys, RFC 8291
encryption, delivery on reminders and assignments — and nothing in the
browser ever asked for a subscription. web/static/ had a manifest and an
icon and no sw.js, so a subscription could not be created at all.

The worker handles push and notificationclick and nothing else. There is
deliberately no offline cache: the app is a live view of a list other
people are editing, and a cached shell serving yesterday's data is worse
than the browser saying it cannot connect. Adding one later means
versioning and invalidating it, which is a feature rather than a few
lines here.

Unsubscribing tells the server first. If the browser unsubscribed and
then the request failed, the server would keep pushing to an endpoint
nothing is listening on, and only a 410 from the push service would ever
clean it up.

Still not verified against a live push service — this is the client half
of a chain whose other half has only ever been tested against the RFC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 10:56:33 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent c2a664d891
commit 1bb28ce862
2 changed files with 172 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
/**
* Web Push, from the browser's side.
*
* Three parties have to agree before a notification arrives: the browser (which
* needs permission and a registered service worker), the push service it chooses
* (Google's, Mozilla's), and this server (which holds the VAPID pair and does the
* RFC 8291 encryption). This module is the first of those, and nothing else.
*/
import { api } from './api.js';
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
* can any browser on a page served over plain HTTP from a non-localhost host. */
export function supported() {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
export function permission() {
return supported() ? Notification.permission : 'unsupported';
}
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');
return navigator.serviceWorker.ready;
}
/** The current subscription, or null. Cheap: it reads local state, no network. */
export async function current() {
if (!supported()) return null;
try {
const reg = await navigator.serviceWorker.getRegistration();
return (await reg?.pushManager.getSubscription()) ?? null;
} catch {
return null;
}
}
/**
* Asks permission, subscribes, and tells the server.
*
* Throws with a message meant to be shown. The failures here are not the API's
* error shape — a refused permission prompt is not an HTTP status — so they are
* plain Errors with Danish text.
*/
export async function subscribe() {
if (!supported()) throw new Error('Denne browser kan ikke vise notifikationer.');
const granted = await Notification.requestPermission();
if (granted !== 'granted') {
throw new Error(
granted === 'denied'
? 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.'
: 'Notifikationer blev ikke slået til.'
);
}
const reg = await registration();
const { public_key } = await api.pushKey();
if (!public_key) throw new Error('Serveren har ingen VAPID-nøgle.');
const subscription = await reg.pushManager.subscribe({
// Not optional in Chrome: a subscription that is not userVisibleOnly is
// refused outright.
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key)
});
const raw = subscription.toJSON();
await api.subscribePush({
endpoint: raw.endpoint,
keys: { p256dh: raw.keys.p256dh, auth: raw.keys.auth }
});
return subscription;
}
/** Unsubscribes here and forgets it on the server. */
export async function unsubscribe() {
const subscription = await current();
if (!subscription) return;
// Server first: if the browser unsubscribes and then the request fails, the
// server keeps pushing to an endpoint nothing is listening on, and only a
// 410 from the push service will ever clean it up.
await api.unsubscribePush(subscription.endpoint);
await subscription.unsubscribe();
}
/**
* The VAPID public key travels as base64url text and `applicationServerKey` wants
* raw bytes. Neither `atob` nor the browser does this conversion — every Web Push
* client on the internet carries some version of these six lines.
*/
function urlBase64ToUint8Array(base64url) {
const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 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);
})()
);
});