176 lines
5.3 KiB
TypeScript
176 lines
5.3 KiB
TypeScript
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()
|