/* * Сервис-воркер glchat (AGENT.md, Фаза 5): офлайн-заглушка и кэш статики. * * Правила простые и безопасные: * - навигация — сеть, при ошибке офлайн-страница (данные всегда свежие); * - собранная статика `/assets/*` (хешированные имена) — из кэша; * - API, шлюз и файлы никогда не кэшируются: это приватные данные. */ const VERSION = 'glchat-v1'; const OFFLINE_URL = '/offline.html'; self.addEventListener('install', (event) => { event.waitUntil( caches.open(VERSION).then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest'])), ); self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))), ) .then(() => self.clients.claim()), ); }); /** Приватные запросы: их нельзя ни кэшировать, ни отдавать из кэша. */ function isPrivate(url) { return ( url.pathname.startsWith('/api/') || url.pathname.startsWith('/files/') || url.pathname.startsWith('/gateway') || url.pathname.startsWith('/rtc') ); } 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 || isPrivate(url)) { return; } if (request.mode === 'navigate') { event.respondWith( fetch(request).catch(() => caches.match(request).then((cached) => cached ?? caches.match(OFFLINE_URL)), ), ); return; } if (url.pathname.startsWith('/assets/')) { event.respondWith( caches.match(request).then( (cached) => cached ?? fetch(request).then((response) => { if (response.ok) { const copy = response.clone(); void caches.open(VERSION).then((cache) => cache.put(request, copy)); } return response; }), ), ); return; } // Иконки, манифест и прочая статика: сеть, при недоступности — кэш. event.respondWith( fetch(request) .then((response) => { if ( response.ok && (url.pathname.startsWith('/icons/') || url.pathname === '/manifest.webmanifest') ) { const copy = response.clone(); void caches.open(VERSION).then((cache) => cache.put(request, copy)); } return response; }) .catch(() => caches.match(request).then((cached) => cached ?? Response.error())), ); }); /* * Web Push (AGENT.md 7.16, Фаза 7). * * Текст уведомления готовит сервер (internal/push/rules.go) по тем же * правилам, что нативные уведомления desktop-обёртки, а воркер только * показывает его и открывает комнату по клику. Уведомление обязано * показываться на каждый push: иначе браузер может отозвать подписку. */ /** sameOriginPath оставляет только внутренний путь: payload приходит извне. */ function sameOriginPath(value) { if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) { return '/app'; } return value; } self.addEventListener('push', (event) => { if (!event.data) { return; } let payload = null; try { payload = event.data.json(); } catch { return; } if (payload === null || typeof payload !== 'object') { return; } const title = typeof payload.title === 'string' && payload.title !== '' ? payload.title : 'glchat'; const body = typeof payload.body === 'string' ? payload.body : ''; const options = { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', // Уведомления одной комнаты заменяют друг друга (Topic на сервере). tag: typeof payload.tag === 'string' && payload.tag !== '' ? payload.tag : undefined, data: { url: sameOriginPath(payload.url), channel_id: typeof payload.channel_id === 'string' ? payload.channel_id : '', guild_id: typeof payload.guild_id === 'string' ? payload.guild_id : '', }, }; event.waitUntil(self.registration.showNotification(title, options)); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); const target = sameOriginPath(event.notification.data && event.notification.data.url); event.waitUntil( (async () => { const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const client of windows) { if ('focus' in client) { await client.focus(); // Открытое окно переходит в комнату без перезагрузки: клиент слушает // это сообщение и делает history.pushState (web/src/lib/webPush.ts). client.postMessage({ type: 'push-navigate', url: target }); return; } } await self.clients.openWindow(target); })(), ); });