claude-code-best/src/server/eventBus.ts
DoSun ca147a40b7 fix: 修复 serve 模式 SSE 事件流、权限响应、协议对齐
- 修复 emitEvent 无限递归(调用自身→调用 eventBus.publishSessionEvent)
- 修复 prompt() 用户消息缺失 uuid/content 字段
- session.ready 事件映射为 session.updated(非重复 session.created)
- 新增用户消息事件(message.updated role:user + message.part.updated)
- 新增 session.status busy/idle、step-start/step-finish、session.idle/diff
- 清理 heartbeat properties 为空对象
- 仅 assistant 类型消息作为 session.message 转发,过滤 system/其他杂消息
- 权限响应映射 once/always/reject → allow/deny
- 会话创建支持 permission ruleset 推导 permission mode
- prompt 路由支持 model 字段切换模型
- 初始化消息支持 hooks 配置
- 新增 HookCallback/ControlCancelRequest 处理
- 权限响应支持 updatedPermissions 和 interrupt
- 新增 --resume-session-at、--permission-prompt-tool stdio 参数
- 追踪 lastMessageUuid 用于消息级回退
- EventBus dead client 清理
2026-05-08 16:59:45 +08:00

114 lines
2.7 KiB
TypeScript

export type SSEWriter = {
writeSSE: (opts: { event: string; data: string }) => Promise<void>
}
type SSEClient = {
id: string
writer: SSEWriter | null
rawWrite: ((event: string, data: unknown) => void) | null
sessionIdFilter?: string
}
type BusEvent = {
event: string
data: unknown
}
export class EventBus {
private clients = new Map<string, SSEClient>()
private buffer: BusEvent[] = []
private bufferSize = 100
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
addClient(writer: SSEWriter, sessionIdFilter?: string): string {
const id = crypto.randomUUID()
const client: SSEClient = { id, writer, rawWrite: null, sessionIdFilter }
this.clients.set(id, client)
const sendAndCleanup = (opts: { event: string; data: string }) =>
writer.writeSSE(opts).catch(() => {
this.clients.delete(id)
})
void sendAndCleanup({
event: 'connected',
data: JSON.stringify({ type: 'server.connected' }),
})
for (const buffered of this.buffer) {
void sendAndCleanup({
event: buffered.event,
data: JSON.stringify(buffered.data),
})
}
return id
}
removeClient(id: string): void {
this.clients.delete(id)
}
publish(event: string, data: unknown): void {
const entry: BusEvent = { event, data }
this.buffer.push(entry)
if (this.buffer.length > this.bufferSize) {
this.buffer.shift()
}
const payload = JSON.stringify(data)
const deadIds: string[] = []
for (const client of this.clients.values()) {
if (client.writer) {
if (client.sessionIdFilter) {
const dataObj = data as Record<string, unknown> | undefined
if (dataObj?.session_id && dataObj.session_id !== client.sessionIdFilter) {
continue
}
}
client.writer.writeSSE({ event, data: payload }).catch(() => {
deadIds.push(client.id)
})
}
}
if (deadIds.length > 0) {
for (const id of deadIds) {
this.clients.delete(id)
}
}
}
publishSessionEvent(
sessionId: string,
event: string,
data: Record<string, unknown>,
): void {
this.publish(`session.${event}`, { session_id: sessionId, ...data })
}
startHeartbeat(intervalMs = 10000): void {
this.heartbeatInterval = setInterval(() => {
this.publish('heartbeat', {
type: 'server.heartbeat',
ts: Date.now(),
})
}, intervalMs)
}
stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
clientCount(): number {
return this.clients.size
}
destroy(): void {
this.stopHeartbeat()
this.clients.clear()
this.buffer.length = 0
}
}