/* 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
}),
),
)
}
})