/// import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; export type {}; declare const self: ServiceWorkerGlobalScope & { // Replaced at build time by vite-plugin-pwa (injectManifest) with the list of // hashed build assets to precache. See vite.config.js VitePWA injectManifest. __WB_MANIFEST: Array; }; /** * PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time * by vite-plugin-pwa with the list of hashed build assets * (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns). * * DEPLOY-SAFETY INVARIANTS (do not break): * 1. index.html / navigations are NEVER precached or precache-routed. The * manifest globs only `assets/**` (content-hashed), so index.html (served * from the app root) is absent from it and navigation requests fall through * to the network — a new deploy is picked up immediately, no stale SPA * shell. We deliberately do NOT register a navigation route / * createHandlerBoundToURL fallback. * 2. precacheAndRoute only matches its own manifest URLs (same-origin hashed * assets). It never matches the media-auth paths handled by the fetch * listener below — those are cross-origin homeserver URLs absent from the * manifest — so the existing media fetch behaviour is fully preserved. It * is registered before that listener; for a media request the precache * route finds no match and does not call respondWith, so the media handler * still runs. * 3. Assets are content-hashed, so a changed asset ships under a new filename; * PrecacheController drops entries no longer in the current manifest on * activate, so the precache self-updates each deploy without unbounded * growth. */ precacheAndRoute(self.__WB_MANIFEST); type SessionInfo = { accessToken: string; baseUrl: string; }; /** * Store session per client (tab) */ const sessions = new Map(); const clientToResolve = new Map void>(); const clientToSessionPromise = new Map>(); async function cleanupDeadClients() { const activeClients = await self.clients.matchAll(); const activeIds = new Set(activeClients.map((c) => c.id)); Array.from(sessions.keys()).forEach((id) => { if (!activeIds.has(id)) { sessions.delete(id); clientToResolve.delete(id); clientToSessionPromise.delete(id); } }); } function setSession(clientId: string, accessToken: any, baseUrl: any) { if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { // Logout or invalid session sessions.delete(clientId); } const resolveSession = clientToResolve.get(clientId); if (resolveSession) { resolveSession(sessions.get(clientId)); clientToResolve.delete(clientId); clientToSessionPromise.delete(clientId); } } function requestSession(client: Client): Promise { const promise = clientToSessionPromise.get(client.id) ?? new Promise((resolve) => { clientToResolve.set(client.id, resolve); client.postMessage({ type: 'requestSession' }); }); if (!clientToSessionPromise.has(client.id)) { clientToSessionPromise.set(client.id, promise); } return promise; } async function requestSessionWithTimeout( clientId: string, timeoutMs = 3000, ): Promise { const client = await self.clients.get(clientId); if (!client) return undefined; const sessionPromise = requestSession(client); const timeout = new Promise((resolve) => { setTimeout(() => resolve(undefined), timeoutMs); }); return Promise.race([sessionPromise, timeout]); } self.addEventListener('install', () => { self.skipWaiting(); }); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil( (async () => { await self.clients.claim(); await cleanupDeadClients(); })(), ); }); /** * Receive session updates from clients */ self.addEventListener('message', (event: ExtendableMessageEvent) => { const client = event.source as Client | null; if (!client) return; const { type, accessToken, baseUrl } = event.data || {}; if (type === 'setSession') { setSession(client.id, accessToken, baseUrl); cleanupDeadClients(); } }); /** * N105: handle clicks on service-worker-owned notifications. This fires even * when the originating tab was closed (a page-level `Notification.onclick` * would not). Focus an existing app window and forward the target path so it * can route there; if no window is open, open the app. */ self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); const data = event.notification.data ?? {}; const path = typeof data.path === 'string' ? data.path : undefined; event.waitUntil( (async () => { const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true, }); const client = windowClients.find((c): c is WindowClient => 'focus' in c); if (client) { await client.focus(); if (path) client.postMessage({ type: 'notificationClick', path }); return; } // No app window open — open one at the app root (the client routes from // there). Router config (hash vs browser) is page-owned, so we don't try // to deep-link the URL here. if (self.clients.openWindow) { await self.clients.openWindow(self.registration.scope); } })(), ); }); const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail']; function mediaPath(url: string): boolean { try { const { pathname } = new URL(url); return MEDIA_PATHS.some((p) => pathname.startsWith(p)); } catch { return false; } } function validMediaRequest(url: string, baseUrl: string): boolean { return MEDIA_PATHS.some((p) => { const validUrl = new URL(p, baseUrl); return url.startsWith(validUrl.href); }); } function fetchConfig(token: string): RequestInit { return { headers: { Authorization: `Bearer ${token}`, }, cache: 'default', }; } self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; if (method !== 'GET' || !mediaPath(url)) return; const { clientId } = event; if (!clientId) return; const session = sessions.get(clientId); if (session) { if (validMediaRequest(url, session.baseUrl)) { event.respondWith(fetch(url, fetchConfig(session.accessToken))); } return; } event.respondWith( requestSessionWithTimeout(clientId).then((s) => { if (s && validMediaRequest(url, s.baseUrl)) { return fetch(url, fetchConfig(s.accessToken)); } return fetch(event.request); }), ); });