pullup-web/sw.js

173 lines
7.3 KiB
JavaScript

/* Pull Up service worker.
*
* HOW THIS FILE GETS SHIPPED, because the next person will look for a build
* step and there isn't one:
*
* `app/public/` is Expo's public folder. `npx expo export --platform web`
* copies it verbatim into `dist/` (@expo/cli export/exportApp.js calls
* copyPublicFolderAsync before it writes anything else), and `npx expo start
* --web` serves it from the dev server through ServeStaticMiddleware. So this
* file lands at `dist/sw.js` and is served at `/sw.js` on both.
*
* That path matters. A service worker's default scope is the directory it was
* served from, so a worker at `/sw.js` controls `/` and a worker tucked under
* `/_expo/` or `/assets/` would control only that subtree and would never see
* a push aimed at the app. Nothing here needs `Service-Worker-Allowed`
* precisely because it sits at the root already.
*
* It is NOT bundled by Metro. It never imports from `app/lib/*` and it cannot:
* a service worker is a separate JS realm with no DOM, no React, and no
* module graph in common with the app. Everything below is plain ES2020 that
* runs as-is.
*
* WHAT IT DOES, AND DELIBERATELY DOES NOT DO
*
* It does not cache anything. This is not an offline app -- it is a live feed of
* who is playing in the next two hours, and a stale cached bundle is a worse
* outcome than a spinner. A push receiver is the entire job.
*/
/* Take over as soon as a new copy is installed. Without these two, a returning
* user keeps whatever worker their tab already had until every tab is closed,
* which for a site people leave open is effectively never. */
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
/* ── where a notification goes when you tap it ───────────────────────────────
*
* The app's own resolver is `linkFor` in app/lib/notifications.ts and that is
* the source of truth. This worker cannot import it (see above), so the click
* handler below prefers to hand the notification back to an open tab and let
* the real `linkFor` decide.
*
* `data.url` is the cold-start fallback, computed server-side by
* ops/reminder-mailer.js. Two copies of one rule is a real cost, and the
* alternative -- opening `/` and making somebody find the thing themselves --
* is a worse one. ops/reminder-mailer.js has a self-check that asserts its copy
* still agrees with linkFor's cases; run `node ops/reminder-mailer.js --selftest`.
*/
self.addEventListener('push', (event) => {
event.waitUntil(handlePush(event));
});
async function handlePush(event) {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
// A push with no payload, or one we cannot read, still means "something
// happened". Saying so vaguely beats saying nothing: the browser will show
// its own "This site has been updated in the background" otherwise, which
// is worse than anything we could write.
payload = {};
}
const title = payload.title || 'Pull Up';
const body = payload.body || '';
await self.registration.showNotification(title, {
body,
/* Both of these must be fixed paths in this same public/ folder. They used
* to point at `/assets/icon.png`, which has never existed in an export —
* Metro content-hashes everything it bundles, so nothing it produces has a
* stable name, and every notification this app has ever shown fell back to
* the browser's own generic glyph. Verify with a HEAD request against a
* deployed build before changing either.
*
* Android reduces `badge` to a monochrome silhouette, so it gets the
* white-on-transparent cut rather than the full icon, which would come back
* as an unreadable grey blob. No emoji anywhere in this app. */
icon: '/icon-192.png',
badge: '/badge-96.png',
/* Collapse by conversation. Five chat messages from one game should be one
* line on the lock screen, not five -- `renotify` still buzzes for each so
* nothing is silently swallowed. A notification with no session falls back
* to its own id so unrelated things never overwrite each other. */
tag: payload.tag || payload.id || 'pullup',
renotify: true,
/* False, so a notification that arrives while you are staring at the tab
* still appears. The click handler is what avoids the silly case of
* re-navigating a tab that is already on the right screen. */
silent: false,
timestamp: payload.at ? Date.parse(payload.at) || Date.now() : Date.now(),
data: {
id: payload.id || null,
kind: payload.kind || null,
payload: payload.data || null,
url: payload.url || '/',
},
});
}
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(openFor(event.notification.data || {}));
});
async function openFor(data) {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
/* An open tab is the good case: it has the router, the session and the real
* `linkFor`, so it can navigate without a reload. Focus it and tell it what
* was tapped; app/lib/webPush.ts is listening. */
for (const client of all) {
if (client.url && new URL(client.url).origin === self.location.origin) {
client.postMessage({
type: 'pullup-notification-click',
id: data.id,
kind: data.kind,
data: data.payload,
url: data.url,
});
if ('focus' in client) {
try {
await client.focus();
return;
} catch {
// Some browsers refuse focus() outside a user gesture they recognise.
// Fall through and open a window instead of failing silently.
}
}
}
}
const url = typeof data.url === 'string' && data.url.startsWith('/') ? data.url : '/';
if (self.clients.openWindow) await self.clients.openWindow(url);
}
/* The browser rotates a subscription occasionally, and when it does the old
* endpoint stops working. There is no session in here to re-save the new one
* with, so this worker does the half it can -- get a fresh subscription using
* the same application server key -- and tells any open tab, which has the
* session and can call save_push_subscription.
*
* If no tab is open the new subscription is simply lost, and that is fine:
* app/lib/webPush.ts re-reads and re-saves the live subscription on every app
* start, so the next visit repairs it. Support for this event is patchy enough
* that it could not be relied on either way.
*/
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
(async () => {
const old = event.oldSubscription || (await self.registration.pushManager.getSubscription());
const key = old && old.options ? old.options.applicationServerKey : null;
if (!key) return;
let fresh;
try {
fresh = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key,
});
} catch {
return;
}
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of all) {
client.postMessage({ type: 'pullup-subscription-changed', subscription: fresh.toJSON() });
}
})()
);
});