/// /// /// import { build, files, prerendered, version } from '$service-worker'; import { DATA_CACHE, DATA_CACHE_MAX_ENTRIES, IMAGE_CACHE, IMAGE_CACHE_MAX_BYTES, IMAGE_CACHE_MAX_ENTRIES, SHELL_CACHE_PREFIX, isCacheableData, isCacheableObject } from '$lib/offline'; const sw = self as unknown as ServiceWorkerGlobalScope; // В dev-режиме ничего не кешируем: файлы приложения часто меняются. const DEV = import.meta.env.DEV; const SHELL_CACHE = `${SHELL_CACHE_PREFIX}${version}`; /** Оболочка приложения: собранный бандл, статика и запасная страница. */ const SHELL = [...build, ...files, ...prerendered, '/']; sw.addEventListener('install', (event) => { if (DEV) return; event.waitUntil( (async () => { const cache = await caches.open(SHELL_CACHE); await Promise.all( SHELL.map(async (url) => { try { const res = await fetch(url, { cache: 'reload' }); if (res.ok) await cache.put(url, res); } catch { /* отдельный файл не загрузился — пропускаем */ } }) ); })() ); }); sw.addEventListener('activate', (event) => { event.waitUntil( (async () => { const keys = await caches.keys(); await Promise.all( keys .filter((key) => key.startsWith(SHELL_CACHE_PREFIX) && key !== SHELL_CACHE) .map((key) => caches.delete(key)) ); await sw.clients.claim(); })() ); }); sw.addEventListener('message', (event) => { if (event.data === 'clear-offline-cache') { event.waitUntil(Promise.all([caches.delete(IMAGE_CACHE), caches.delete(DATA_CACHE)])); } }); sw.addEventListener('fetch', (event) => { if (DEV) return; const request = event.request; if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== sw.location.origin) return; // Поток событий (SSE) не перехватываем. if (url.pathname.startsWith('/api/sse/')) return; if (url.pathname.startsWith('/api/objects/') || url.pathname.startsWith('/api/avatars/')) { event.respondWith(handleImage(request, url)); return; } if (url.pathname.startsWith('/api/')) { event.respondWith(handleData(request, url)); return; } if (request.mode === 'navigate') { event.respondWith(handleNavigation(request)); return; } event.respondWith(handleAsset(request)); }); /** * Картинки: кеш по пути без подписи. Подпись объекта обновляется каждые * десять минут, но содержимое объекта неизменно, поэтому ключ кеша не должен * зависеть от строки запроса. */ async function handleImage(request: Request, url: URL): Promise { // Частичные запросы (видео) не кешируем. if (request.headers.has('range')) return fetch(request); if (!isCacheableObject(url.pathname)) return fetch(request); const cache = await caches.open(IMAGE_CACHE); const key = url.origin + url.pathname; const cached = await cache.match(key); if (cached) return cached; try { const response = await fetch(request); if (response.ok) { await cache.put(key, response.clone()); scheduleTrim(); } return response; } catch { return new Response('', { status: 504, statusText: 'Offline' }); } } /** Ответы API: сеть вперёд, при её отсутствии — последняя сохранённая копия. */ async function handleData(request: Request, url: URL): Promise { if (!isCacheableData(url.pathname)) return fetch(request); const cache = await caches.open(DATA_CACHE); try { const response = await fetch(request); if (response.ok) { await cache.put(request, response.clone()); scheduleTrim(); } return response; } catch { const cached = await cache.match(request); if (cached) return cached; return new Response( JSON.stringify({ error: { code: 'offline', message: 'Нет соединения с сервером' } }), { status: 503, headers: { 'content-type': 'application/json' } } ); } } /** Переходы: сеть, а без неё — сохранённая оболочка приложения. */ async function handleNavigation(request: Request): Promise { try { return await fetch(request); } catch { const cache = await caches.open(SHELL_CACHE); const cached = await cache.match('/'); if (cached) return cached; return new Response('Офлайн', { status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' } }); } } /** Статика приложения: сначала кеш, затем сеть с дозаписью. */ async function handleAsset(request: Request): Promise { const cache = await caches.open(SHELL_CACHE); // wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы // предпросмотр правок, поэтому его всегда берём из сети. if (new URL(request.url).pathname.endsWith('.wasm')) { try { const fresh = await fetch(request, { cache: 'no-store' }); if (fresh.ok) await cache.put(request, fresh.clone()); return fresh; } catch { const stale = await cache.match(request); if (stale) return stale; } } const cached = await cache.match(request); if (cached) return cached; const response = await fetch(request); const type = response.headers.get('content-type') ?? ''; // Запасная страница SPA не должна отдаваться вместо статики. if (response.ok && type.includes('text/html')) { return new Response('', { status: 404, statusText: 'Not Found' }); } if (response.ok) await cache.put(request, response.clone()); return response; } let trimCounter = 0; /** Периодически ужимаем кеш картинок, вытесняя самые старые записи. */ function scheduleTrim() { trimCounter += 1; if (trimCounter < 20) return; trimCounter = 0; void trimImageCache(); void trimDataCache(); } async function trimImageCache() { const cache = await caches.open(IMAGE_CACHE); const keys = await cache.keys(); if (!keys.length) return; const sizes: number[] = []; let total = 0; for (const key of keys) { const response = await cache.match(key); const size = Number(response?.headers.get('content-length') ?? 0); sizes.push(size); total += size; } let index = 0; while ( index < keys.length && (total > IMAGE_CACHE_MAX_BYTES || keys.length - index > IMAGE_CACHE_MAX_ENTRIES) ) { await cache.delete(keys[index]); total -= sizes[index]; index += 1; } } /** Ужимаем кеш ответов API: остаются последние сохранённые списки. */ async function trimDataCache() { const cache = await caches.open(DATA_CACHE); const keys = await cache.keys(); let index = 0; while (index < keys.length && keys.length - index > DATA_CACHE_MAX_ENTRIES) { await cache.delete(keys[index]); index += 1; } }