Files
lxc-streamutils/apps/overlay/pwa/control-sw.js
T
2026-08-13 12:05:47 -07:00

122 lines
3.6 KiB
JavaScript

/**
* 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))
})