Files
familysync/apps/pwa/src/sw.ts
T
Lucas Berger 03e953158a fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
  them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
  block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
  expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
  disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
  void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
  with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
  all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
  Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
  type annotation on mockLastSyncedUid; remove async from test callbacks without
  await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
  comments land on the exact violation lines; void self.skipWaiting()
2026-06-11 20:23:38 -04:00

190 lines
8.5 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)
)
}
}
// No existing window — open a new one at the deep-link URL
if (self.clients.openWindow) {
return self.clients.openWindow(url)
}
}),
)
})