2026-09-21 19:42:12 +03:00
|
|
|
/*
|
|
|
|
|
* Сервис-воркер 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())),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-09-26 16:15:24 +03:00
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* 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);
|
|
|
|
|
})(),
|
|
|
|
|
);
|
|
|
|
|
});
|