178 lines
7.5 KiB
JavaScript
178 lines
7.5 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 `pushUrlFor`
|
|
* in ops/reminder-mailer.js. Note that the second copy of the rule lives THERE
|
|
* and not here: this file contains no routing logic at all, only a preference
|
|
* for the tab over the payload. Two copies is a real cost, and the alternative
|
|
* -- opening `/` and making somebody find the thing themselves -- is a worse
|
|
* one.
|
|
*
|
|
* `node ops/reminder-mailer.js --selftest` keeps the two honest. It reads
|
|
* linkFor out of app/lib/notifications.ts and runs both implementations over
|
|
* the same cases, so editing either side alone fails the check.
|
|
*/
|
|
|
|
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() });
|
|
}
|
|
})()
|
|
);
|
|
});
|