/* ═══════════════════════════════════════════════════════════ FlowDeck Service Worker — v6.0.0 PWA Stratégies : - Shell (CSS/JS/fonts/icons) → cache-first - Pages HTML → network-first, fallback cache - API GET → network-first, fallback cache - API mutations (POST/...) → network-only (queue côté client) ═══════════════════════════════════════════════════════════ */ 'use strict'; const CACHE_NAME = 'flowdeck-v6'; const DATA_CACHE = 'flowdeck-data-v6'; // App shell (assets versionnés comme référencés dans les templates). const PRECACHE_URLS = [ // Pages '/', '/workspaces', '/library', '/settings', // CSS '/static/css/app.css?v=5.1.1', '/static/css/design-tokens.css?v=5.2.0', '/static/css/components.css?v=5.2.0', '/static/css/katex.min.css?v=0.16.11', '/static/css/prism.css', // JS '/static/js/htmx.min.js', '/static/js/alpine.min.js', '/static/js/sortable.min.js', '/static/js/app.js?v=2.4.8', '/static/js/offline.js?v=6.0.0', '/static/js/katex.min.js?v=0.16.11', '/static/js/prism.min.js', // Icônes & métadonnées '/static/favicon.svg', '/static/manifest.json', '/static/icons/icon-192x192.png', '/static/icons/icon-512x512.png', ]; const SHELL_ASSET = /\.(css|js|png|jpg|jpeg|svg|gif|webp|ico|woff2?|ttf|eot|json)(\?.*)?$/; const OFFLINE_PAGE = [ '', '', '', 'FlowDeck — Hors ligne', '', '', '', '

Vous êtes hors ligne

', '

Les pages consultées récemment restent disponibles. Votre contenu sera synchronisé dès le retour du réseau.

', '
⟳ Synchronisation en attente…
', '
', '', ].join('\n'); // ── Install : precache du shell (ignore redirections /login) ── self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME) .then(async (cache) => { await Promise.all( PRECACHE_URLS.map(async (url) => { try { const res = await fetch(url, { redirect: 'manual', credentials: 'same-origin' }); // status 0 = opaqueredirect (vers /login par ex.) → on ne cache pas if (res.status === 0 || !res.ok) return; await cache.put(url, res); } catch { // réseau indisponible pendant install : on continue } }) ); // Page de secours offline await cache.put('/offline.html', new Response(OFFLINE_PAGE, { headers: { 'Content-Type': 'text/html; charset=utf-8' }, })); }) .then(() => self.skipWaiting()) ); }); // ── Activate : purge des anciens caches ── self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all( keys.filter((k) => k !== CACHE_NAME && k !== DATA_CACHE).map((k) => caches.delete(k)) )) .then(() => self.clients.claim()) ); }); // ── Fetch : routing par stratégie ── self.addEventListener('fetch', (event) => { const request = event.request; if (request.method !== 'GET') { // Mutations : always network (queue côté client en mode offline) return; } const url = new URL(request.url); if (url.origin !== self.location.origin) { return; // cross-origin (fonts Google, CDN…) non intercepté } const isNavigation = request.mode === 'navigate'; const isApi = url.pathname.startsWith('/api/') || url.pathname.startsWith('/db/') || url.pathname.startsWith('/board/') || url.pathname.startsWith('/p/'); // HTML / navigation : network-first, fallback cache, offline page if (isNavigation || request.headers.get('accept')?.includes('text/html')) { event.respondWith(networkFirst(request, { fallbackToShell: true })); return; } // API GET : réseau d'abord, cache en repli if (isApi) { event.respondWith(networkFirst(request, { cacheName: DATA_CACHE })); return; } // Assets statiques : cache-first avec mise en cache à la volée if (SHELL_ASSET.test(url.pathname) || url.pathname === '/manifest.json') { event.respondWith(cacheFirst(request, { cacheName: CACHE_NAME })); return; } // Tout le reste : network-first event.respondWith(networkFirst(request)); }); async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) { const cache = await caches.open(cacheName); const cached = await cache.match(request, { ignoreSearch: false }); if (cached) return cached; try { const res = await fetch(request); if (res && res.ok) cache.put(request, res.clone()); return res; } catch { return cached; } } async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) { const cache = await caches.open(cacheName); try { const res = await timeoutFetch(request, timeoutMs); if (res && res.ok) { cache.put(request, res.clone()); return res; } throw new Error('bad status'); } catch { const cached = await cache.match(request, { ignoreSearch: false }); if (cached) return cached; if (fallbackToShell) { const shell = await cache.match('/'); if (shell) return shell; return caches.match('/offline.html'); } return cached; } } function timeoutFetch(request, ms) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { const controller = new AbortController(); controller.abort(); reject(new Error('timeout')); }, ms); fetch(request).then( (res) => { clearTimeout(timer); resolve(res); }, (err) => { clearTimeout(timer); reject(err); } ); }); } // ── Background Sync : notifier les clients pour rejouer la queue ── self.addEventListener('sync', (event) => { if (event.tag === 'sync-flowdeck') { event.waitUntil( self.clients.matchAll({ type: 'window' }).then((clients) => { clients.forEach((client) => client.postMessage({ type: 'flowdeck-sync' })); }) ); } }); // ── Messages : mise à jour immédiate ── self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } });