realtime.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventFrame } from '@shared/types'
  2. import { ApiError } from './client'
  3. import { number, record, string } from '@/utils/normalize'
  4. import { HTTPRealtimeChannel, HTTPRPCError } from './httpRealtime'
  5. export class RpcError extends Error {
  6. constructor(message: string, readonly code?: number | string, readonly data?: JsonValue) {
  7. super(message); this.name = 'RpcError'
  8. }
  9. }
  10. type StateListener = (state: RealtimeConnectionState, reason?: string) => void
  11. type RpcEventListener = (event: RpcEventFrame['params']) => void
  12. /** Historical facade name retained for store injection; transport is HTTP+SSE only. */
  13. export class ChatRpcSocket {
  14. private http?: HTTPRealtimeChannel
  15. private generation = 0
  16. private openingRequests = 0
  17. private earlyFrames: string[] = []
  18. private eventListeners = new Set<RpcEventListener>()
  19. private stateListeners = new Set<StateListener>()
  20. state: RealtimeConnectionState = 'idle'
  21. onEvent(listener: RpcEventListener): () => void { this.eventListeners.add(listener); return () => this.eventListeners.delete(listener) }
  22. onState(listener: StateListener): () => void { this.stateListeners.add(listener); return () => this.stateListeners.delete(listener) }
  23. async connect(): Promise<void> {
  24. this.close()
  25. const generation = ++this.generation
  26. this.publishState('connecting')
  27. await HTTPRealtimeChannel.requireSupport()
  28. if (generation !== this.generation) return
  29. this.http = new HTTPRealtimeChannel(frame => this.handleMessage(frame, generation), error => {
  30. if (generation === this.generation) this.publishState('failed', error.message)
  31. })
  32. await this.http.open('chat')
  33. }
  34. close(): void {
  35. this.generation++; this.http?.close(); this.http = undefined
  36. this.earlyFrames = []; this.openingRequests = 0
  37. this.publishState('disconnected')
  38. }
  39. async request(method: string, params: Record<string, JsonValue> = {}, timeoutMs = 120_000, requestId?: string): Promise<JsonValue> {
  40. if (!this.http) throw new ApiError('HTTP 事件通道尚未就绪', 0, 'REALTIME_NOT_READY')
  41. const generation = this.generation
  42. const opening = ['session.create', 'session.resume', 'session.branch'].includes(method)
  43. if (opening) this.openingRequests++
  44. try { return await this.http.request(method, params, timeoutMs, requestId) }
  45. catch (error) { if (error instanceof HTTPRPCError) throw new RpcError(error.message, error.code, error.data); throw error }
  46. finally {
  47. if (opening && generation === this.generation) {
  48. this.openingRequests--
  49. window.setTimeout(() => {
  50. if (generation !== this.generation || this.openingRequests) return
  51. const frames = this.earlyFrames; this.earlyFrames = []
  52. for (const frame of frames) void this.handleMessage(frame, generation)
  53. }, 0)
  54. }
  55. }
  56. }
  57. private async handleMessage(text: string, generation: number): Promise<void> {
  58. if (generation !== this.generation) return
  59. const frame = record(JSON.parse(text))
  60. if (frame.method !== 'event') return
  61. const params = record(frame.params), type = string(params.type)
  62. if (!type) return
  63. if (this.openingRequests && type !== 'gateway.ready') {
  64. if (this.earlyFrames.length >= 512) { this.earlyFrames = []; this.publishState('failed', '事件缓存已满,需要重新同步') }
  65. else this.earlyFrames.push(text)
  66. return
  67. }
  68. if (type === 'gateway.ready') this.publishState('ready')
  69. const event: RpcEventFrame['params'] = {
  70. type, session_id: string(params.session_id) || undefined,
  71. profile: string(params.profile) || undefined, payload: (params.payload ?? null) as JsonValue,
  72. }
  73. for (const listener of this.eventListeners) listener(event)
  74. }
  75. private publishState(state: RealtimeConnectionState, reason?: string): void {
  76. this.state = state
  77. for (const listener of this.stateListeners) listener(state, reason)
  78. }
  79. }
  80. export interface GroupEventSocketOptions {
  81. epoch: string
  82. cursor: number
  83. onEnvelope(envelope: GroupSocketEnvelope): void
  84. onState?(state: RealtimeConnectionState, reason?: string): void
  85. onReset(reason: string): void
  86. }
  87. export class GroupEventSocket {
  88. private http?: HTTPRealtimeChannel
  89. private generation = 0
  90. private epoch = ''
  91. private cursor = 0
  92. state: RealtimeConnectionState = 'idle'
  93. async connect(options: GroupEventSocketOptions): Promise<void> {
  94. this.close()
  95. this.epoch = options.epoch; this.cursor = options.cursor
  96. const generation = ++this.generation
  97. this.publish(options, 'connecting')
  98. await HTTPRealtimeChannel.requireSupport()
  99. if (generation !== this.generation) return
  100. this.http = new HTTPRealtimeChannel(frame => this.handleMessage(frame, generation, options), error => {
  101. if (generation === this.generation) this.needsReset(options, error.message)
  102. })
  103. await this.http.open('groups', { epoch: this.epoch, cursor: this.cursor })
  104. }
  105. close(): void {
  106. this.generation++; this.http?.close(); this.http = undefined
  107. this.state = 'disconnected'
  108. }
  109. private async handleMessage(data: unknown, generation: number, options: GroupEventSocketOptions): Promise<void> {
  110. if (generation !== this.generation) return
  111. let text: string
  112. if (typeof data === 'string') text = data
  113. else if (data instanceof Blob) text = await data.text()
  114. else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data)
  115. else return
  116. let decoded: unknown
  117. try { decoded = JSON.parse(text) } catch {
  118. this.needsReset(options, 'malformed_frame')
  119. return
  120. }
  121. const source = record(decoded)
  122. const envelope: GroupSocketEnvelope = {
  123. type: string(source.type) as GroupSocketEnvelope['type'],
  124. epoch: string(source.epoch) || undefined,
  125. cursor: source.cursor == null ? undefined : number(source.cursor, -1),
  126. heartbeatSeconds: source.heartbeatSeconds == null && source.heartbeat_seconds == null ? undefined : number(source.heartbeatSeconds ?? source.heartbeat_seconds),
  127. roomId: string(source.roomId ?? source.room_id) || undefined,
  128. event: string(source.event) || undefined,
  129. payload: (source.payload ?? null) as JsonValue,
  130. reason: string(source.reason) || undefined,
  131. }
  132. if (!['group.ready', 'group.event', 'group.heartbeat', 'group.reset_required'].includes(envelope.type)) {
  133. this.needsReset(options, 'unknown_envelope')
  134. return
  135. }
  136. if (envelope.epoch && envelope.epoch !== this.epoch) {
  137. this.needsReset(options, 'epoch_mismatch')
  138. return
  139. }
  140. if (envelope.type === 'group.ready') {
  141. if (envelope.cursor !== this.cursor) return this.needsReset(options, 'ready_cursor_mismatch')
  142. this.publish(options, 'ready')
  143. options.onEnvelope(envelope)
  144. return
  145. }
  146. if (envelope.type === 'group.event') {
  147. if (envelope.cursor == null || envelope.cursor < 0) return this.needsReset(options, 'invalid_cursor')
  148. if (envelope.cursor <= this.cursor) return
  149. if (envelope.cursor !== this.cursor + 1) return this.needsReset(options, 'cursor_gap')
  150. this.cursor = envelope.cursor
  151. options.onEnvelope(envelope)
  152. return
  153. }
  154. if (envelope.type === 'group.reset_required') {
  155. this.needsReset(options, envelope.reason || 'server_reset')
  156. return
  157. }
  158. if (envelope.cursor != null && envelope.cursor !== this.cursor) this.needsReset(options, 'heartbeat_cursor_mismatch')
  159. }
  160. private needsReset(options: GroupEventSocketOptions, reason: string): void {
  161. this.http?.close(); this.http = undefined
  162. this.publish(options, 'needs-reset', reason)
  163. options.onReset(reason)
  164. }
  165. private publish(options: GroupEventSocketOptions, state: RealtimeConnectionState, reason?: string): void {
  166. this.state = state
  167. options.onState?.(state, reason)
  168. }
  169. }