/* 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() }); } })() ); });