| import type { ClientMessage, ServerMessage } from '../types/chat' |
| import { getAuthToken, getBaseUrl } from './client' |
|
|
| type MessageHandler = (msg: ServerMessage) => void |
|
|
| type Connection = { |
| ws: WebSocket |
| handlers: Set<MessageHandler> |
| reconnectTimer: ReturnType<typeof setTimeout> | null |
| reconnectAttempt: number |
| pingInterval: ReturnType<typeof setInterval> | null |
| intentionalClose: boolean |
| pendingMessages: ClientMessage[] |
| } |
|
|
| class WebSocketManager { |
| private connections = new Map<string, Connection>() |
|
|
| isConnected(sessionId: string): boolean { |
| const conn = this.connections.get(sessionId) |
| return conn?.ws.readyState === WebSocket.OPEN |
| } |
|
|
| getConnectedSessionIds(): string[] { |
| return [...this.connections.keys()] |
| } |
|
|
| connect(sessionId: string) { |
| const existing = this.connections.get(sessionId) |
| if ( |
| existing && |
| !existing.intentionalClose && |
| ( |
| existing.ws.readyState === WebSocket.OPEN || |
| existing.ws.readyState === WebSocket.CONNECTING || |
| existing.reconnectTimer !== null |
| ) |
| ) { |
| return |
| } |
|
|
| const ws = new WebSocket(buildSessionWebSocketUrl(sessionId)) |
|
|
| const conn: Connection = { |
| ws, |
| handlers: existing?.handlers ?? new Set(), |
| reconnectTimer: null, |
| reconnectAttempt: existing?.reconnectAttempt ?? 0, |
| pingInterval: null, |
| intentionalClose: false, |
| pendingMessages: existing?.pendingMessages ?? [], |
| } |
| this.connections.set(sessionId, conn) |
|
|
| ws.onopen = () => { |
| conn.reconnectAttempt = 0 |
| this.startPingLoop(sessionId) |
| while (conn.pendingMessages.length > 0) { |
| const msg = conn.pendingMessages.shift()! |
| ws.send(JSON.stringify(msg)) |
| } |
| } |
|
|
| ws.onmessage = (event) => { |
| try { |
| const msg = JSON.parse(event.data as string) as ServerMessage |
| for (const handler of conn.handlers) { |
| handler(msg) |
| } |
| } catch { |
| |
| } |
| } |
|
|
| ws.onclose = () => { |
| this.stopPingLoop(sessionId) |
| if (!conn.intentionalClose && this.connections.get(sessionId) === conn) { |
| this.scheduleReconnect(sessionId, conn) |
| } |
| } |
|
|
| ws.onerror = () => { |
| |
| } |
| } |
|
|
| disconnect(sessionId: string) { |
| const conn = this.connections.get(sessionId) |
| if (!conn) return |
|
|
| conn.intentionalClose = true |
| this.stopPingLoop(sessionId) |
| if (conn.reconnectTimer) { |
| clearTimeout(conn.reconnectTimer) |
| conn.reconnectTimer = null |
| } |
| conn.pendingMessages = [] |
|
|
| conn.ws.close() |
| this.connections.delete(sessionId) |
| } |
|
|
| disconnectAll() { |
| for (const sessionId of [...this.connections.keys()]) { |
| this.disconnect(sessionId) |
| } |
| } |
|
|
| send(sessionId: string, message: ClientMessage) { |
| let conn = this.connections.get(sessionId) |
| if (!conn) { |
| this.connect(sessionId) |
| conn = this.connections.get(sessionId) |
| if (!conn) return |
| } |
|
|
| if (conn.ws.readyState === WebSocket.OPEN) { |
| conn.ws.send(JSON.stringify(message)) |
| return |
| } |
|
|
| conn.pendingMessages.push(message) |
|
|
| if ( |
| conn.ws.readyState === WebSocket.CLOSED || |
| conn.ws.readyState === WebSocket.CLOSING |
| ) { |
| if (!conn.intentionalClose && !conn.reconnectTimer) { |
| this.scheduleReconnect(sessionId, conn) |
| } |
| } |
| } |
|
|
| onMessage(sessionId: string, handler: MessageHandler): () => void { |
| const conn = this.connections.get(sessionId) |
| if (!conn) return () => {} |
| conn.handlers.add(handler) |
| return () => { conn.handlers.delete(handler) } |
| } |
|
|
| clearHandlers(sessionId: string) { |
| const conn = this.connections.get(sessionId) |
| if (conn) conn.handlers.clear() |
| } |
|
|
| private startPingLoop(sessionId: string) { |
| this.stopPingLoop(sessionId) |
| const conn = this.connections.get(sessionId) |
| if (!conn) return |
| conn.pingInterval = setInterval(() => { |
| this.send(sessionId, { type: 'ping' }) |
| }, 30_000) |
| } |
|
|
| private stopPingLoop(sessionId: string) { |
| const conn = this.connections.get(sessionId) |
| if (conn?.pingInterval) { |
| clearInterval(conn.pingInterval) |
| conn.pingInterval = null |
| } |
| } |
|
|
| private scheduleReconnect(sessionId: string, conn: Connection) { |
| if (conn.reconnectTimer) { |
| clearTimeout(conn.reconnectTimer) |
| } |
|
|
| const delay = Math.min(1000 * 2 ** conn.reconnectAttempt, 30_000) |
| conn.reconnectAttempt++ |
|
|
| conn.reconnectTimer = setTimeout(() => { |
| if (this.connections.get(sessionId) === conn && !conn.intentionalClose) { |
| conn.reconnectTimer = null |
| this.connect(sessionId) |
| } |
| }, delay) |
| } |
| } |
|
|
| export function buildSessionWebSocketUrl(sessionId: string) { |
| const url = new URL(getBaseUrl()) |
| url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' |
| const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '') |
| url.pathname = `${basePath}/ws/${encodeURIComponent(sessionId)}` |
|
|
| const token = getAuthToken() |
| if (token) { |
| url.searchParams.set('token', token) |
| } else { |
| url.searchParams.delete('token') |
| } |
|
|
| return url.toString() |
| } |
|
|
| export const wsManager = new WebSocketManager() |
|
|