feat(05-04): SW migration to injectManifest with push + notificationclick + denylist
- 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
This commit is contained in:
@@ -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).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
|
||||||
|
// iOS 18.4+ declarative web push format
|
||||||
|
if (
|
||||||
|
payload.web_push === 8030 &&
|
||||||
|
payload.notification &&
|
||||||
|
typeof payload.notification === 'object'
|
||||||
|
) {
|
||||||
|
const notif = payload.notification as Record<string, unknown>
|
||||||
|
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<string, unknown>).url === 'string'
|
||||||
|
) {
|
||||||
|
url = (payload.data as Record<string, string>).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)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
+18
-13
@@ -7,19 +7,24 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
// ⚠️ CRITICAL: exclude /callback from SW navigation handling (Gate 2 — T-03-20)
|
// ⚠️ CRITICAL (T-03-20): generateSW migrated to injectManifest so the custom
|
||||||
// The OIDC authorization-code exchange lands on /callback — if the SW
|
// sw.ts can add push + notificationclick handlers while preserving the /callback
|
||||||
// intercepts this as a navigation, it serves the cached shell instead of
|
// denylist. The denylist is re-implemented explicitly in src/sw.ts.
|
||||||
// letting the server process the authorization code exchange (login loop).
|
strategies: 'injectManifest',
|
||||||
workbox: {
|
srcDir: 'src',
|
||||||
navigateFallback: '/index.html',
|
filename: 'sw.ts',
|
||||||
navigateFallbackDenylist: [
|
// rolldownOptions: force the SW output to IIFE so the output file is sw.js
|
||||||
/^\/callback/, // OIDC redirect endpoint — must reach the server
|
// (not sw.mjs). Vite 8 + vite-plugin-pwa 1.3.x defaults to ES module SW format
|
||||||
/^\/api\//, // API calls — never serve from cache
|
// when the source is .ts, producing sw.mjs. The registerSW.js generated by
|
||||||
/^\/health/, // Health endpoint
|
// the plugin registers '/sw.js', so the filenames must match.
|
||||||
],
|
rolldownOptions: {
|
||||||
// No /api caching — runtimeCaching: [] means all API calls fall through to network
|
output: {
|
||||||
runtimeCaching: [],
|
format: 'iife',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
injectManifest: {
|
||||||
|
// Prevent /callback-related URLs from appearing in the precache manifest.
|
||||||
|
globIgnores: ['**/node_modules/**', '**/callback**'],
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'FamilySync',
|
name: 'FamilySync',
|
||||||
|
|||||||
Reference in New Issue
Block a user