import { Client } from '@stomp/stompjs' import { useConnectionStore } from '../store/useConnectionStore' export interface ChatEvent { messageId?: string type: 'TOKEN' | 'TOOL_STATUS' | 'DONE' | 'ERROR' content?: string tool?: string status?: string } interface QueuedMessage { destination: string body: string } class StompService { private client: Client | null = null private reconnectAttempt = 0 private reconnectTimeoutId: any = null private messageQueue: QueuedMessage[] = [] private messageListeners: Array<(event: ChatEvent) => void> = [] private maxQueueSize = 20 private getWsUrl(): string { const host = window.location.hostname || 'localhost' const port = window.location.port ? window.location.port : '9332' // If dev server port 9333 or similar, connect to 9332 if port is 9333, else same port const targetPort = port === '9333' || port === '5173' ? '9332' : port const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return `${protocol}//${host}:${targetPort}/ws` } public connect() { if (this.client && this.client.active) { return } useConnectionStore.getState().setStatus('connecting') this.client = new Client({ brokerURL: this.getWsUrl(), reconnectDelay: 0, // Manual reconnect management for exponential backoff + jitter heartbeatIncoming: 10000, heartbeatOutgoing: 10000, onConnect: () => { console.log('[StompService] Connected to WebSocket') this.reconnectAttempt = 0 useConnectionStore.getState().setStatus('connected') // Subscribe to user chat events this.client?.subscribe('/user/queue/chat-events', (message) => { try { const event: ChatEvent = JSON.parse(message.body) this.notifyMessageListeners(event) } catch (e) { console.error('[StompService] Failed to parse message body:', e) } }) // Flush offline queue this.flushQueue() }, onWebSocketClose: () => { console.warn('[StompService] WebSocket connection closed') useConnectionStore.getState().setStatus('disconnected') this.scheduleReconnect() }, onStompError: (frame) => { console.error('[StompService] STOMP error frame received:', frame.headers['message']) useConnectionStore.getState().setStatus('error') this.scheduleReconnect() }, onWebSocketError: (event) => { console.error('[StompService] WebSocket error:', event) useConnectionStore.getState().setStatus('error') } }) this.client.activate() } public disconnect() { if (this.reconnectTimeoutId) { clearTimeout(this.reconnectTimeoutId) this.reconnectTimeoutId = null } if (this.client) { this.client.deactivate() this.client = null } useConnectionStore.getState().setStatus('disconnected') } private scheduleReconnect() { if (this.reconnectTimeoutId) return // Base exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000) // Jitter: ±50% (0.5 to 1.5 multiplier) const jitter = 0.5 + Math.random() const delay = Math.round(baseDelay * jitter) this.reconnectAttempt++ console.log(`[StompService] Scheduling reconnect attempt #${this.reconnectAttempt} in ${delay}ms`) this.reconnectTimeoutId = setTimeout(() => { this.reconnectTimeoutId = null if (this.client) { this.client.activate() } else { this.connect() } }, delay) } public publish(destination: string, payload: object): boolean { const body = JSON.stringify(payload) const isConnected = this.client && this.client.connected if (isConnected) { this.client!.publish({ destination, body }) return true } else { if (this.messageQueue.length >= this.maxQueueSize) { console.warn('[StompService] Queue limit reached (20). Message dropped.') return false } this.messageQueue.push({ destination, body }) useConnectionStore.getState().setQueuedCount(this.messageQueue.length) console.log(`[StompService] Message queued offline (${this.messageQueue.length}/${this.maxQueueSize})`) // Ensure we are trying to reconnect this.connect() return true } } private flushQueue() { if (!this.client || !this.client.connected) return while (this.messageQueue.length > 0) { const msg = this.messageQueue.shift() if (msg) { this.client.publish({ destination: msg.destination, body: msg.body }) } } useConnectionStore.getState().setQueuedCount(0) console.log('[StompService] Offline message queue flushed') } public onMessage(callback: (event: ChatEvent) => void): () => void { this.messageListeners.push(callback) return () => { this.messageListeners = this.messageListeners.filter((cb) => cb !== callback) } } private notifyMessageListeners(event: ChatEvent) { this.messageListeners.forEach((listener) => { try { listener(event) } catch (e) { console.error('[StompService] Error in message listener:', e) } }) } public getQueueLength(): number { return this.messageQueue.length } } export const stompService = new StompService()