Files
agent-desk-ng/web/lib/realtime-connection.ts
mono 299ea50fb5 fix: recover chat replies and organize setup navigation [skip ci]
Targeted source-only patch from reviewed local e370485. Adds bounded realtime/catch-up recovery and moves the setup wizard under System. Static checks only; regression tests, full build and runtime verification have not been run. No deployment. Separate hardening-review branch is not merged. Expected tree: 6235478b9a.
2026-10-16 13:43:41 +00:00

248 lines
6.9 KiB
TypeScript

// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
"use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
type RealtimeConnectionManagerOptions = {
createSocket: () => WebSocket
beforeConnect?: () => Promise<unknown> | unknown
canReconnect?: () => boolean
onStatusChange?: (status: RealtimeConnectionStatus) => void
onSocketChange?: (socket: WebSocket | null) => void
onOpen?: (socket: WebSocket) => void
onMessage?: (event: MessageEvent, socket: WebSocket) => void
onClose?: (event: CloseEvent, socket: WebSocket) => void
onError?: (event: Event, socket: WebSocket) => void
onConnectError?: (error: unknown) => void
buildPingMessage?: () => string
connectTimeoutMs?: number
pingIntervalMs?: number
reconnectBaseDelayMs?: number
reconnectMaxDelayMs?: number
}
type DisconnectOptions = {
reconnect?: boolean
updateStatus?: boolean
}
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
const DEFAULT_PING_INTERVAL_MS = 20000
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
export function createRealtimeConnectionManager(
options: RealtimeConnectionManagerOptions
) {
let socket: WebSocket | null = null
let connectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let reconnectAttempt = 0
let reconnectEnabled = false
let connectEpoch = 0
const clearConnectTimer = () => {
if (connectTimer !== null) {
globalThis.clearTimeout(connectTimer)
connectTimer = null
}
}
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
globalThis.clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const clearPingTimer = () => {
if (pingTimer !== null) {
globalThis.clearInterval(pingTimer)
pingTimer = null
}
}
const clearTimers = () => {
clearConnectTimer()
clearReconnectTimer()
clearPingTimer()
}
const setSocket = (nextSocket: WebSocket | null) => {
socket = nextSocket
options.onSocketChange?.(nextSocket)
}
const canReconnect = () => {
return reconnectEnabled && (options.canReconnect?.() ?? true)
}
const scheduleReconnect = () => {
if (!canReconnect() || reconnectTimer !== null) {
return
}
const delay = Math.min(
(options.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
2 ** reconnectAttempt,
options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
)
options.onStatusChange?.("connecting")
reconnectTimer = globalThis.setTimeout(() => {
reconnectTimer = null
reconnectAttempt += 1
if (canReconnect()) {
void connect()
}
}, delay)
}
const connect = async () => {
reconnectEnabled = true
if (!(options.canReconnect?.() ?? true)) {
return
}
disconnect({ reconnect: true, updateStatus: false })
reconnectEnabled = true
options.onStatusChange?.("connecting")
const currentEpoch = ++connectEpoch
connectTimer = globalThis.setTimeout(() => {
if (currentEpoch !== connectEpoch) {
return
}
disconnect({ reconnect: true, updateStatus: false })
options.onStatusChange?.("disconnected")
// Callers already localize their fallback for non-Error failures.
options.onConnectError?.({ code: "realtime_connect_timeout" })
scheduleReconnect()
}, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
if (options.beforeConnect) {
try {
await options.beforeConnect()
} catch (error) {
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return
}
clearConnectTimer()
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
return
}
}
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return
}
if (!(options.canReconnect?.() ?? true)) {
clearConnectTimer()
options.onStatusChange?.("disconnected")
return
}
let nextSocket: WebSocket
try {
nextSocket = options.createSocket()
} catch (error) {
clearConnectTimer()
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
return
}
setSocket(nextSocket)
nextSocket.addEventListener("open", () => {
if (socket !== nextSocket) {
return
}
clearConnectTimer()
clearReconnectTimer()
clearPingTimer()
reconnectAttempt = 0
options.onStatusChange?.("connected")
const buildPingMessage =
options.buildPingMessage ?? (() => JSON.stringify({ type: "ping" }))
pingTimer = globalThis.setInterval(() => {
if (nextSocket.readyState === WebSocket.OPEN) {
nextSocket.send(buildPingMessage())
}
}, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS)
options.onOpen?.(nextSocket)
})
nextSocket.addEventListener("message", (event) => {
if (socket === nextSocket) {
options.onMessage?.(event, nextSocket)
}
})
nextSocket.addEventListener("close", (event) => {
// A replaced socket may finish closing after the next one has opened.
// It must not clear that connection's heartbeat or subscription state.
if (socket !== nextSocket) {
return
}
clearConnectTimer()
clearPingTimer()
setSocket(null)
options.onClose?.(event, nextSocket)
if (canReconnect()) {
scheduleReconnect()
} else {
options.onStatusChange?.("disconnected")
}
})
nextSocket.addEventListener("error", (event) => {
if (socket !== nextSocket) {
return
}
options.onError?.(event, nextSocket)
// An error need not be followed promptly by close. Detach it now so
// callers cannot keep reusing a failed OPEN/CONNECTING socket.
disconnect({ reconnect: true, updateStatus: false })
options.onStatusChange?.("disconnected")
scheduleReconnect()
})
}
const disconnect = (disconnectOptions?: DisconnectOptions) => {
// A timed-out or abandoned authentication wait must not open a late socket.
connectEpoch += 1
reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers()
if (!reconnectEnabled) {
reconnectAttempt = 0
}
const currentSocket = socket
setSocket(null)
if (
currentSocket &&
(currentSocket.readyState === WebSocket.OPEN ||
currentSocket.readyState === WebSocket.CONNECTING)
) {
currentSocket.close()
}
if (disconnectOptions?.updateStatus ?? !reconnectEnabled) {
options.onStatusChange?.("disconnected")
}
}
return {
connect,
disconnect,
reconnect: () => {
reconnectEnabled = true
connect()
},
getSocket: () => socket,
}
}