realtime.ts 7.8 KB

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