- 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
207 lines
7.1 KiB
JavaScript
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();
|
|
}
|
|
}); |