/** * Network policy for the `/control/` application shell. * * `__PWA_BUILD_ID__` is replaced by Vite before emission. Navigations use a * network-first strategy so fresh HTML can reference the newest hashed bundle; * known static assets use cache-first. The worker never handles API, WebSocket * or OBS routes because they are outside both this fetch policy and its scope. */ const buildId = '__PWA_BUILD_ID__' const cacheName = `lxc-control-v2-shell-${buildId}` const shellPath = '/control/' const shellRoutes = [ '/control/', '/control/invitations', '/control/login', '/control/register', '/control/setup', ] const stableAssets = [ '/control/manifest.webmanifest', '/pwa/icon-192.png', '/pwa/icon-512.png', '/pwa/icon-maskable-512.png', '/pwa/apple-touch-icon.png', ] async function precacheShell() { const cache = await caches.open(cacheName) const response = await fetch(shellPath, { cache: 'reload', credentials: 'same-origin' }) if (!response.ok) throw new Error(`Cannot precache control shell: ${response.status}`) const html = await response.clone().text() await Promise.all(shellRoutes.map(route => cache.put(route, response.clone()))) const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map( match => match[1], ) await cache.addAll([...new Set([...stableAssets, ...generatedAssets])]) } self.addEventListener('install', event => { event.waitUntil(precacheShell()) }) self.addEventListener('activate', event => { event.waitUntil( (async () => { const names = await caches.keys() await Promise.all( names .filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName) .map(name => caches.delete(name)), ) await self.clients.claim() })(), ) }) self.addEventListener('message', event => { if (event.data?.type === 'SKIP_WAITING') event.waitUntil(self.skipWaiting()) }) function isControlShell(pathname) { return pathname.startsWith('/control/') } function isStaticAsset(pathname) { return ( pathname.startsWith('/assets/') || pathname.startsWith('/fonts/') || pathname.startsWith('/pwa/') || pathname === '/control/manifest.webmanifest' ) } async function navigationResponse(request) { const cache = await caches.open(cacheName) const pathname = new URL(request.url).pathname try { const response = await fetch(request) if ( shellRoutes.includes(pathname) && response.ok && response.headers.get('content-type')?.includes('text/html') ) { await cache.put(pathname, response.clone()) } return response } catch { return ( (await cache.match(request, { ignoreSearch: true })) || (await cache.match(shellPath)) || Response.error() ) } } async function staticResponse(request) { const cached = await caches.match(request, { ignoreSearch: false }) if (cached) return cached const response = await fetch(request) if (response.ok) { const cache = await caches.open(cacheName) await cache.put(request, response.clone()) } return response } self.addEventListener('fetch', event => { const request = event.request if (request.method !== 'GET') return const url = new URL(request.url) if (url.origin !== self.location.origin) return // Authentication, component data, WebSockets and OBS are outside this // worker's scope and are never cached. Keep the path check as defence in depth. if (request.mode === 'navigate' && isControlShell(url.pathname)) { event.respondWith(navigationResponse(request)) return } if (isStaticAsset(url.pathname)) event.respondWith(staticResponse(request)) })