fix(pwa): BUG-005 chargement mobile via Cloudflare (SW network-first + no-cache)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m53s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:13:16 -04:00
parent 4c4e415975
commit 7cf7d33b6d
10 changed files with 382 additions and 153 deletions
+21 -2
View File
@@ -5067,8 +5067,27 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
<script>
// Force-kill stale Service Worker and reload fresh
(function(){if('serviceWorker'in navigator&&navigator.serviceWorker.controller){var reloaded=sessionStorage.getItem('_sw_killed');if(!reloaded){navigator.serviceWorker.getRegistrations().then(function(regs){regs.forEach(function(r){r.unregister()})});caches.keys().then(function(names){names.forEach(function(n){caches.delete(n)})});sessionStorage.setItem('_sw_killed','1');location.reload(true)}}else{sessionStorage.removeItem('_sw_killed')}})();
// One-time migration for devices stuck on the legacy service worker
// (cache-first with a fixed cache name). Deletes the old caches and
// triggers an update check; the new network-first SW then takes over.
// Keyed in localStorage so it never loops or re-runs on every session.
(function () {
if (!('serviceWorker' in navigator) || !('caches' in window)) return;
var MIGRATION_KEY = 'obsigate-sw-migration';
try {
if (localStorage.getItem(MIGRATION_KEY) === 'v2') return;
localStorage.setItem(MIGRATION_KEY, 'v2');
} catch (e) { return; }
caches.keys().then(function (names) {
return Promise.all(names.map(function (name) {
return /^obsigate-(v1|offline-v1)$/.test(name) ? caches.delete(name) : null;
}));
}).then(function () {
return navigator.serviceWorker.getRegistration();
}).then(function (reg) {
if (reg) reg.update();
}).catch(function () { /* best effort */ });
})();
</script>
<script type="module" src="/static/js/app.js"></script>
+36 -22
View File
@@ -488,34 +488,48 @@ function _initAIStatus() {
// PWA Service Worker Registration
// ---------------------------------------------------------------------------
function registerServiceWorker() {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("[PWA] Service Worker registered successfully:", registration.scope);
if (!("serviceWorker" in navigator)) return;
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60000); // Check every minute
// Reload once when a new service worker takes control (guarded against
// loops). Only relevant when a worker was already controlling the page.
const hadController = !!navigator.serviceWorker.controller;
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!hadController || refreshing) return;
refreshing = true;
window.location.reload();
});
// Handle service worker updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// New service worker available
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("[PWA] Service Worker registered successfully:", registration.scope);
// Check for updates periodically.
setInterval(() => {
registration.update();
}, 60000); // Check every minute
// Handle service worker updates: activate the new worker immediately
// so the fresh (network-first) code is served on the next load.
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
newWorker.postMessage({ type: "SKIP_WAITING" });
showUpdateNotification();
}
});
}
});
})
.catch((error) => {
console.log("[PWA] Service Worker registration failed:", error);
});
});
}
})
.catch((error) => {
console.log("[PWA] Service Worker registration failed:", error);
});
});
}
function showUpdateNotification() {
+121 -114
View File
@@ -1,126 +1,146 @@
/* ObsiGate — Service Worker (Push notifications + Offline cache)
* Version: 1.0.0
* Scope: /
/* ObsiGate — Service Worker (PWA offline + push)
* Version: 2 (2026-09-11)
*
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
* - API : network-first (+ offline)
* - other static (images, fonts…) : stale-while-revalidate
*
* Assets are served without content hashes. The previous "cache-first with a
* fixed cache name" kept an old build forever on mobile: flushing the browser
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const CACHE_NAME = 'obsigate-v1';
const OFFLINE_CACHE = 'obsigate-offline-v1';
const SW_VERSION = 'v2';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
const OFFLINE_URL = '/index.html';
// Files to cache for offline access
// Core shell precached for offline use. Paths MUST be the real served URLs
// (the app is mounted under /static, not /frontend).
const PRECACHE_URLS = [
'/',
'/index.html',
OFFLINE_URL,
'/manifest.json',
'/frontend/js/app.js',
'/frontend/js/state.js',
'/frontend/js/auth.js',
'/frontend/js/ui.js',
'/frontend/js/sidebar.js',
'/frontend/js/viewer.js',
'/frontend/js/search.js',
'/frontend/js/config.js',
'/frontend/js/utils.js',
'/frontend/js/i18n.js',
'/frontend/js/offline.js',
'/frontend/js/offline-db.js',
'/frontend/style.css',
'/static/lucide.min.js'
'/static/style.css',
'/static/js/app.js',
'/static/js/state.js',
'/static/js/auth.js',
'/static/js/ui.js',
'/static/js/sidebar.js',
'/static/js/viewer.js',
'/static/js/search.js',
'/static/js/config.js',
'/static/js/utils.js',
'/static/js/i18n.js',
'/static/js/offline.js',
'/static/js/offline-db.js',
'/static/js/sync.js',
'/static/js/legacy.js',
];
// ── Install: precache static assets ────────────────────────────────────────
// ── Install: precache the shell, then activate immediately ──────────────────
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Precaching static assets');
return cache.addAll(PRECACHE_URLS);
}).then(() => self.skipWaiting())
);
event.waitUntil((async () => {
const cache = await caches.open(CODE_CACHE);
// Precache individually so a single 404 never aborts the whole install.
await Promise.all(PRECACHE_URLS.map((url) =>
cache.add(new Request(url, { cache: 'reload' })).catch(() => {})
));
await self.skipWaiting();
})());
});
// ── Activate: clean old caches ─────────────────────────────────────────────
// ── Activate: drop every previous cache (old fixed-name caches included) ────
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== OFFLINE_CACHE)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim())
);
event.waitUntil((async () => {
const keep = new Set([CODE_CACHE, RUNTIME_CACHE, API_CACHE]);
const names = await caches.keys();
await Promise.all(names.map((name) => (keep.has(name) ? null : caches.delete(name))));
await self.clients.claim();
})());
});
// ── Fetch: Network-first for API, Cache-first for static ───────────────────
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Skip non-GET
if (event.request.method !== 'GET') return;
// Skip cross-origin
if (url.origin !== location.origin) return;
// API requests: Network-first with offline fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(event.request));
return;
}
// Static assets: Cache-first
event.respondWith(cacheFirstStrategy(event.request));
});
function isCodeRequest(url, request) {
if (request.mode === 'navigate') return true;
if (url.pathname === '/sw.js' || url.pathname === '/manifest.json') return true;
if (url.pathname.startsWith('/static/js/')) return true;
return /\.(?:js|mjs|css|html|json)$/i.test(url.pathname);
}
// Network-first strategy (for API)
async function networkFirstStrategy(request) {
async function networkFirst(request, cacheName, makeFallback) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(OFFLINE_CACHE);
if (response && response.ok && response.type === 'basic') {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Cache-first strategy (for static assets)
async function cacheFirstStrategy(request) {
const cached = await caches.match(request);
if (cached) {
// Stale-while-revalidate: update cache in background
fetch(request).then((response) => {
if (response.ok) {
caches.open(CACHE_NAME).then((cache) => cache.put(request, response));
}
}).catch(() => {});
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
if (makeFallback) return makeFallback();
return new Response('Offline', { status: 503 });
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fresh = fetch(request).then((response) => {
if (response && response.ok && response.type === 'basic') {
cache.put(request, response.clone());
}
return response;
}).catch(() => null);
if (cached) return cached;
return (await fresh) || new Response('Offline', { status: 503 });
}
// ── Fetch routing ───────────────────────────────────────────────────────────
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Let the browser handle range requests (PDF/streamed media) directly.
if (request.headers.has('range')) return;
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE, () =>
new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
));
return;
}
if (isCodeRequest(url, request)) {
event.respondWith(networkFirst(request, CODE_CACHE, async () => {
const cached = await caches.match(OFFLINE_URL);
return cached || new Response('<h1>Hors ligne</h1>', {
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}));
return;
}
event.respondWith(staleWhileRevalidate(request, RUNTIME_CACHE));
});
// ── Message from the page ───────────────────────────────────────────────────
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
// ── Push Event: Display notification ───────────────────────────────────────
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || data.message || 'Nouvelle notification',
@@ -136,7 +156,7 @@ self.addEventListener('push', (event) => {
],
requireInteraction: false
};
event.waitUntil(
self.registration.showNotification(data.title || 'ObsiGate', options)
);
@@ -145,21 +165,18 @@ self.addEventListener('push', (event) => {
// ── Notification Click: Open the app ───────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
// Default action or 'open' action: navigate to the file
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Try to focus existing window
for (const client of clientList) {
if (client.url.includes(urlToOpen) && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
@@ -167,7 +184,6 @@ self.addEventListener('notificationclick', (event) => {
);
});
// ── Notification Close ─────────────────────────────────────────────────────
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event.notification.tag);
});
@@ -184,7 +200,7 @@ async function syncFileIndex() {
const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200');
if (resp.ok) {
const data = await resp.json();
const files = (data.results || []).map(r => ({
const files = (data.results || []).map((r) => ({
vault: r.vault,
path: r.path,
title: r.title,
@@ -192,10 +208,9 @@ async function syncFileIndex() {
snippet: r.snippet || '',
modified: r.modified || ''
}));
// Send to main thread for IndexedDB storage
const clients = await self.clients.matchAll();
clients.forEach(client => client.postMessage({
clients.forEach((client) => client.postMessage({
type: 'OFFLINE_INDEX_SYNC',
files
}));
@@ -205,16 +220,8 @@ async function syncFileIndex() {
}
}
// ── Periodic Background Sync (if supported) ────────────────────────────────
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'periodic-index-sync') {
event.waitUntil(syncFileIndex());
}
});
// ── Message from main thread ───────────────────────────────────────────────
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});