/** * Client wrapper around EventSource('/api/notifications/stream'). * * The auto-mounting plugin (plugins/03.notifications-stream.client.ts) uses * this to pipe SSE events into useToast(). It's exposed as a composable so * pages can also drive their own handlers (e.g. badge counters, sounds, etc.) * without re-implementing the connection logic. */ export const useNotificationStream = () => { const eventSource = ref(null) const isConnected = ref(false) let reconnectTimer: ReturnType | null = null let userOnNotification: ((data: any) => void) | null = null const start = (onNotification: (data: any) => void) => { if (eventSource.value) return // already running userOnNotification = onNotification const es = new EventSource('/api/notifications/stream') eventSource.value = es es.addEventListener('connected', (e: MessageEvent) => { isConnected.value = true try { const data = JSON.parse(e.data) console.log('[NotifStream] connected', data) } catch { console.log('[NotifStream] connected') } }) es.addEventListener('notification', (e: MessageEvent) => { try { const data = JSON.parse(e.data) onNotification(data) } catch (err) { console.error('[NotifStream] failed to parse notification payload:', err) } }) es.onerror = () => { isConnected.value = false // EventSource auto-reconnects on transient errors. Only act when the // browser has given up (readyState === CLOSED) — typically after a 401 // or other non-recoverable response. In that case schedule a manual // retry rather than tight-looping. if (es.readyState === EventSource.CLOSED) { console.warn('[NotifStream] connection closed by server; will retry in 30s') eventSource.value = null if (reconnectTimer) clearTimeout(reconnectTimer) reconnectTimer = setTimeout(() => { if (userOnNotification) start(userOnNotification) }, 30000) } } } const stop = () => { if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } if (eventSource.value) { eventSource.value.close() eventSource.value = null isConnected.value = false } } // Auto-cleanup if the composable is used inside a component. if (getCurrentInstance()) { onUnmounted(() => { stop() }) } return { start, stop, isConnected: readonly(isConnected) } }