Newer
Older
hard-panel / panel / frontend / public / sw.js
/* GHard Monitor — service worker PWA.
 *
 * Стратегии:
 * - навигации: network-first (свежая оболочка), офлайн/ошибка → кэш;
 * - /assets/* — hashed, immutable → cache-first;
 * - мелкая статика (манифест, favicon, logo, шрифты) — stale-while-revalidate;
 * - /api/, /auth/, /webhooks/, /mcp — не кэшируются никогда.
 *
 * Версию кэша (ghard-pwa-vN) поднимать при изменении списка стратегий:
 * activate стирает кэши с чужими префиксами.
 */
const CACHE = 'ghard-pwa-v2'

const API_BYPASS = ['/api/', '/auth/', '/webhooks/', '/mcp']

const SWR = [
  '/manifest.webmanifest',
  '/favicon.svg',
  '/logo.svg',
  '/assets/fonts/IBM_Plex_Mono/',
  '/assets/fonts/phosphor-icons/',
]

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE)
      // оболочка '/' сразу: первая офлайн-сессия не упирается в пустой кэш
      .then((cache) =>
        fetch('/').then((r) => (r.ok ? cache.put('/', r) : null)).catch(() => {}),
      )
      .then(() => self.skipWaiting()),
  )
})

// Страница после первого визита (SW берёт контроль после load, ассеты уже
// проскочили) передаёт список текущих файлов оболочки — докэшируем их,
// чтобы офлайн-сборка собиралась целиком.
self.addEventListener('message', (event) => {
  if (event.data?.type !== 'shell') return
  event.waitUntil(
    caches.open(CACHE).then((cache) =>
      Promise.all(
        (event.data.urls || []).slice(0, 50).map((u) =>
          cache.match(u).then(
            (hit) =>
              hit ||
              fetch(u)
                .then((r) => (r.ok ? cache.put(u, r) : null))
                .catch(() => {}),
          ),
        ),
      ),
    ),
  )
})

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))),
    ).then(() => self.clients.claim()),
  )
})

self.addEventListener('fetch', (event) => {
  const request = event.request
  if (request.method !== 'GET') return
  const url = new URL(request.url)
  if (url.origin !== self.location.origin) return
  if (API_BYPASS.some((p) => url.pathname === p || url.pathname.startsWith(p))) return

  // навигация (SPA): старый index.html лучше, чем обрыв сети
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request)
        .then((response) => {
          // кэшируем только оболочку (любой SPA-путь отдаёт index.html)
          const copy = response.clone()
          caches.open(CACHE).then((cache) => cache.put(request, copy)).catch(() => {})
          return response
        })
        .catch(() =>
          caches.match(request).then((hit) => hit || caches.match('/')),
        ),
    )
    return
  }

  // hashed-ассеты vite: неизменяемые → cache-first
  if (url.pathname.startsWith('/assets/')) {
    event.respondWith(
      caches.match(request).then(
        (hit) =>
          hit ||
          fetch(request).then((response) => {
            if (response.ok) {
              const copy = response.clone()
              caches.open(CACHE).then((cache) => cache.put(request, copy)).catch(() => {})
            }
            return response
          }),
      ),
    )
    return
  }

  // мелкая статика: из кэша сразу, фоном обновляем
  if (SWR.some((p) => url.pathname === p || url.pathname.startsWith(p))) {
    event.respondWith(
      caches.open(CACHE).then((cache) =>
        cache.match(request).then((hit) => {
          const refresh = fetch(request)
            .then((response) => {
              if (response.ok) cache.put(request, response.clone()).catch(() => {})
              return response
            })
            .catch(() => hit)
          return hit || refresh
        }),
      ),
    )
  }
})