From e5953ebb314021e803196f480575f60d386be10c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 21:06:32 -0400 Subject: [PATCH] feat(05-04): SW migration to injectManifest with push + notificationclick + denylist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate vite.config.ts from generateSW to injectManifest (strategies, srcDir, filename) - Add rolldownOptions.output.format=iife to produce sw.js (not sw.mjs) matching registerSW.js - Create apps/pwa/src/sw.ts: skipWaiting+clientsClaim (autoUpdate), precacheAndRoute, NavigationRoute denylist (/callback,/api/,/health) - Push handler: dual-format payload parsing (iOS 18.4+ declarative + legacy), ALWAYS event.waitUntil(showNotification) — never silent (D-11) - notificationclick: close notification, focus existing window or openWindow to deep-link URL (D-14) - Build verified: dist/sw.js contains showNotification, waitUntil, callback denylist, notificationclick --- apps/pwa/src/sw.ts | 164 ++++++++++++++++++++++++++++++++++++++++ apps/pwa/vite.config.ts | 31 ++++---- 2 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 apps/pwa/src/sw.ts diff --git a/apps/pwa/src/sw.ts b/apps/pwa/src/sw.ts new file mode 100644 index 0000000..8fd6daa --- /dev/null +++ b/apps/pwa/src/sw.ts @@ -0,0 +1,164 @@ +/** + * 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. +// --------------------------------------------------------------------------- +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). + event.waitUntil( + self.registration.showNotification(title, { + body, + tag, + data: { url }, + }), + ) +}) + +// --------------------------------------------------------------------------- +// notificationclick handler (D-14 — deep-link on tap). +// +// Closes the notification, then: +// 1. If a window is already open at the target URL, focus it. +// 2. Otherwise, open a new window at the target URL. +// --------------------------------------------------------------------------- +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close() + + const url: string = + typeof event.notification.data?.url === 'string' + ? event.notification.data.url + : '/' + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Focus an existing window already at the target URL + for (const client of clientList) { + if (client.url === url && 'focus' in client) { + return (client as WindowClient).focus() + } + } + // No existing window — open a new one + if (self.clients.openWindow) { + return self.clients.openWindow(url) + } + }), + ) +}) diff --git a/apps/pwa/vite.config.ts b/apps/pwa/vite.config.ts index 1a53218..0370c10 100644 --- a/apps/pwa/vite.config.ts +++ b/apps/pwa/vite.config.ts @@ -7,19 +7,24 @@ export default defineConfig({ react(), VitePWA({ registerType: 'autoUpdate', - // ⚠️ CRITICAL: exclude /callback from SW navigation handling (Gate 2 — T-03-20) - // The OIDC authorization-code exchange lands on /callback — if the SW - // intercepts this as a navigation, it serves the cached shell instead of - // letting the server process the authorization code exchange (login loop). - workbox: { - navigateFallback: '/index.html', - navigateFallbackDenylist: [ - /^\/callback/, // OIDC redirect endpoint — must reach the server - /^\/api\//, // API calls — never serve from cache - /^\/health/, // Health endpoint - ], - // No /api caching — runtimeCaching: [] means all API calls fall through to network - runtimeCaching: [], + // ⚠️ CRITICAL (T-03-20): generateSW migrated to injectManifest so the custom + // sw.ts can add push + notificationclick handlers while preserving the /callback + // denylist. The denylist is re-implemented explicitly in src/sw.ts. + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + // rolldownOptions: force the SW output to IIFE so the output file is sw.js + // (not sw.mjs). Vite 8 + vite-plugin-pwa 1.3.x defaults to ES module SW format + // when the source is .ts, producing sw.mjs. The registerSW.js generated by + // the plugin registers '/sw.js', so the filenames must match. + rolldownOptions: { + output: { + format: 'iife', + }, + }, + injectManifest: { + // Prevent /callback-related URLs from appearing in the precache manifest. + globIgnores: ['**/node_modules/**', '**/callback**'], }, manifest: { name: 'FamilySync',