Files
familysync/apps/pwa/src/sw.ts
T
Lucas Berger af78ccc8b9 fix(quick-260611-tfc): make notificationclick openWindow fallback reachable
- Add .then(navigated) check: opens new window when client.navigate() resolves null
- Add .catch(): opens new window when client.focus() or client.navigate() rejects
- Both branches guarded by self.clients.openWindow per spec
- Returned chain (not floating) satisfies no-floating-promises gate
- All other behaviour preserved: close(), url extraction, post-loop fallback
2026-06-11 21:14:14 -04:00

193 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.418.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.
// ---------------------------------------------------------------------------
// 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.418.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).
// 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);
}
}),
);
});