/**
* 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))
.then((navigated) => {
if (navigated === null && self.clients.openWindow)
return self.clients.openWindow(url);
})
.catch(() => {
if (self.clients.openWindow) return self.clients.openWindow(url);
});
}
}
// No existing window — open a new one at the deep-link URL
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
}),
);
});