Newer
Older
gnexus-tasks / frontend / src / realtime.ts
// Серверные события (SSE, ТЗ 3.14): единый EventSource на вкладку.
// События грубозернистые (kind + данные): подписчики реагируют refetch'ем.
// Reconnect: браузер сам переподключает EventSource, но при CLOSED-состоянии
// (сессия истекла / сеть лежит долго) включается backoff 1→2→5→15 с.
import { getCurrentScope, onScopeDispose } from 'vue'
import { scopedKey } from './userScope'

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-эхо и эхо соседней вкладки молчат.
// Ключ — скоуп пользователя (userScope): смена аккаунта не читает чужой дедуп.
const OWN_ACTIVITY_WINDOW = 3000

export function markOwnActivity(): void {
  try {
    localStorage.setItem(scopedKey('own-activity'), String(Date.now()))
  } catch {
    // приватный режим — дедуп не работает, тосты могут задвоиться
  }
}

export function wasOwnActivity(): boolean {
  try {
    const ts = Number(localStorage.getItem(scopedKey('own-activity')))
    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)
  })
}