/* ObsiGate — Service Worker (PWA offline + push) * Version: 4 (2026-09-12) * * Caching policy (fixes stale-asset loads behind Cloudflare / mobile): * - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST * - API : network-first (+ offline) * - other static (images, fonts…) : stale-while-revalidate * * Assets are served without content hashes. The previous "cache-first with a * fixed cache name" kept an old build forever on mobile: flushing the browser * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * a separate store. Bumping SW_VERSION invalidates it on every release. */ const SW_VERSION = 'v24'; const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`; const OFFLINE_URL = '/index.html'; // Core shell precached for offline use. Paths MUST be the real served URLs // (the app is mounted under /static, not /frontend). const PRECACHE_URLS = [ '/', OFFLINE_URL, '/manifest.json', '/static/style.css', '/static/js/app.js', '/static/js/state.js', '/static/js/auth.js', '/static/js/ui.js', '/static/js/sidebar.js', '/static/js/viewer.js', '/static/js/search.js', '/static/js/config.js', '/static/js/utils.js', '/static/js/i18n.js', '/static/js/offline.js', '/static/js/offline-db.js', '/static/js/sync.js', '/static/js/legacy.js', '/static/js/collab.js', ]; // ── Install: precache the shell, then activate immediately ────────────────── self.addEventListener('install', (event) => { event.waitUntil((async () => { const cache = await caches.open(CODE_CACHE); // Precache individually so a single 404 never aborts the whole install. await Promise.all(PRECACHE_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}) )); await self.skipWaiting(); })()); }); // ── Activate: drop every previous cache (old fixed-name caches included) ──── self.addEventListener('activate', (event) => { event.waitUntil((async () => { const keep = new Set([CODE_CACHE, RUNTIME_CACHE, API_CACHE]); const names = await caches.keys(); await Promise.all(names.map((name) => (keep.has(name) ? null : caches.delete(name)))); await self.clients.claim(); })()); }); function isCodeRequest(url, request) { if (request.mode === 'navigate') return true; if (url.pathname === '/sw.js' || url.pathname === '/manifest.json') return true; if (url.pathname.startsWith('/static/js/')) return true; return /\.(?:js|mjs|css|html|json)$/i.test(url.pathname); } async function networkFirst(request, cacheName, makeFallback) { try { const response = await fetch(request); if (response && response.ok && response.type === 'basic') { const cache = await caches.open(cacheName); cache.put(request, response.clone()); } return response; } catch (error) { const cached = await caches.match(request); if (cached) return cached; if (makeFallback) return makeFallback(); return new Response('Offline', { status: 503 }); } } async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); const fresh = fetch(request).then((response) => { if (response && response.ok && response.type === 'basic') { cache.put(request, response.clone()); } return response; }).catch(() => null); if (cached) return cached; return (await fresh) || new Response('Offline', { status: 503 }); } // ── Fetch routing ─────────────────────────────────────────────────────────── self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; // Let the browser handle range requests (PDF/streamed media) directly. if (request.headers.has('range')) return; // Streamed audio/video is large and range-driven — never cache it // (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached. if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) { event.respondWith(fetch(request)); return; } if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(request, API_CACHE, () => new Response(JSON.stringify({ error: 'Offline' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }) )); return; } if (isCodeRequest(url, request)) { event.respondWith(networkFirst(request, CODE_CACHE, async () => { const cached = await caches.match(OFFLINE_URL); return cached || new Response('