Files
sm-bm-plugin/public/sw.js
2026-10-09 02:47:29 +02:00

84 lines
3.0 KiB
JavaScript

const BUILD = '3b3c3c52da91'
// The service worker of the BM student app. The Vite plugin bm-service-worker
// (vite.config.ts) emits this file as /sw.js and replaces the build id above,
// so every build that changes an asset gets a fresh cache and an update prompt.
//
// It is deliberately minimal: hashed /assets/* files are cached, navigations
// go to the network with an offline fallback page, and everything else is
// left to the browser untouched.
const CACHE = 'bm-' + BUILD
const OFFLINE_URL = '/offline.html'
// API, realtime, upload and admin paths are never intercepted. The speaker
// deck (/_presentations) holds notes and text meant only for its owner, and
// no response from these prefixes may ever land in a device cache. The list
// mirrors reservedPrefixes in plugins/jz/bm/static.go plus /ws and the admin.
const SKIP_PREFIXES = ['/_user', '/_bm', '/_quizzes', '/_presentations', '/api', '/ws', '/storage', '/bmadmin']
// swRoute classifies a request: 'skip' leaves it to the browser, 'page' is a
// network-only navigation with an offline fallback, 'asset' is cache-first.
function swRoute(method, href, mode, origin) {
if (method !== 'GET') return 'skip'
let url
try {
url = new URL(href)
} catch {
return 'skip'
}
if (url.origin !== origin) return 'skip'
const path = url.pathname
// The reserved check runs before the navigation check, so even a top-level
// navigation to /storage/... or /bmadmin is never touched.
for (const prefix of SKIP_PREFIXES) {
if (path === prefix || path.startsWith(prefix + '/')) return 'skip'
}
if (mode === 'navigate') return 'page'
if (path.startsWith('/assets/')) return 'asset'
return 'skip'
}
self.addEventListener('install', (event) => {
// No skipWaiting here: a new worker waits until the user asks to refresh.
event.waitUntil(caches.open(CACHE).then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' }))))
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('bm-') && key !== CACHE).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
)
})
self.addEventListener('fetch', (event) => {
const req = event.request
const route = swRoute(req.method, req.url, req.mode, self.location.origin)
if (route === 'skip') return
if (route === 'page') {
// Navigations are never stored: an HTML response could carry user state.
event.respondWith(
fetch(req).catch(() => caches.match(OFFLINE_URL).then((res) => res || Response.error())),
)
return
}
event.respondWith(
caches.open(CACHE).then((cache) =>
cache.match(req).then((hit) => {
if (hit) return hit
return fetch(req).then((res) => {
if (res.status === 200 && res.type === 'basic') cache.put(req, res.clone())
return res
})
}),
),
)
})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting()
})