const BUILD = '2c4e8c046409' // 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() })