Files
verdande/web/src/lib/push.js
T

118 lines
4.1 KiB
JavaScript
Raw Normal View History

/**
* 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';
import { t } from './i18n.svelte.js';
import { isNativeApp } from './platform.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' &&
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
// has no push service, and a subscription made there would go nowhere.
!isNativeApp() &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
export function permission() {
return supported() ? Notification.permission : 'unsupported';
}
async function registration() {
// 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;
}
/** 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'
? t('push.deniedByBrowser')
: t('push.notEnabled')
);
}
const reg = await registration();
const { public_key } = await api.pushKey();
if (!public_key) throw new Error(t('push.noVapidKey'));
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;
}