/** * FamilySync Service Worker — injectManifest custom SW. * * Responsibilities: * 1. Precache the app shell (Workbox injects self.__WB_MANIFEST at build time). * 2. Reproduce autoUpdate behavior: skipWaiting + clientsClaim. * 3. Navigation denylist (T-03-20): /callback, /api/*, /health must never be * intercepted — they must reach the server. Re-implemented from the former * vite.config.ts workbox.navigateFallbackDenylist. * 4. Push handler: ALWAYS shows a visible notification (D-11 / iOS anti-pattern * mitigation). iOS revokes subscriptions after ~3 silent pushes. Never swallow * a push silently — even malformed payloads get a generic fallback notification. * 5. notificationclick handler: deep-link to the URL in notification.data.url. * Focuses an existing window at that URL or opens a new one (D-14). * * Payload format (from pushDispatcher.ts buildPushBody): * iOS 18.4+ declarative: { web_push: 8030, notification: { title, body, navigate } } * Legacy (iOS 16.4–18.3 + Android): { title, body, tag, data: { url } } * * Security (T-05-11, T-05-12): * - NavigationRoute denylist prevents the OIDC /callback from being served from cache. * - Try/catch on push payload parse falls back to generic notification — prevents * a malformed payload from silently dropping a push (which would revoke iOS subs). */ /// import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching' import { clientsClaim } from 'workbox-core' import { NavigationRoute, registerRoute } from 'workbox-routing' declare const self: ServiceWorkerGlobalScope // --------------------------------------------------------------------------- // AutoUpdate behavior: replace old SW immediately on install/activate. // Equivalent to the former generateSW autoUpdate: 'prompt' → 'autoUpdate' path. // --------------------------------------------------------------------------- // skipWaiting() resolves when the SW is installed; fire-and-forget is the correct pattern here void self.skipWaiting() clientsClaim() // --------------------------------------------------------------------------- // Precache the app shell. // self.__WB_MANIFEST is injected by vite-plugin-pwa injectManifest at build time. // At dev time this is an empty array; the real manifest is injected in production. // --------------------------------------------------------------------------- precacheAndRoute(self.__WB_MANIFEST) // --------------------------------------------------------------------------- // Navigation denylist (T-03-20 — CRITICAL). // // Routes that must NEVER be intercepted by the SW navigation handler: // /callback — OIDC authorization-code exchange; must reach the API server. // /api/* — All API calls; SW must not cache or serve these. // /health — Health endpoint; must not be served from cache. // // Re-implements the former workbox.navigateFallbackDenylist from vite.config.ts. // --------------------------------------------------------------------------- const navHandler = createHandlerBoundToURL('/index.html') registerRoute( new NavigationRoute(navHandler, { denylist: [ /^\/callback/, // OIDC redirect — must reach server (T-03-20) /^\/api\//, // API calls — never serve from cache /^\/health/, // Health endpoint ], }), ) // --------------------------------------------------------------------------- // Push handler (D-11, NOTIF-01/02/03). // // ALWAYS shows a visible notification. iOS silently revokes subscriptions after // ~3 silent pushes — we must NEVER let a push event resolve without displaying // a notification. The fallback title/body covers malformed payloads. // // Payload format (dual-format from buildPushBody): // Declarative (iOS 18.4+): { web_push: 8030, notification: { title, body, navigate } } // Legacy (iOS 16.4–18.3 + Android): { title, body, tag, data: { url } } // --------------------------------------------------------------------------- self.addEventListener('push', (event: PushEvent) => { let title = 'FamilySync' let body = 'You have a new notification' let tag = 'familysync-notification' let url = '/' try { if (event.data) { const payload = event.data.json() as Record // iOS 18.4+ declarative web push format if ( payload.web_push === 8030 && payload.notification && typeof payload.notification === 'object' ) { const notif = payload.notification as Record if (typeof notif.title === 'string') title = notif.title if (typeof notif.body === 'string') body = notif.body if (typeof notif.navigate === 'string') url = notif.navigate // Use navigate as tag for coalescing duplicate pushes tag = `familysync-${url}` } else { // Legacy format: top-level title/body/tag/data if (typeof payload.title === 'string') title = payload.title if (typeof payload.body === 'string') body = payload.body if (typeof payload.tag === 'string') tag = payload.tag if ( payload.data && typeof payload.data === 'object' && typeof (payload.data as Record).url === 'string' ) { url = (payload.data as Record).url } } } } catch { // Malformed payload — fall through with default title/body/url. // We still show the notification below (D-11 — never silent). } // event.waitUntil() is MANDATORY: keeps the SW alive until the notification // is displayed. Without it the SW may be terminated before showNotification // resolves, resulting in a silent push (iOS subscription death). // renotify and vibrate are valid NotificationOptions per spec but are absent // from some lib.dom versions (TS strict check). Cast the extended object to // NotificationOptions so only these two extra keys are affected — no blanket // suppression of unrelated errors elsewhere in the file. const options = { body, tag, data: { url }, icon: '/icon-192.png', badge: '/icon-192.png', renotify: true, vibrate: [200, 100, 200], } as NotificationOptions event.waitUntil( self.registration.showNotification(title, options), ) }) // --------------------------------------------------------------------------- // notificationclick handler (D-14 — deep-link on tap). // // Closes the notification, then: // 1. Find any existing same-origin window with clients.matchAll (not exact-URL // match — deep-link URLs differ from the current window URL). Focus it and // navigate it to the target URL so the deep-link is honoured (CR-03 fix). // 2. If no window exists, open a new one at the target URL. // // CR-03: The previous exact-URL match (client.url === url) never fired for // deep-link notifications whose query string differed from the current window URL // (e.g. notification for /calendar?event=uid while window is at /calendar). // Using any existing window + navigate() satisfies D-14 on both Android and iOS. // --------------------------------------------------------------------------- self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close() // Notification.data is typed as 'any' in the ServiceWorker lib; we validate with typeof // before using the value so the access is safe despite the lack of static types. let url = '/' // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any' per webworker lib; typeof guard on the right-hand side validates this access if (typeof event.notification.data?.url === 'string') { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any'; typeof check above validates this access url = event.notification.data.url as string } event.waitUntil( self.clients .matchAll({ type: 'window', includeUncontrolled: true }) .then((clientList) => { // Focus any existing window on this origin and navigate it to the target URL. // We don't match on client.url — the deep-link URL will almost always differ // from the current window location (query string with event uid / date). for (const client of clientList) { if ('focus' in client) { return client.focus().then(() => client.navigate(url) ) } } // No existing window — open a new one at the deep-link URL if (self.clients.openWindow) { return self.clients.openWindow(url) } }), ) })