Files
claude-mnemonic/ui/src/composables/useSSE.ts
T

141 lines
3.7 KiB
TypeScript

import { ref, onMounted, onUnmounted } from 'vue'
import type { SSEEvent } from '@/types'
// Singleton state - shared across all useSSE() calls
const isConnected = ref(false)
const isProcessing = ref(false)
const queueDepth = ref(0)
const lastEvent = ref<SSEEvent | null>(null)
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let connectionCount = 0
let reconnectAttempt = 0
// Exponential backoff configuration
const MIN_BACKOFF = 1000 // 1 second
const MAX_BACKOFF = 30000 // 30 seconds
const BACKOFF_MULTIPLIER = 2
const JITTER_FACTOR = 0.2 // 20% jitter
function getBackoffDelay(): number {
const baseDelay = Math.min(MIN_BACKOFF * Math.pow(BACKOFF_MULTIPLIER, reconnectAttempt), MAX_BACKOFF)
const jitter = baseDelay * JITTER_FACTOR * Math.random()
return Math.floor(baseDelay + jitter)
}
export function useSSE() {
const connect = () => {
// Only create connection if not already connected
if (eventSource) {
return
}
eventSource = new EventSource('/api/events')
eventSource.onopen = () => {
isConnected.value = true
reconnectAttempt = 0 // Reset backoff on successful connection
console.log('[SSE] Connected')
}
eventSource.onmessage = (event) => {
try {
const data: SSEEvent = JSON.parse(event.data)
// Debug: log all SSE events
if (data.type !== 'processing_status') {
console.log('[SSE] Event received:', data.type, data)
}
lastEvent.value = data
if (data.type === 'processing_status') {
isProcessing.value = data.isProcessing ?? false
queueDepth.value = data.queueDepth ?? 0
}
} catch (err) {
console.error('[SSE] Parse error:', err)
}
}
eventSource.onerror = () => {
isConnected.value = false
eventSource?.close()
eventSource = null
// Exponential backoff with jitter
const delay = getBackoffDelay()
reconnectAttempt++
console.log(`[SSE] Reconnecting in ${Math.round(delay/1000)}s (attempt ${reconnectAttempt})`)
reconnectTimeout = window.setTimeout(() => {
connect()
}, delay)
}
}
const disconnect = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
}
isConnected.value = false
}
// Handle page unload/refresh to ensure SSE connection is closed immediately
const handleBeforeUnload = () => {
disconnect()
}
// Handle pagehide for mobile browsers and bfcache
const handlePageHide = (event: PageTransitionEvent) => {
if (event.persisted) {
// Page is being cached (bfcache), disconnect but don't prevent reconnect
disconnect()
}
}
// Handle pageshow to reconnect if page was restored from bfcache
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted && !eventSource) {
connect()
}
}
onMounted(() => {
connectionCount++
if (connectionCount === 1) {
// First consumer - add listeners and connect
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('pagehide', handlePageHide)
window.addEventListener('pageshow', handlePageShow)
connect()
}
})
onUnmounted(() => {
connectionCount--
if (connectionCount === 0) {
// Last consumer - remove listeners and disconnect
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('pagehide', handlePageHide)
window.removeEventListener('pageshow', handlePageShow)
disconnect()
}
})
return {
isConnected,
isProcessing,
queueDepth,
lastEvent,
reconnect: connect
}
}