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
This commit is contained in:
+207
@@ -0,0 +1,207 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user