/** * Browser client for the realtime gateway — **KenEvent over Socket.IO**. * * 历史:这一层以前实现的是 `GET /ws/v1`(`gitdataai/app/im`,裸 WebSocket + * `gitdata.jwt.*` 子协议 + subscribe/history/event 帧)。im 网关已从 KenApi 删除, * 实时通道整体换成 KenEvent(RocketMQ → Socket.IO),本文件是迁移后的唯一入口。 * * 保留的对外契约(`hub.ts` / `react.ts` / 页面都用它,迁移时零改动): * - `onStatus`:`WsStatus` 生命周期; * - `onFrame`:`ServerFrame`(`event` / `user_event` / `resync_required`); * - `onIssue`:协议层无法自愈的情况(订阅被拒、鉴权失败、连接失败); * - `subscribe` / `unsubscribe`:房间意图,refcount 由 `ChatRealtimeHub` 维护。 * * 与旧实现的三处语义差别,都来自 KenEvent 的契约: * 1. **没有 history 快照**:订阅不会回放消息,页面一律用 REST 回拉( * `use-room-stream` 本来就有 HTTP re-seed 路径)。 * 2. **没有 typing 广播**:`docs/events.json` 里 chat 没有 typing tag, * `sendTyping` 保留为本地 no-op,接口不变以免页面改动。 * 3. **重连由 Socket.IO 自带**:指数退避 + 连接恢复都在库内,本层只在 * `connect` 时重放订阅(服务端订阅是 per-socket 的)。 */ import { unwrapApi } from '@app/lib' import type { MessageDto } from '@app/services/api' import Apis from '@app/services/api' import { type KenEventEnvelope, KenEventSocket, prop } from './kenEvent' import { frameSeq, toServerFrame } from './kenEventFrames' import { classifyWsError, type RoomEvent, type ServerFrame, type UserEvent, WS_SERVER_DEFAULTS, type WsServerErrorKind, type WsStatus, type WsTypingIndicator, } from './protocol' import { TypingRegistry } from './typing' export interface ChatWsClientOptions { /** 覆盖 realtime 入口地址;默认同源(vite dev 与网关都反代 `/socket.io`)。 */ url?: string /** 保留字段:KenEvent 的鉴权走会话 cookie,不再需要单独换 JWT。 */ fetchJwt?: () => Promise /** 保留字段:心跳由 Socket.IO 自己完成(30s ping/pong)。 */ pingIntervalMs?: number minReconnectDelayMs?: number maxReconnectDelayMs?: number maxConnectFailures?: number pongTimeoutMs?: number /** 保留字段:KenEvent 的订阅上限由服务端强制。 */ maxSubscriptions?: number historyLimitHint?: number maxResyncPages?: number typingThrottleMs?: number correlateSubscribeErrors?: boolean } /** * 协议层无法自愈的情况。页面只对其中少数几种做反应(forbidden → 收回权限, * resync / cursor_lost → HTTP 重新播种),其余用于观测。 */ export type WsClientIssue = | { kind: 'server_error'; error: WsServerErrorKind; message: string; room_id?: string } | { kind: 'subscription_limit'; room_id: string; limit: number } | { kind: 'gap'; room_id: string; from: number; to: number } | { kind: 'resync_required'; room_id: string; reason: string; dropped: boolean } | { kind: 'cursor_lost'; room_id: string; reason: string } | { kind: 'history_truncated'; room_id: string; pages: number; page_size: number } | { kind: 'server_bye'; message: string } | { kind: 'socket_closed'; code: number; ready: boolean } | { kind: 'pong_timeout'; outstanding_pings: number } | { kind: 'unrecognized_frame'; snippet: string } | { kind: 'binary_frame' } | { kind: 'unparseable_payload'; channel: 'room' | 'user'; room_id?: string } | { kind: 'connect_failed'; phase: 'jwt' | 'handshake'; message: string; failures: number } | { kind: 'auth_required'; message: string } | { kind: 'gave_up'; failures: number } export class ChatWsClient { private transport: KenEventSocket | null = null private status: WsStatus = 'idle' private selfIdValue: string | undefined private readonly rooms = new Set() private readonly sequences = new Map() private readonly typing = new TypingRegistry() private readonly statusListeners = new Set<(status: WsStatus) => void>() private readonly frameListeners = new Set<(frame: ServerFrame) => void>() private readonly issueListeners = new Set<(issue: WsClientIssue) => void>() private readonly typingListeners = new Set<(roomId: string) => void>() private readonly seenNotifications = new Set() private failures = 0 private typingNoticeShown = false private readonly options: ChatWsClientOptions constructor(options: ChatWsClientOptions = {}) { this.options = options this.typing.onTyping((roomId) => { for (const listener of [...this.typingListeners]) listener(roomId) }) } /** `ready.user_id`:KenEvent 不在帧里回传,保留接口供页面消费(Undefined 即未知)。 */ get userId(): string | undefined { return this.selfIdValue } get connectionStatus(): WsStatus { return this.status } onStatus(listener: (status: WsStatus) => void): () => void { this.statusListeners.add(listener) return () => this.statusListeners.delete(listener) } onFrame(listener: (frame: ServerFrame) => void): () => void { this.frameListeners.add(listener) return () => this.frameListeners.delete(listener) } onIssue(listener: (issue: WsClientIssue) => void): () => void { this.issueListeners.add(listener) return () => this.issueListeners.delete(listener) } onTypingChange(listener: (roomId: string) => void): () => void { this.typingListeners.add(listener) return () => this.typingListeners.delete(listener) } /** 幂等:已连接或连接中时直接返回。 */ start(): void { if (this.transport && this.status !== 'closed' && this.status !== 'failed') return this.failures = 0 this.setStatus('connecting') const transport = new KenEventSocket({ url: this.options.url, onOpen: ({ sid }) => { this.failures = 0 void sid this.setStatus('open') // 个人通道(通知/提及/已读)每个连接订阅一次。 transport.subscribePersonal() for (const roomId of this.rooms) transport.subscribeRoom(roomId) }, onClose: (_reason, wasOpen) => { if (this.status === 'auth_required' || this.status === 'failed') return // Socket.IO 会自己重连;这里只反映状态,不主动重建。 this.setStatus(wasOpen ? 'reconnecting' : 'closed') this.emitIssue({ kind: 'socket_closed', code: 1006, ready: wasOpen }) }, onAuthRequired: (message) => { // 终态:等页面在登录后显式 `start()`(hub 的 watchRoom 会这么做)。 this.setStatus('auth_required') this.emitIssue({ kind: 'auth_required', message }) transport.stop() }, onConnectError: (message) => { this.failures += 1 this.emitIssue({ kind: 'connect_failed', phase: 'handshake', message, failures: this.failures }) this.setStatus('reconnecting') }, onEnvelope: (envelope) => this.handleEnvelope(envelope), onResync: (envelope) => { const roomId = prop(envelope, 'room_id') if (!roomId) return this.emitFrame({ type: 'resync_required', room_id: roomId, reason: 'lagged' }) this.emitIssue({ kind: 'resync_required', room_id: roomId, reason: 'lagged', dropped: true }) }, onServerError: (message) => { const error = classifyServerError(message) this.emitIssue({ kind: 'server_error', error, message }) }, onSubscribeError: (subscription, message) => { const roomId = subscription.split(':')[0] const error = classifyServerError(message) this.emitIssue({ kind: 'server_error', error, message, room_id: roomId }) }, }) this.transport = transport transport.start() } stop(): void { this.transport?.stop() this.transport = null this.setStatus('closed') } dispose(): void { this.transport?.dispose() this.transport = null this.rooms.clear() this.sequences.clear() this.typing.clear() this.statusListeners.clear() this.frameListeners.clear() this.issueListeners.clear() this.typingListeners.clear() this.setStatus('idle') } subscribe(roomId: string): void { if (this.rooms.has(roomId)) return this.rooms.add(roomId) this.start() this.transport?.subscribeRoom(roomId) } unsubscribe(roomId: string): void { this.rooms.delete(roomId) this.transport?.unsubscribeRoom(roomId) this.typing.clear(roomId) } /** * typing 广播在 KenEvent 里没有对应 tag(`docs/events.json` 的 `gitdata-chat` * 不含 typing),所以这里保持 no-op:接口不变,页面调用无害,等服务端补上 * 事件后只需在这里 `emit('typing', …)`。 */ sendTyping(roomId: string, threadId?: string | null): void { void roomId void threadId if (!this.typingNoticeShown && import.meta.env.DEV) { this.typingNoticeShown = true console.info('[ws] typing 事件在 KenEvent 侧尚未定义,已忽略 sendTyping()') } } typingUsers(roomId: string, options: { threadId?: string | null } = {}): WsTypingIndicator[] { return this.typing.active(roomId, { excludeUserId: this.selfIdValue, threadId: options.threadId ?? null, }) } lastKnownSeq(roomId: string): number | undefined { return this.sequences.get(roomId) } private handleEnvelope(envelope: KenEventEnvelope): void { void this.pump(envelope) } /** * 信封 → 帧。KenApi 的事件体只是摘要(`summary_body` = id/kind/occurred_at/ * summary),消息正文必须回拉 `GET /chat/rooms/{room}/messages/{id}` 才能 * 交给 transcript reducer,这也是 KenEvent 文档里"详情走 REST"的含义。 */ private async pump(envelope: KenEventEnvelope): Promise { const hydrated = await this.hydrate(envelope) let frame = toServerFrame(hydrated) if (!frame) { // 已知的 chat 事件映射不出来(比如 reaction-update 只带摘要)时, // 退化成一次房间级软刷新,页面自己回拉最新状态。 const roomId = prop(envelope, 'room_id') if (roomId && envelope.topic === 'gitdata-chat') { frame = { type: 'event', room_id: roomId, event: { kind: 'room_updated', room_id: roomId } } } else { // 未消费的事件(issue/pull/kenshell/oauth…)静默丢弃:它们有各自的页面。 return } } const seq = frameSeq(frame) if (seq) { const current = this.sequences.get(seq.roomId) ?? 0 if (seq.seq > current) this.sequences.set(seq.roomId, seq.seq) } this.emitFrame(frame) } /** 消息类事件回拉完整 DTO;失败时原样返回信封,由上层降级处理。 */ private async hydrate(envelope: KenEventEnvelope): Promise { const hydrated = await this.hydrateNotification(envelope) if (hydrated) return hydrated return this.hydrateMessage(envelope) } /** * 通知事件同样只带摘要,而 `WsNotification` 要渲染标题/正文,所以回拉最新一条 * (`GET /me/notifications?limit=1`)。同一条只处理一次,避免重连补发时重复弹窗。 */ private async hydrateNotification(envelope: KenEventEnvelope): Promise { if (envelope.topic !== 'gitdata-notify' || envelope.tag !== 'notification-create') return null if (isNotificationDto(envelope.body)) return envelope try { const page = await unwrapApi<{ items?: unknown[] }>( Apis.notifications.list_notifications({ params: { limit: 1, offset: 0 } }), ) const latest = page?.items?.[0] if (!isNotificationDto(latest)) return null if (this.seenNotifications.has(latest.id)) return null this.seenNotifications.add(latest.id) return { ...envelope, body: latest } } catch { return null } } private async hydrateMessage(envelope: KenEventEnvelope): Promise { const needsMessage = envelope.tag === 'message-create' || envelope.tag === 'message-update' if (!needsMessage) return envelope if (isMessageDto(envelope.body)) return envelope const roomId = prop(envelope, 'room_id') const messageId = prop(envelope, 'message_id') if (!roomId || !messageId) return envelope try { const message = await unwrapApi( Apis.chat.get_message({ pathParams: { room_id: roomId, message_id: messageId } }), ) return { ...envelope, body: message } } catch { return envelope } } private emitFrame(frame: ServerFrame): void { for (const listener of [...this.frameListeners]) listener(frame) } private emitIssue(issue: WsClientIssue): void { for (const listener of [...this.issueListeners]) listener(issue) } private setStatus(status: WsStatus): void { if (this.status === status) return this.status = status for (const listener of [...this.statusListeners]) listener(status) } } /** 可渲染的通知 DTO(摘要体只有 `{id, kind, occurred_at, summary}`)。 */ function isNotificationDto(value: unknown): value is { id: string; notification_type: string } { if (typeof value !== 'object' || value === null) return false const body = value as Record return typeof body.id === 'string' && typeof body.notification_type === 'string' } /** 真正可渲染的消息 DTO(摘要体只有 `{id, kind, occurred_at, summary}`)。 */ function isMessageDto(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false const body = value as Record return typeof body.id === 'string' && typeof body.room_id === 'string' && typeof body.author_id === 'number' } function classifyServerError(message: string): WsServerErrorKind { // 先按旧协议的四条固定文案匹配,再按 KenEvent 的措辞兜底 // (`events/subscriptions.rs:validate` 会回 "unsupported subscription topic or tag")。 const known = classifyWsError(message) if (known !== 'unknown') return known const text = message.toLowerCase() if (text.includes('limit')) return 'subscription_limit' if (text.includes('forbidden') || text.includes('permission') || text.includes('not allowed')) { return 'forbidden' } if (text.includes('subscri')) return 'subscribe_failed' if (text.includes('invalid') || text.includes('unsupported') || text.includes('unrecognized')) { return 'invalid_frame' } return 'unknown' } /** Re-exported for consumers that still type against `RoomEvent`/`UserEvent`. */ export type { RoomEvent, ServerFrame, UserEvent } export { WS_SERVER_DEFAULTS }