// Серверные события (SSE, ТЗ 3.14): единый EventSource на вкладку.
// События грубозернистые (kind + данные): подписчики реагируют refetch'ем.
// Reconnect: браузер сам переподключает EventSource, но при CLOSED-состоянии
// (сессия истекла / сеть лежит долго) включается backoff 1→2→5→15 с.
import { getCurrentScope, onScopeDispose } from 'vue'
export interface RealtimeEvent {
kind: string
data?: Record<string, unknown> | null
}
type Handler = (e: RealtimeEvent) => void
// kind → подписчики; '*' — любые события и синтетика onopen (форс-рефреш)
const handlers = new Map<string, Set<Handler>>()
let source: EventSource | null = null
let retryAttempt = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
const BACKOFFS = [1000, 2000, 5000, 15000]
function emit(e: RealtimeEvent): void {
for (const h of handlers.get(e.kind) ?? []) h(e)
for (const h of handlers.get('*') ?? []) h(e)
}
function connect(): void {
source = new EventSource('/api/events')
source.onopen = () => {
retryAttempt = 0
// синтетическое событие: после разрыва все виды перечитывают данные
emit({ kind: '*' })
}
source.onmessage = (msg: MessageEvent) => {
try {
emit(JSON.parse(msg.data) as RealtimeEvent)
} catch {
// мусорный кадр — игнорируем
}
}
source.onerror = () => {
if (source && source.readyState === EventSource.CLOSED) {
source = null
scheduleReconnect()
}
// CONNECTING — браузер переподключается сам, ничего не делаем
}
}
function scheduleReconnect(): void {
if (retryTimer) return
const delay = BACKOFFS[Math.min(retryAttempt, BACKOFFS.length - 1)]
retryAttempt++
retryTimer = setTimeout(async () => {
retryTimer = null
// 401 — сессия истекла: на вход вместо вечного переподключения
try {
const res = await fetch('/api/xp')
if (res.status === 401) {
window.location.href = '/auth/login'
return
}
} catch {
// сеть лежит — пробуем дальше
}
connect()
}, delay)
}
// Idempotent: повторный вызов (HMR, повторный onMounted) соединение не дублирует
export function startRealtime(): void {
if (source || retryTimer) return
retryAttempt = 0
connect()
}
export function stopRealtime(): void {
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
source?.close()
source = null
}
export function onRealtime(kind: string, handler: Handler): () => void {
let set = handlers.get(kind)
if (!set) {
set = new Set()
handlers.set(kind, set)
}
set.add(handler)
const off = () => {
set?.delete(handler)
if (set && set.size === 0) handlers.delete(kind)
}
// Подписка из setup-контекста компонента (типичный вид) умирает вместе с
// компонентом: без этого N визитов на страницу оставляли N живых обработчиков
// в модульном Map — каждое SSE-событие дёргало load() на отмонтированных.
if (getCurrentScope()) onScopeDispose(off)
return off
}
// Дедуп XP-тостов (окно 3 с): собственные действия празднуются по заголовкам
// ответа (celebrateEarned), SSE-эхо и эхо соседней вкладки молчат.
const OWN_ACTIVITY_KEY = 'gntodo:own-activity'
const OWN_ACTIVITY_WINDOW = 3000
export function markOwnActivity(): void {
try {
localStorage.setItem(OWN_ACTIVITY_KEY, String(Date.now()))
} catch {
// приватный режим — дедуп не работает, тосты могут задвоиться
}
}
export function wasOwnActivity(): boolean {
try {
const ts = Number(localStorage.getItem(OWN_ACTIVITY_KEY))
return ts > 0 && Date.now() - ts < OWN_ACTIVITY_WINDOW
} catch {
return false
}
}
// Trailing-debounce refetch: бёрст «закрытие задачи» (task.changed + xp.changed +
// garden.changed) коалесцирует в один запрос. Также слушает '*' — форс-рефреш
// после reconnect'а. Вызывать в setup вида; отписка — при размонтировании.
export function useRealtimeRefresh(
loader: () => void | Promise<void>,
kinds: string[],
debounceMs = 300,
): void {
let timer: ReturnType<typeof setTimeout> | null = null
const run = () => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
void loader()
}, debounceMs)
}
const offs = [...kinds, '*'].map((kind) => onRealtime(kind, run))
onScopeDispose(() => {
offs.forEach((off) => off())
if (timer) clearTimeout(timer)
})
}