/** * KenEvent transport: the Socket.IO wire contract of the realtime gateway * (`KenEvent/src/socket`, Engine.IO v4, path `/socket.io`). * * 与旧 im 网关(`GET /ws/v1`,裸 WebSocket + `gitdata.jwt.*` 子协议)的区别: * - **连接**:Socket.IO,默认路径 `/socket.io`;浏览器握手带不了自定义头, * 所以 token 由会话 cookie(`gitdata_session`)或 CONNECT 包的 `auth.token` * 提供,KenEvent 侧见 `socket/response.rs` 与 `socket/session/state.rs`。 * - **订阅**:客户端发 `subscribe {topic, tag, filter, id}`,服务端按 * RocketMQ 的 topic/tag 精确匹配(`events/subscriptions.rs:validate`), * 没有通配,所以一个房间要按关心的 tag 各订阅一条。 * - **推送**:服务端只发两种事件名——`event` 与 `resync`,载荷是 * `{topic, tag, user_id, recipient_user_id, properties, body}` 信封 * (`events/action/common.rs:payload`)。**详情一律走 REST 回拉**。 * - **个人事件**:`gitdata-notify`/`notification-create` 与 chat 的 * mention/read_state/presence 等由服务端强制绑定 `filter.user_id` 自己。 */ import { io, type Socket } from 'socket.io-client' export const KEN_EVENT_PATH = '/socket.io' export const CHAT_TOPIC = 'gitdata-chat' export const NOTIFY_TOPIC = 'gitdata-notify' /** 房间级事件:一条订阅一个 tag,`filter` 固定为 `{room_id}`。 */ export const ROOM_TAGS = [ 'message-create', 'message-update', 'message-delete', 'messages-delete', 'pin-create', 'pin-delete', 'reaction-update', 'room-update', 'member-create', 'member-delete', 'thread-update', 'voice-presence', 'voice-hand', 'voice-layout', 'voice-moderate', ] as const /** 用户级事件:不带 room 过滤,服务端会补上 `filter.user_id = 自己`。 */ export const PERSONAL_CHAT_TAGS = [ 'mention-create', 'reminder-create', 'membership-create', 'read_state-update', 'presence-update', ] as const export const PERSONAL_NOTIFY_TAGS = ['notification-create'] as const /** 服务端推送的信封(`events/action/common.rs:80`)。 */ export interface KenEventEnvelope { topic: string tag: string user_id?: number | null recipient_user_id?: number properties?: Record | null body?: unknown /** 断流补齐时 KenEvent 会把事件名换成 `resync` 并置这个标记。 */ resync_required?: boolean } export interface SubscriptionAck { ok?: boolean data?: unknown error?: string } export interface KenEventSocketOptions { /** 默认同源(`window.location.origin`);vite dev 与网关都由 `/socket.io` 反代。 */ url?: string onOpen: (info: { sid: string }) => void onClose: (reason: string, wasOpen: boolean) => void onAuthRequired: (message: string) => void onConnectError: (message: string) => void onEnvelope: (envelope: KenEventEnvelope) => void onResync: (envelope: KenEventEnvelope) => void onServerError: (message: string) => void onSubscribeError: (subscription: string, message: string) => void } const DEVICE_KEY = 'gitdata.ws.device_id' /** 设备标识:KenEvent 用它做多端会话隔离(`session/state.rs`)。 */ export function deviceId(): string { try { const stored = window.localStorage.getItem(DEVICE_KEY) if (stored) return stored const created = crypto.randomUUID() window.localStorage.setItem(DEVICE_KEY, created) return created } catch { // 隐私模式 / SSR:退化成会话级 id,重连后仍能复用(同一模块生命周期内)。 return crypto.randomUUID() } } function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null } export function asEnvelope(value: unknown): KenEventEnvelope | null { const record = asRecord(value) if (!record) return null const topic = record.topic const tag = record.tag if (typeof topic !== 'string' || typeof tag !== 'string') return null return record as unknown as KenEventEnvelope } /** `properties` 里的字符串字段(RocketMQ 用户属性全是字符串)。 */ export function prop(envelope: KenEventEnvelope, key: string): string | null { const value = envelope.properties?.[key] if (typeof value === 'string' && value.length > 0) return value const fromBody = asRecord(envelope.body)?.[key] return typeof fromBody === 'string' && fromBody.length > 0 ? fromBody : null } /** * 订阅管理器:把「打开了哪些房间」这份意图翻译成 KenEvent 的逐 tag 订阅。 * * 重连后服务端的订阅是空的(per-socket 状态),所以每次 `connect` 都要重放; * 房间的 refcount 由上层 hub 维护,这里只负责 idempotent 增删。 */ export class KenEventSocket { private socket: Socket | null = null /** roomId -> tag -> 订阅 id(KenEvent 要求 id 是 UUID,由客户端生成)。 */ private readonly rooms = new Map>() /** `${topic}|${tag}` -> 个人订阅 id。 */ private readonly personalIds = new Map() /** 通用资源订阅:key -> 订阅意图(issue / pull / org / kenshell 等)。 */ private readonly topics = new Map< string, { topic: string; tag: string; filter: Record; refs: number; id: string } >() private personal = false private disposed = false private readonly options: KenEventSocketOptions constructor(options: KenEventSocketOptions) { this.options = options } get connected(): boolean { return this.socket?.connected ?? false } /** 幂等:连接中/已连接时什么也不做。 */ start(): void { if (this.disposed || this.socket) return const socket = io(this.options.url, { path: KEN_EVENT_PATH, // 先 polling 再升级 websocket:网关(KenGateWay)不代理 Upgrade,会回 501, // 从 websocket 起步会直接连不上;polling 起步在两种部署下都能连上, // 拿到连接后 socket.io 会自己尝试升级到 WebSocket(nginx 环境即可升级成功)。 transports: ['polling', 'websocket'], // 兜底:某个传输被拒时继续尝试下一个,而不是直接失败。 tryAllTransports: true, withCredentials: true, auth: (cb) => cb({ device_id: deviceId() }), reconnection: true, reconnectionDelay: 500, reconnectionDelayMax: 30_000, timeout: 20_000, }) this.socket = socket socket.on('connect', () => { this.options.onOpen({ sid: socket.id ?? '' }) this.replay() }) socket.on('disconnect', (reason) => { this.options.onClose(reason, true) }) socket.on('connect_error', (error) => { const message = error instanceof Error ? error.message : String(error) // KenEvent 在鉴权失败时发 `connect_error('authentication failed')` // (`socket/session/state.rs:connect`)。 if (/auth|unauthor|missing access token/i.test(message)) { this.options.onAuthRequired(message) return } this.options.onConnectError(message) }) socket.on('event', (payload: unknown) => { const envelope = asEnvelope(payload) if (!envelope) { this.options.onServerError('unrecognized event envelope') return } if (envelope.resync_required) { this.options.onResync(envelope) return } this.options.onEnvelope(envelope) }) socket.on('resync', (payload: unknown) => { const envelope = asEnvelope(payload) if (envelope) this.options.onResync(envelope) }) socket.on('error', (payload: unknown) => { const message = (asRecord(payload)?.message as string | undefined) ?? 'realtime gateway error' this.options.onServerError(message) }) } stop(): void { const socket = this.socket this.socket = null if (!socket) return socket.removeAllListeners() socket.disconnect() } dispose(): void { this.disposed = true this.rooms.clear() this.personal = false this.stop() } /** 加入一个房间:按 `ROOM_TAGS` 逐条订阅。 */ subscribeRoom(roomId: string): void { if (this.rooms.has(roomId)) return this.rooms.set(roomId, new Map()) this.start() if (this.connected) this.emitRoom(roomId) } unsubscribeRoom(roomId: string): void { const ids = this.rooms.get(roomId) if (!ids) return this.rooms.delete(roomId) if (!this.connected) return for (const [tag, id] of ids) this.emitUnsubscribe(CHAT_TOPIC, tag, id) } /** * 订阅任意 topic/tag(带资源过滤)。同一 `key` 只发一次订阅,引用计数归零时退订; * 返回的函数可安全重复调用。 */ subscribeTopic(spec: { key: string; topic: string; tag: string; filter?: Record }): () => void { const existing = this.topics.get(spec.key) if (existing) { existing.refs += 1 return () => this.releaseTopic(spec.key) } const entry = { topic: spec.topic, tag: spec.tag, filter: spec.filter ?? {}, refs: 1, id: crypto.randomUUID(), } this.topics.set(spec.key, entry) this.start() if (this.connected) this.emit(entry.topic, entry.tag, { id: entry.id, filter: entry.filter }) return () => this.releaseTopic(spec.key) } private releaseTopic(key: string): void { const entry = this.topics.get(key) if (!entry) return entry.refs -= 1 if (entry.refs > 0) return this.topics.delete(key) if (this.connected) this.emitUnsubscribe(entry.topic, entry.tag, entry.id) } /** 个人通道(通知、提及、已读游标):一次连接订阅一次。 */ subscribePersonal(): void { if (this.personal) return this.personal = true this.start() if (this.connected) this.emitPersonal() } /** 重放全部订阅意图;每次 `connect` 都会调用。 */ private replay(): void { this.personalIds.clear() for (const ids of this.rooms.values()) ids.clear() if (this.personal) this.emitPersonal() for (const roomId of this.rooms.keys()) this.emitRoom(roomId) for (const entry of this.topics.values()) { entry.id = crypto.randomUUID() this.emit(entry.topic, entry.tag, { id: entry.id, filter: entry.filter }) } } private emitRoom(roomId: string): void { const ids = this.rooms.get(roomId) if (!ids) return for (const tag of ROOM_TAGS) { if (ids.has(tag)) continue const id = crypto.randomUUID() ids.set(tag, id) this.emit(CHAT_TOPIC, tag, { id, filter: { room_id: roomId } }) } } private emitPersonal(): void { for (const tag of PERSONAL_CHAT_TAGS) this.emitPersonalOne(CHAT_TOPIC, tag) for (const tag of PERSONAL_NOTIFY_TAGS) this.emitPersonalOne(NOTIFY_TOPIC, tag) } private emitPersonalOne(topic: string, tag: string): void { const key = `${topic}|${tag}` if (this.personalIds.has(key)) return const id = crypto.randomUUID() this.personalIds.set(key, id) this.emit(topic, tag, { id }) } private emit(topic: string, tag: string, extra: { id: string; filter?: unknown }): void { const socket = this.socket if (!socket) return const payload = { topic, tag, ...extra } // ack 里的 `{ok:false,error}` 是唯一能归因到具体订阅的失败信号。 socket.timeout(10_000).emit('subscribe', payload, (error: Error | null, ack?: SubscriptionAck) => { if (error) { this.options.onSubscribeError(`${topic}/${tag}`, 'subscribe ack timeout') return } if (ack && ack.ok === false) { this.options.onSubscribeError(`${topic}/${tag}`, ack.error ?? 'subscribe rejected') } }) } private emitUnsubscribe(topic: string, tag: string, id: string): void { this.socket?.emit('unsubscribe', { topic, tag, id }) } }