Newer
Older
navi-1 / webclient / dist / sw.js
/* 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.
 *
 * 9d0215f759d18111 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 = '9d0215f759d18111';
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);
  })());
});