/** * public/sw.js — service worker (ES module; must be registered with {type:'module'}). * * Network-first caching + A1 push notifications + notificationclick routing. * Pure push/notification helpers live in sw-push.js (no SW globals → testable). * * IMPORTANT: This file uses ES module `import` syntax. The SW registration in * public/main.ts must pass { type: 'module' }: * navigator.serviceWorker.register('./sw.js', { type: 'module' }) */ import { buildPushNotification, resolveNotificationClick } from './sw-push.js' const CACHE = 'webterm-v1' self.addEventListener('install', () => self.skipWaiting()) self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) self.addEventListener('fetch', (e) => { const req = e.request if (req.method !== 'GET') return const url = new URL(req.url) // Bypass: WS upgrade, hook endpoints, and push subscription routes (A1) if ( url.pathname === '/term' || url.pathname.startsWith('/hook') || url.pathname.startsWith('/push') ) return e.respondWith( fetch(req) .then((res) => { const copy = res.clone() caches.open(CACHE).then((c) => c.put(req, copy)) return res }) .catch(() => caches.match(req)), ) }) /** A1: show a notification when a push arrives. */ self.addEventListener('push', (e) => { let payload try { payload = e.data?.json() } catch { return // malformed push — ignore } if (!payload || typeof payload !== 'object') return const { title, options } = buildPushNotification(payload) e.waitUntil(self.registration.showNotification(title, options)) }) /** * A1: handle notification action clicks. * Allow/Deny → POST /hook/decision with capability token (SEC-C1). * Body click → focus / open the terminal session tab. */ self.addEventListener('notificationclick', (e) => { e.notification.close() const result = resolveNotificationClick(e.notification, e.action) if (result.kind === 'decision') { // credentials:'same-origin' ensures Origin header is sent so the server // can validate it alongside the capability token (SEC-C1). e.waitUntil( fetch('/hook/decision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: result.body, }).catch((err) => { console.error('[sw] /hook/decision fetch failed', err) }), ) } else { // Focus an existing window if available, otherwise open a new one. e.waitUntil( self.clients .matchAll({ type: 'window', includeUncontrolled: true }) .then((clientList) => { for (const client of clientList) { if ('focus' in client) return client.focus() } return self.clients.openWindow(result.url) }), ) } })