240 lines
8.2 KiB
JavaScript
240 lines
8.2 KiB
JavaScript
/* ObsiGate — Service Worker (PWA offline + push)
|
|
* Version: 4 (2026-09-12)
|
|
*
|
|
* 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 SW_VERSION = 'v29';
|
|
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';
|
|
|
|
// Core shell precached for offline use. Paths MUST be the real served URLs
|
|
// (the app is mounted under /static, not /frontend).
|
|
const PRECACHE_URLS = [
|
|
'/',
|
|
OFFLINE_URL,
|
|
'/manifest.json',
|
|
'/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/xlsx/refs.js',
|
|
'/static/js/xlsx/command-bar.js',
|
|
'/static/js/xlsx/dashboard.js',
|
|
'/static/js/xlsx/context-menu.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',
|
|
'/static/js/collab.js',
|
|
];
|
|
|
|
// ── Install: precache the shell, then activate immediately ──────────────────
|
|
self.addEventListener('install', (event) => {
|
|
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: drop every previous cache (old fixed-name caches included) ────
|
|
self.addEventListener('activate', (event) => {
|
|
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();
|
|
})());
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
async function networkFirst(request, cacheName, makeFallback) {
|
|
try {
|
|
const response = await fetch(request);
|
|
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;
|
|
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;
|
|
|
|
// Streamed audio/video is large and range-driven — never cache it
|
|
// (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached.
|
|
if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) {
|
|
event.respondWith(fetch(request));
|
|
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',
|
|
icon: '/static/icon-192.png',
|
|
badge: '/static/badge-72.png',
|
|
vibrate: [200, 100, 200],
|
|
tag: data.tag || 'obsigate-notification',
|
|
renotify: true,
|
|
data: data.data || {},
|
|
actions: [
|
|
{ action: 'open', title: 'Ouvrir' },
|
|
{ action: 'dismiss', title: 'Ignorer' }
|
|
],
|
|
requireInteraction: false
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'ObsiGate', options)
|
|
);
|
|
});
|
|
|
|
// ── Notification Click: Open the app ───────────────────────────────────────
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
if (event.action === 'dismiss') return;
|
|
|
|
const urlToOpen = event.notification.data?.url || '/';
|
|
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
|
for (const client of clientList) {
|
|
if (client.url.includes(urlToOpen) && 'focus' in client) {
|
|
return client.focus();
|
|
}
|
|
}
|
|
if (clients.openWindow) {
|
|
return clients.openWindow(urlToOpen);
|
|
}
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclose', (event) => {
|
|
console.log('[SW] Notification closed:', event.notification.tag);
|
|
});
|
|
|
|
// ── Background Sync: Periodic index sync ──────────────────────────────────
|
|
self.addEventListener('sync', (event) => {
|
|
if (event.tag === 'sync-file-index') {
|
|
event.waitUntil(syncFileIndex());
|
|
}
|
|
});
|
|
|
|
async function syncFileIndex() {
|
|
try {
|
|
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) => ({
|
|
vault: r.vault,
|
|
path: r.path,
|
|
title: r.title,
|
|
tags: r.tags || [],
|
|
snippet: r.snippet || '',
|
|
modified: r.modified || ''
|
|
}));
|
|
|
|
const clients = await self.clients.matchAll();
|
|
clients.forEach((client) => client.postMessage({
|
|
type: 'OFFLINE_INDEX_SYNC',
|
|
files
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.warn('[SW] Background sync failed:', e);
|
|
}
|
|
}
|
|
|
|
self.addEventListener('periodicsync', (event) => {
|
|
if (event.tag === 'periodic-index-sync') {
|
|
event.waitUntil(syncFileIndex());
|
|
}
|
|
});
|