/// <reference lib="webworker" />
// Свой service worker (ТЗ 3.15, 3.21): плагин собирает этот файл в dist/sw.js
// (injectManifest). Прежний generateSW вставлял обработчик push некуда —
// системные уведомления требуют своего воркера, поэтому оффлайн-обвязка
// (precache, SPA-fallback, CacheFirst) переехала сюда из vite.config.ts.
//
// `declare const self: ServiceWorkerGlobalScope` — не украшение: файл модуль
// (есть импорты), а в модуле `self` типизирован как Window, и без объявления
// не существует ни clients, ни registration.
import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { CacheFirst } from 'workbox-strategies'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
declare const self: ServiceWorkerGlobalScope
// Обновление без «закройте все вкладки»: новый воркер забирает управление сразу
// (в generateSW это делал registerType: 'autoUpdate')
self.skipWaiting()
clientsClaim()
cleanupOutdatedCaches()
// Манифест precache вписывает плагин на этапе сборки; строка должна остаться
// буквальной — по ней он и ищет точку вставки
const precache = self.__WB_MANIFEST
precacheAndRoute(precache)
// SPA-fallback: любой переход внутри приложения отдаём из кэша.
// /api, /auth и /mcp из него исключены — на них отвечает сервер, и ответ
// index.html'ом был бы ложью (401 вместо разлогина, JSON вместо страницы).
//
// Ставим маршрут только если index.html и правда в precache: в dev манифест
// пуст (VITE_PWA_DEV=1), а createHandlerBoundToURL на незнакомом URL бросает
// исключение прямо при вычислении воркера — регистрация падала бы целиком,
// и отлаживать push было бы негде
const hasAppShell = precache.some(
(entry) => (typeof entry === 'string' ? entry : entry.url) === 'index.html',
)
if (hasAppShell) {
registerRoute(
new NavigationRoute(createHandlerBoundToURL('index.html'), {
denylist: [/^\/api\//, /^\/auth\//, /^\/mcp\//],
}),
)
}
// Вложения и ассеты кита: сначала кэш, сеть — только если файла там нет.
// Оба неизменяемы по адресу, поэтому CacheFirst безопасен
registerRoute(
({ url, request }) =>
request.method === 'GET' &&
(url.pathname.startsWith('/api/attachments/') || url.pathname.startsWith('/assets/')),
new CacheFirst({
cacheName: 'attachments-and-assets',
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
}),
)
type PushPayload = {
kind?: string
title?: string
body?: string
url?: string
tag?: string
}
function parse(event: PushEvent): PushPayload {
// Payload может не прийти вовсе (push без данных) или прийти битым — тогда
// показываем безликую «новую» запись: молча проглотить push нельзя, браузер
// за это наказывает (в Chrome — «тихим» отказом в разрешении)
try {
return (event.data?.json() ?? {}) as PushPayload
} catch {
return {}
}
}
/**
* Открыта ли видимая вкладка приложения.
*
* Именно видимость, а не факт SSE-соединения: шина не различает «вкладка на
* экране» и «вкладка в фоне с открытым EventSource», а фону уведомление как раз
* и нужно. При видимой вкладке событие уже показано тостом — системное
* уведомление его продублировало бы.
*/
async function hasVisibleTab(): Promise<boolean> {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
return windows.some((client) => client.visibilityState === 'visible')
}
self.addEventListener('push', (event) => {
event.waitUntil(
(async () => {
if (await hasVisibleTab()) return
const payload = parse(event)
await self.registration.showNotification(payload.title || 'GNexus Tasks', {
body: payload.body || '',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
// tag схлопывает уведомления об одной задаче: в шторке не должно
// копиться «агент взял задачу» ×5, пока владелец не открыл приложение
tag: payload.tag,
data: { url: payload.url || '/' },
})
})(),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data as { url?: string } | undefined)?.url || '/'
event.waitUntil(
(async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
const client = windows.find((item) => 'focus' in item)
if (client) {
// Вкладка уже открыта: ведём её по ссылке и поднимаем на экран —
// новое окно рядом с открытым приложением выглядело бы как два приложения
await client.focus()
await client.navigate(url)
return
}
await self.clients.openWindow(url)
})(),
)
})