Newer
Older
gnexus-tasks / frontend / src / sw.ts
/// <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)
    })(),
  )
})