cc-haha/desktop/src/api/websocket.ts
程序员阿江(Relakkes) 5377eb596f Keep desktop session history and reconnect recovery trustworthy
Desktop session interruptions were leaving two different classes of stale UI
artifacts behind: reconnects could silently lose in-flight output, and history
reloads could replay synthetic interruption or internal command breadcrumbs as
if they were user-facing transcript content. This change rebinds active session
output to the latest client websocket on reconnect, preserves queued outbound
messages across transient disconnects, and filters synthetic transcript entries
before they reach the desktop history API so reloads reconstruct only the
messages users should actually see.

Constraint: Desktop transcript history is shared with CLI JSONL files and must stay compatible with persisted message shapes
Rejected: Hide these artifacts only in React renderers | other transcript consumers would still receive polluted history
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep transcript filtering aligned with real synthetic/internal message shapes before adding new hidden message types
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts
Tested: cd /Users/nanmi/.codex/worktrees/e7ac/claude-code-haha/desktop && bun run test -- websocket.test.ts
Tested: cd /Users/nanmi/.codex/worktrees/e7ac/claude-code-haha/desktop && bun run lint
Not-tested: Manual browser verification of the refreshed session transcript after interrupt on this exact session
2026-04-21 17:55:15 +08:00

182 lines
4.7 KiB
TypeScript

import type { ClientMessage, ServerMessage } from '../types/chat'
import { 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 wsUrl = getBaseUrl().replace(/^http/, 'ws')
const ws = new WebSocket(`${wsUrl}/ws/${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 {
// Ignore malformed messages
}
}
ws.onclose = () => {
this.stopPingLoop(sessionId)
if (!conn.intentionalClose && this.connections.get(sessionId) === conn) {
this.scheduleReconnect(sessionId, conn)
}
}
ws.onerror = () => {
// onclose will fire after 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 const wsManager = new WebSocketManager()