Files
flowdeck/static/sw.js
T
bruno b5207216f1 feat: v6.0.0 PWA offline support
- manifest + icones, service worker (precache, network-first, Background Sync)

- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur

- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)

- migrations offline_sync_queue + sync_version (triggers)

- UI offline (banner, badge sync, toasts, icone dirty) + doc /help

- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
2026-09-18 13:05:40 -04:00

207 lines
7.1 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════
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 = [
'<!DOCTYPE html>',
'<html lang="fr">',
'<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">',
'<title>FlowDeck — Hors ligne</title>',
'<style>',
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#191919;color:#fff;',
'display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;}',
'.box{padding:32px;background:#262626;border-radius:8px;max-width:340px;}',
'h1{font-size:20px;margin:0 0 8px;}',
'p{color:#b8b8b8;font-size:14px;margin:0;line-height:1.5;}',
'.sync{margin-top:12px;font-size:12px;color:#2383E2;animation:blink 1.4s infinite;}',
'@keyframes blink{50%{opacity:.35}}',
'</style>',
'</head>',
'<body>',
'<div class="box"><h1>Vous êtes hors ligne</h1>',
'<p>Les pages consultées récemment restent disponibles. Votre contenu sera synchronisé dès le retour du réseau.</p>',
'<div class="sync">⟳ Synchronisation en attente…</div>',
'</div>',
'</body></html>',
].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();
}
});