// Veza Platform Service Worker // v1.0.9 W4 Day 16 — strategy spec (per docs/ROADMAP_V1.0_LAUNCH.md) : // - Static assets : StaleWhileRevalidate // - HLS segments : CacheFirst, max-age 7d, max 50 entries // - API GET : NetworkFirst, timeout 3s // // We intentionally stay on hand-rolled fetch handlers rather than // migrating to Workbox : the existing implementation already carries // push notifications + background sync + notificationclick, and the // strategies the roadmap asks for are 60 lines below. Workbox would // bring an additional 200+ KB of runtime + a build-step dependency // for a feature set we already cover. const CACHE_VERSION = '__BUILD_VERSION__'; const CACHE_NAME = `veza-platform-${CACHE_VERSION}`; const STATIC_CACHE_NAME = `veza-static-${CACHE_VERSION}`; const DYNAMIC_CACHE_NAME = `veza-dynamic-${CACHE_VERSION}`; // Day 16 strategy constants — tunable here, NOT inline in the helpers. const HLS_CACHE_MAX_ENTRIES = 50; const HLS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7d const NETWORK_FIRST_TIMEOUT_MS = 3000; // 3s — beyond this, serve cached // Files to cache on install const STATIC_ASSETS = [ '/', '/dashboard', '/chat', '/library', '/profile', '/settings', '/manifest.json', '/icons/icon-192x192.png', '/icons/icon-512x512.png' ]; // API endpoints to cache with network-first strategy const API_CACHE_PATTERNS = [ /^https?:\/\/.*\/api\/v1\/user\/profile$/, /^https?:\/\/.*\/api\/v1\/library\/files$/, /^https?:\/\/.*\/api\/v1\/dashboard\/stats$/ ]; // Install event - v0.801: skip waiting, no aggressive cache clear on install self.addEventListener('install', (event) => { event.waitUntil(self.skipWaiting()); }); // Activate event - clean old version caches, claim self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => !name.includes(CACHE_VERSION)) .map((name) => caches.delete(name)) ); }).then(() => self.clients.claim()) ); }); // Fetch event - handle requests with appropriate caching strategy self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests if (request.method !== 'GET') { return; } // Skip WebSocket connections if (request.headers.get('upgrade') === 'websocket') { return; } // v0.801: JS/CSS chunks always from network - never cache if (url.pathname.includes('/sw.js') || url.pathname.includes('/assets/') || /\.(js|css|mjs)(\?.*)?$/.test(url.pathname)) { return; } // Skip external requests (except API) if (!url.origin.includes(self.location.origin) && !isApiRequest(request.url)) { return; } event.respondWith( handleRequest(request) ); }); // Handle different types of requests with appropriate strategies async function handleRequest(request) { try { // Strategy 0: Cache First for audio files (offline playback) if (isAudioRequest(request.url)) { return await cacheAudio(request); } // Strategy 1: Cache First for static assets if (isStaticAsset(request.url)) { return await cacheFirst(request, STATIC_CACHE_NAME); } // Strategy 2: Network First for API requests if (isApiRequest(request.url)) { return await networkFirst(request, DYNAMIC_CACHE_NAME); } // Strategy 3: Stale While Revalidate for pages if (isPageRequest(request.url)) { return await staleWhileRevalidate(request, DYNAMIC_CACHE_NAME); } // Default: Network only return await fetch(request); } catch (error) { console.error('[SW] Request failed:', error); // Return offline page for navigation requests if (isPageRequest(request.url)) { return await getOfflinePage(); } // Return cached version if available const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Return generic offline response return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); } } // Cache First strategy async function cacheFirst(request, cacheName) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } const networkResponse = await fetch(request); if (networkResponse.ok) { const cache = await caches.open(cacheName); cache.put(request, networkResponse.clone()); } return networkResponse; } // Network First strategy with 3s timeout (Day 16). // Race the network request against a fixed timeout. If the network // hasn't replied within NETWORK_FIRST_TIMEOUT_MS, fall back to the // cached version IF one exists — otherwise let the request continue // (no point timing out into a hard error). async function networkFirst(request, cacheName) { const cache = await caches.open(cacheName); const cachedPromise = cache.match(request); const networkPromise = fetch(request).then((networkResponse) => { if (networkResponse.ok) { // Best-effort cache write ; clone first to avoid the // "Response body is already used" trap. cache.put(request, networkResponse.clone()).catch(() => {}); } return networkResponse; }); // If the network is slow, return the cached response after the // timeout. The network request keeps running in the background and // updates the cache for the next visit. const timed = new Promise((resolve) => { setTimeout(async () => { const cached = await cachedPromise; if (cached) { console.log('[SW] networkFirst: 3s timeout hit, serving cached'); resolve(cached); } // No cached response — let the network race continue. }, NETWORK_FIRST_TIMEOUT_MS); }); try { return await Promise.race([networkPromise, timed.then((v) => v || networkPromise)]); } catch (error) { const cached = await cachedPromise; if (cached) { console.log('[SW] networkFirst: network failed, serving cached'); return cached; } throw error; } } // Stale While Revalidate strategy // CORRECTION DURABLE: Clone la réponse IMMÉDIATEMENT pour éviter "Response body is already used" async function staleWhileRevalidate(request, cacheName) { const cachedResponse = await caches.match(request); const networkResponsePromise = fetch(request) .then((networkResponse) => { if (networkResponse.ok) { // ✅ Cloner IMMÉDIATEMENT la réponse avant toute autre opération const responseToCache = networkResponse.clone(); // Mettre en cache de manière asynchrone (sans bloquer) caches.open(cacheName).then((cache) => { cache.put(request, responseToCache).catch((err) => { console.warn('[SW] Failed to cache response:', err); }); }); } return networkResponse; }) .catch(() => null); return cachedResponse || await networkResponsePromise; } // Get offline page async function getOfflinePage() { const cache = await caches.open(STATIC_CACHE_NAME); const offlineResponse = await cache.match('/'); if (offlineResponse) { return offlineResponse; } return new Response(` Veza - Hors ligne
📱

Veza - Mode Hors Ligne

Vous êtes actuellement hors ligne. Certaines fonctionnalités peuvent être limitées.

`, { headers: { 'Content-Type': 'text/html' } }); } // v0.12.5: Audio file caching for offline playback. // v1.0.9 Day 16: enforce 50-entry cap + 7-day TTL per the roadmap spec. const AUDIO_CACHE_NAME = `veza-audio-${CACHE_VERSION}`; function isAudioRequest(url) { const path = new URL(url).pathname; return /\.(mp3|m4a|ogg|wav|flac|aac|opus|ts|m4s)(\?.*)?$/.test(path) || path.includes('/audio/') || path.includes('/hls/'); } // CacheFirst with TTL + LRU. The HLS cache holds up to 50 segments ; // each is valid for 7 days from the moment it was first cached. Older // entries are pruned on every miss-then-fetch ; the cap is enforced // FIFO (oldest-cached first to evict). async function cacheAudio(request) { const cache = await caches.open(AUDIO_CACHE_NAME); const cached = await cache.match(request); // Hit — but check the age before serving. The cache stores the // response with its `date` header preserved ; we compute age client-side. if (cached) { if (!isCachedEntryStale(cached)) { return cached; } // Stale : fall through to refresh, but if the network is offline // we'll happily serve the stale entry below rather than fail. console.log('[SW] HLS cache: entry stale (>7d), refreshing'); } try { const response = await fetch(request); if (response.ok) { // Clone ONCE then put — `Response.body` is single-use. const responseToCache = response.clone(); await cache.put(request, responseToCache); // Best-effort eviction : never block the user on it. pruneAudioCache(cache).catch((err) => console.warn('[SW] HLS cache prune failed:', err), ); } return response; } catch (error) { // Network down — serve the stale entry if we have one ; this is // the offline-playback path the roadmap acceptance asks for. if (cached) { console.log('[SW] HLS cache: network failed, serving stale (offline)'); return cached; } throw error; } } // isCachedEntryStale reads the response's `date` header and returns // true if the entry is older than HLS_CACHE_MAX_AGE_MS. Returns false // if the header is missing (newer entries always have one ; missing // = legacy entry pre-Day 16, give it the benefit of the doubt). function isCachedEntryStale(response) { const dateHeader = response.headers.get('date'); if (!dateHeader) return false; const cachedAt = Date.parse(dateHeader); if (Number.isNaN(cachedAt)) return false; return Date.now() - cachedAt > HLS_CACHE_MAX_AGE_MS; } // pruneAudioCache enforces HLS_CACHE_MAX_ENTRIES. Cache `keys()` // returns Requests in insertion order, so we evict from the head // (FIFO ≈ LRU when access is the same as insertion order, which it // is for content-addressed segments — they're never re-inserted). async function pruneAudioCache(cache) { const keys = await cache.keys(); if (keys.length <= HLS_CACHE_MAX_ENTRIES) return; const toEvict = keys.length - HLS_CACHE_MAX_ENTRIES; for (let i = 0; i < toEvict; i++) { // eslint-disable-next-line no-await-in-loop await cache.delete(keys[i]); } } // Helper functions - only cache images, fonts, ico (never js/css) function isStaticAsset(url) { const path = new URL(url).pathname; if (path.includes('/assets/')) return false; return /\.(png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot|ico)(\?.*)?$/.test(path); } function isApiRequest(url) { return url.includes('/api/') || API_CACHE_PATTERNS.some(pattern => pattern.test(url)); } function isPageRequest(url) { const urlObj = new URL(url); return urlObj.pathname.match(/^\/[^.]*$/) && !isApiRequest(url); } // Message handling for communication with the main thread self.addEventListener('message', (event) => { const { type } = event.data; switch (type) { case 'SKIP_WAITING': self.skipWaiting(); break; case 'GET_VERSION': event.ports[0].postMessage({ type: 'VERSION', payload: { version: CACHE_NAME } }); break; case 'CLEAR_CACHE': caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => caches.delete(cacheName)) ); }).then(() => { event.ports[0].postMessage({ type: 'CACHE_CLEARED', payload: { success: true } }); }); break; // v0.12.5: Cache a specific audio URL for offline playback case 'CACHE_AUDIO': if (event.data.url) { caches.open(AUDIO_CACHE_NAME).then(async (cache) => { try { await cache.add(event.data.url); event.ports[0]?.postMessage({ type: 'AUDIO_CACHED', payload: { success: true, url: event.data.url } }); } catch (err) { event.ports[0]?.postMessage({ type: 'AUDIO_CACHED', payload: { success: false, error: err.message } }); } }); } break; default: console.log('[SW] Unknown message type:', type); } }); // Background sync for offline actions self.addEventListener('sync', (event) => { if (event.tag === 'background-sync') { event.waitUntil(doBackgroundSync()); } }); async function doBackgroundSync() { console.log('[SW] Performing background sync...'); // Implement background sync logic here // For example: sync offline messages, upload queued files, etc. } // Push notifications self.addEventListener('push', (event) => { if (!event.data) { return; } const data = event.data.json(); const url = data.link || data.url || '/'; const options = { body: data.body, icon: '/icons/icon-192x192.png', badge: '/icons/badge-72x72.png', vibrate: [100, 50, 100], data: { dateOfArrival: Date.now(), primaryKey: data.primaryKey || 1, url }, actions: [ { action: 'explore', title: 'Ouvrir', icon: '/icons/checkmark.png' }, { action: 'close', title: 'Fermer', icon: '/icons/xmark.png' } ] }; event.waitUntil( self.registration.showNotification(data.title, options) ); }); // Notification click handling self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'close') { return; } const urlToOpen = event.notification.data?.url || event.notification.data?.link || '/'; event.waitUntil( self.clients.matchAll({ type: 'window' }).then((clientList) => { // Check if a window is already open for (const client of clientList) { if (client.url === urlToOpen && 'focus' in client) { return client.focus(); } } // Open a new window if (self.clients.openWindow) { return self.clients.openWindow(urlToOpen); } }) ); }); console.log('[SW] Veza Platform Service Worker loaded');