realtime.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventFrame, RpcRequestFrame } from '@shared/types'
  2. import { apiRequest, ApiError, unwrapData } from './client'
  3. import { createId } from '@/utils/id'
  4. import { number, record, string } from '@/utils/normalize'
  5. export class RpcError extends Error {
  6. readonly code?: number | string
  7. readonly data?: JsonValue
  8. constructor(message: string, code?: number | string, data?: JsonValue) {
  9. super(message)
  10. this.name = 'RpcError'
  11. this.code = code
  12. this.data = data
  13. }
  14. }
  15. interface LeaseResponse { lease?: string; token?: string; id?: string; expiresAt?: string }
  16. export async function createRealtimeLease(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<string> {
  17. const payload = unwrapData(await apiRequest<LeaseResponse>('/api/app/realtime-leases', {
  18. method: 'POST', body: { channel, ...(anchor ?? {}) }, timeoutMs: 15_000,
  19. }))
  20. const lease = payload.lease ?? payload.token ?? payload.id
  21. if (!lease?.trim()) throw new ApiError('实时连接租约无效', 0, 'INVALID_LEASE')
  22. return lease.trim()
  23. }
  24. function webSocketURL(path: string, query: Record<string, string | number | undefined>): string {
  25. const url = new URL(path, window.location.origin)
  26. url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
  27. for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value))
  28. return url.toString()
  29. }
  30. type StateListener = (state: RealtimeConnectionState, reason?: string) => void
  31. type RpcEventListener = (event: RpcEventFrame['params']) => void
  32. interface PendingRequest {
  33. resolve(value: JsonValue): void
  34. reject(reason: unknown): void
  35. timer: number
  36. method: string
  37. }
  38. export class ChatRpcSocket {
  39. private socket?: WebSocket
  40. private generation = 0
  41. private pending = new Map<string, PendingRequest>()
  42. private eventListeners = new Set<RpcEventListener>()
  43. private stateListeners = new Set<StateListener>()
  44. private manuallyClosed = false
  45. state: RealtimeConnectionState = 'idle'
  46. onEvent(listener: RpcEventListener): () => void {
  47. this.eventListeners.add(listener)
  48. return () => this.eventListeners.delete(listener)
  49. }
  50. onState(listener: StateListener): () => void {
  51. this.stateListeners.add(listener)
  52. return () => this.stateListeners.delete(listener)
  53. }
  54. async connect(): Promise<void> {
  55. this.close()
  56. this.manuallyClosed = false
  57. const generation = ++this.generation
  58. this.publishState('leasing')
  59. const lease = await createRealtimeLease('chat')
  60. if (generation !== this.generation || this.manuallyClosed) return
  61. this.publishState('connecting')
  62. await new Promise<void>((resolve, reject) => {
  63. const socket = new WebSocket(webSocketURL('/ws/chat', { lease }))
  64. this.socket = socket
  65. let settled = false
  66. const readyTimer = window.setTimeout(() => {
  67. if (settled) return
  68. settled = true
  69. socket.close(1011, 'gateway ready timeout')
  70. reject(new ApiError('等待 Hermes 实时握手超时', 0, 'GATEWAY_READY_TIMEOUT'))
  71. }, 15_000)
  72. socket.addEventListener('open', () => {
  73. if (generation !== this.generation) return socket.close(1000)
  74. this.publishState('connected')
  75. }, { once: true })
  76. socket.addEventListener('message', event => {
  77. void this.handleMessage(event.data, generation).then(ready => {
  78. if (!ready || settled) return
  79. settled = true
  80. window.clearTimeout(readyTimer)
  81. resolve()
  82. })
  83. })
  84. socket.addEventListener('error', () => {
  85. if (!settled) {
  86. settled = true
  87. window.clearTimeout(readyTimer)
  88. reject(new ApiError('聊天实时连接失败', 0, 'WEBSOCKET_ERROR'))
  89. }
  90. })
  91. socket.addEventListener('close', event => {
  92. if (!settled) {
  93. settled = true
  94. window.clearTimeout(readyTimer)
  95. reject(new ApiError(event.reason || '聊天实时连接在握手前关闭', 0, 'WEBSOCKET_CLOSED'))
  96. }
  97. this.handleClose(event, generation)
  98. })
  99. })
  100. }
  101. close(): void {
  102. this.manuallyClosed = true
  103. this.generation += 1
  104. this.socket?.close(1000, 'client close')
  105. this.socket = undefined
  106. this.rejectAll(new ApiError('实时连接已关闭', 0, 'WEBSOCKET_CLOSED'))
  107. this.publishState('disconnected')
  108. }
  109. async request(method: string, params: Record<string, JsonValue> = {}, timeoutMs = 120_000): Promise<JsonValue> {
  110. const socket = this.socket
  111. if (!socket || socket.readyState !== WebSocket.OPEN) throw new ApiError('聊天实时连接尚未就绪', 0, 'WEBSOCKET_NOT_READY')
  112. const id = createId('rpc')
  113. const frame: RpcRequestFrame = { jsonrpc: '2.0', id, method, params }
  114. return new Promise<JsonValue>((resolve, reject) => {
  115. const timer = window.setTimeout(() => {
  116. this.pending.delete(id)
  117. reject(new ApiError(`实时请求超时:${method}`, 0, 'RPC_TIMEOUT'))
  118. // A timed-out request has an unknown receipt and the transport may be half-open.
  119. this.socket?.close(1011, 'rpc timeout')
  120. }, timeoutMs)
  121. this.pending.set(id, { resolve, reject, timer, method })
  122. try {
  123. socket.send(JSON.stringify(frame))
  124. } catch (error) {
  125. window.clearTimeout(timer)
  126. this.pending.delete(id)
  127. reject(error)
  128. }
  129. })
  130. }
  131. private async handleMessage(data: unknown, generation: number): Promise<boolean> {
  132. if (generation !== this.generation) return false
  133. let text: string
  134. if (typeof data === 'string') text = data
  135. else if (data instanceof Blob) text = await data.text()
  136. else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data)
  137. else return false
  138. let frame: unknown
  139. try { frame = JSON.parse(text) } catch { return false }
  140. const source = record(frame)
  141. const id = typeof source.id === 'string' || typeof source.id === 'number' ? String(source.id) : ''
  142. if (id && this.pending.has(id)) {
  143. const pending = this.pending.get(id)!
  144. this.pending.delete(id)
  145. window.clearTimeout(pending.timer)
  146. const error = record(source.error)
  147. if (source.error) pending.reject(new RpcError(string(error.message, 'Hermes JSON-RPC 请求失败'), error.code as number | string, error.data as JsonValue))
  148. else pending.resolve((source.result ?? null) as JsonValue)
  149. return false
  150. }
  151. if (source.method !== 'event') return false
  152. const params = record(source.params)
  153. const type = string(params.type)
  154. if (!type) return false
  155. const event: RpcEventFrame['params'] = {
  156. type,
  157. session_id: string(params.session_id) || undefined,
  158. profile: string(params.profile) || undefined,
  159. payload: (params.payload ?? null) as JsonValue,
  160. }
  161. if (type === 'gateway.ready') this.publishState('ready')
  162. for (const listener of this.eventListeners) listener(event)
  163. return type === 'gateway.ready'
  164. }
  165. private handleClose(event: CloseEvent, generation: number): void {
  166. if (generation !== this.generation) return
  167. this.socket = undefined
  168. this.rejectAll(new ApiError('聊天实时连接已断开', 0, 'WEBSOCKET_CLOSED'))
  169. this.publishState(this.manuallyClosed ? 'disconnected' : 'failed', event.reason || `连接关闭(${event.code})`)
  170. }
  171. private rejectAll(error: Error): void {
  172. for (const pending of this.pending.values()) {
  173. window.clearTimeout(pending.timer)
  174. pending.reject(error)
  175. }
  176. this.pending.clear()
  177. }
  178. private publishState(state: RealtimeConnectionState, reason?: string): void {
  179. this.state = state
  180. for (const listener of this.stateListeners) listener(state, reason)
  181. }
  182. }
  183. export interface GroupEventSocketOptions {
  184. epoch: string
  185. cursor: number
  186. onEnvelope(envelope: GroupSocketEnvelope): void
  187. onState?(state: RealtimeConnectionState, reason?: string): void
  188. onReset(reason: string): void
  189. }
  190. export class GroupEventSocket {
  191. private socket?: WebSocket
  192. private generation = 0
  193. private manuallyClosed = false
  194. private epoch = ''
  195. private cursor = 0
  196. state: RealtimeConnectionState = 'idle'
  197. async connect(options: GroupEventSocketOptions): Promise<void> {
  198. this.close()
  199. this.manuallyClosed = false
  200. this.epoch = options.epoch
  201. this.cursor = options.cursor
  202. const generation = ++this.generation
  203. this.publish(options, 'leasing')
  204. const lease = await createRealtimeLease('groups', { epoch: this.epoch, cursor: this.cursor })
  205. if (generation !== this.generation || this.manuallyClosed) return
  206. this.publish(options, 'connecting')
  207. await new Promise<void>((resolve, reject) => {
  208. const socket = new WebSocket(webSocketURL('/ws/groups', { lease }))
  209. this.socket = socket
  210. let settled = false
  211. socket.addEventListener('open', () => {
  212. if (generation !== this.generation) return socket.close(1000)
  213. settled = true
  214. resolve()
  215. }, { once: true })
  216. socket.addEventListener('message', event => { void this.handleMessage(event.data, generation, options) })
  217. socket.addEventListener('error', () => {
  218. if (!settled) {
  219. settled = true
  220. reject(new ApiError('群聊事件连接失败', 0, 'GROUP_WEBSOCKET_ERROR'))
  221. }
  222. })
  223. socket.addEventListener('close', event => {
  224. if (generation !== this.generation) return
  225. this.socket = undefined
  226. this.publish(options, this.manuallyClosed ? 'disconnected' : 'failed', event.reason || `连接关闭(${event.code})`)
  227. })
  228. })
  229. }
  230. close(): void {
  231. this.manuallyClosed = true
  232. this.generation += 1
  233. this.socket?.close(1000, 'client close')
  234. this.socket = undefined
  235. this.state = 'disconnected'
  236. }
  237. private async handleMessage(data: unknown, generation: number, options: GroupEventSocketOptions): Promise<void> {
  238. if (generation !== this.generation) return
  239. let text: string
  240. if (typeof data === 'string') text = data
  241. else if (data instanceof Blob) text = await data.text()
  242. else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data)
  243. else return
  244. let decoded: unknown
  245. try { decoded = JSON.parse(text) } catch {
  246. this.needsReset(options, 'malformed_frame')
  247. return
  248. }
  249. const source = record(decoded)
  250. const envelope: GroupSocketEnvelope = {
  251. type: string(source.type) as GroupSocketEnvelope['type'],
  252. epoch: string(source.epoch) || undefined,
  253. cursor: source.cursor == null ? undefined : number(source.cursor, -1),
  254. heartbeatSeconds: source.heartbeatSeconds == null && source.heartbeat_seconds == null ? undefined : number(source.heartbeatSeconds ?? source.heartbeat_seconds),
  255. roomId: string(source.roomId ?? source.room_id) || undefined,
  256. event: string(source.event) || undefined,
  257. payload: (source.payload ?? null) as JsonValue,
  258. reason: string(source.reason) || undefined,
  259. }
  260. if (!['group.ready', 'group.event', 'group.heartbeat', 'group.reset_required'].includes(envelope.type)) {
  261. this.needsReset(options, 'unknown_envelope')
  262. return
  263. }
  264. if (envelope.epoch && envelope.epoch !== this.epoch) {
  265. this.needsReset(options, 'epoch_mismatch')
  266. return
  267. }
  268. if (envelope.type === 'group.ready') {
  269. if (envelope.cursor !== this.cursor) return this.needsReset(options, 'ready_cursor_mismatch')
  270. this.publish(options, 'ready')
  271. options.onEnvelope(envelope)
  272. return
  273. }
  274. if (envelope.type === 'group.event') {
  275. if (envelope.cursor == null || envelope.cursor < 0) return this.needsReset(options, 'invalid_cursor')
  276. if (envelope.cursor <= this.cursor) return
  277. if (envelope.cursor !== this.cursor + 1) return this.needsReset(options, 'cursor_gap')
  278. this.cursor = envelope.cursor
  279. options.onEnvelope(envelope)
  280. return
  281. }
  282. if (envelope.type === 'group.reset_required') {
  283. this.needsReset(options, envelope.reason || 'server_reset')
  284. return
  285. }
  286. if (envelope.cursor != null && envelope.cursor !== this.cursor) this.needsReset(options, 'heartbeat_cursor_mismatch')
  287. }
  288. private needsReset(options: GroupEventSocketOptions, reason: string): void {
  289. this.publish(options, 'needs-reset', reason)
  290. this.socket?.close(1000, 'reset required')
  291. options.onReset(reason)
  292. }
  293. private publish(options: GroupEventSocketOptions, state: RealtimeConnectionState, reason?: string): void {
  294. this.state = state
  295. options.onState?.(state, reason)
  296. }
  297. }