/* Navi webclient service worker — hand-rolled, no deps.
*
* Strategies:
* - navigation requests: network-first (3s race) with cached-shell fallback,
* then background refresh — after a deploy old hashed assets are deleted
* from dist, so a stale cached shell would 404 on its entry chunks;
* - /assets/*: cache-first (Vite content-hashes filenames — immutable);
* - /images/*: cache-first, capped;
* - everything dynamic (/api, /ws, /auth, /push, /content, ...): pass-through.
*
* __NAVI_BUILD_VERSION__ is stamped at build time (vite.config.js closeBundle)
* with a digest of index.html + the hashed asset filenames — so sw.js bytes
* change on every build, the browser picks the new SW up (served no-store),
* and activation evicts caches from previous builds.
*/
const VERSION = '__NAVI_BUILD_VERSION__';
const SHELL_CACHE = `navi-shell-${VERSION}`;
const ASSETS_CACHE = `navi-assets-${VERSION}`;
const IMAGES_CACHE = 'navi-images';
const IMAGES_CACHE_LIMIT = 200;
const NAV_TIMEOUT_MS = 3000;
// Requests that must always reach the server (dynamic/authenticated content).
const PASS_THROUGH = [
'/api/', '/ws/', '/auth/', '/push/', '/content/', '/content-viewers/',
'/debug', '/admin',
];
function isPassThrough(url) {
return url.pathname.startsWith('/') && PASS_THROUGH.some(
(p) => url.pathname === p || url.pathname === p.slice(0, -1) || url.pathname.startsWith(p),
);
}
async function cacheShell() {
const cache = await caches.open(SHELL_CACHE);
try {
// cache: 'no-store' — bypass the HTTP cache; the Cache API is allowed to
// store the response even though it carries a no-store directive.
const resp = await fetch('/', { cache: 'no-store' });
if (resp.ok) {
await cache.put('/', resp.clone());
// Warm the hashed entry chunks referenced by the shell so
// first-visit-then-offline works without a second online visit.
const html = await resp.text();
const assetsCache = await caches.open(ASSETS_CACHE);
const urls = [...html.matchAll(/\/assets\/[^"'\s)]+/g)].map((m) => m[0]);
await Promise.all(urls.map(async (u) => {
try {
const asset = await fetch(u, { cache: 'no-store' });
if (asset.ok) await assetsCache.put(u, asset);
} catch { /* offline warm is best-effort */ }
}));
}
} catch { /* first install while offline: nothing to warm */ }
}
async function trimCache(cacheName, limit) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= limit) return;
await Promise.all(keys.slice(0, keys.length - limit).map((k) => cache.delete(k)));
}
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
await cacheShell();
await self.skipWaiting();
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names
.filter((n) => n.startsWith('navi-')
&& n !== SHELL_CACHE && n !== ASSETS_CACHE && n !== IMAGES_CACHE)
.map((n) => caches.delete(n)));
await self.clients.claim();
})());
});
async function fetchWithTimeout(request, ms) {
const timer = new Promise((resolve) => setTimeout(() => resolve(null), ms));
const race = Promise.race([
fetch(request).then((r) => (r && r.ok ? r : null)),
timer,
]);
return race;
}
async function handleNavigation(request) {
const cache = await caches.open(SHELL_CACHE);
const fresh = await fetchWithTimeout(request, NAV_TIMEOUT_MS);
if (fresh) {
cache.put('/', fresh.clone());
return fresh;
}
const cached = await cache.match('/');
if (cached) {
// Refresh the shell in the background for the next load.
fetch(request, { cache: 'no-store' })
.then((r) => { if (r && r.ok) cache.put('/', r.clone()); })
.catch(() => {});
return cached;
}
return fetch(request);
}
async function cacheFirst(request, cacheName, limit) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const resp = await fetch(request);
if (resp && resp.ok && resp.type === 'basic') {
await cache.put(request, resp.clone());
if (limit) await trimCache(cacheName, limit);
}
return resp;
}
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;
if (isPassThrough(url)) return;
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(request));
return;
}
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSETS_CACHE));
return;
}
if (url.pathname.startsWith('/images/')) {
event.respondWith(cacheFirst(request, IMAGES_CACHE, IMAGES_CACHE_LIMIT));
}
});
// ── Web push ─────────────────────────────────────────────────────────────
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch { /* malformed payload — fall back to a bare notification */ }
const title = data.title || 'Navi';
const body = data.body || 'Новый ответ готов';
const url = data.url || '/';
event.waitUntil(self.registration.showNotification(title, {
body,
tag: data.session_id ? `navi-turn-${data.session_id}` : 'navi-turn',
renotify: true,
icon: '/images/icon-192.png',
badge: '/images/icon-192.png',
data: { url },
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of all) {
// Focus an open window and point it at the notification's session.
await client.focus();
if ('navigate' in client) {
try { await client.navigate(url); } catch { /* cross-origin or stale */ }
}
return;
}
await self.clients.openWindow(url);
})());
});