/* 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 = 'v29'; 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/xlsx/refs.js', '/static/js/xlsx/command-bar.js', '/static/js/xlsx/dashboard.js', '/static/js/xlsx/context-menu.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('

Hors ligne

', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); })); return; } event.respondWith(staleWhileRevalidate(request, RUNTIME_CACHE)); }); // ── Message from the page ─────────────────────────────────────────────────── self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') self.skipWaiting(); }); // ── Push Event: Display notification ─────────────────────────────────────── self.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); const options = { body: data.body || data.message || 'Nouvelle notification', icon: '/static/icon-192.png', badge: '/static/badge-72.png', vibrate: [200, 100, 200], tag: data.tag || 'obsigate-notification', renotify: true, data: data.data || {}, actions: [ { action: 'open', title: 'Ouvrir' }, { action: 'dismiss', title: 'Ignorer' } ], requireInteraction: false }; event.waitUntil( self.registration.showNotification(data.title || 'ObsiGate', options) ); }); // ── Notification Click: Open the app ─────────────────────────────────────── self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'dismiss') return; const urlToOpen = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { if (client.url.includes(urlToOpen) && 'focus' in client) { return client.focus(); } } if (clients.openWindow) { return clients.openWindow(urlToOpen); } }) ); }); self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event.notification.tag); }); // ── Background Sync: Periodic index sync ────────────────────────────────── self.addEventListener('sync', (event) => { if (event.tag === 'sync-file-index') { event.waitUntil(syncFileIndex()); } }); async function syncFileIndex() { try { const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200'); if (resp.ok) { const data = await resp.json(); const files = (data.results || []).map((r) => ({ vault: r.vault, path: r.path, title: r.title, tags: r.tags || [], snippet: r.snippet || '', modified: r.modified || '' })); const clients = await self.clients.matchAll(); clients.forEach((client) => client.postMessage({ type: 'OFFLINE_INDEX_SYNC', files })); } } catch (e) { console.warn('[SW] Background sync failed:', e); } } self.addEventListener('periodicsync', (event) => { if (event.tag === 'periodic-index-sync') { event.waitUntil(syncFileIndex()); } });