httpRealtime.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. import type { JsonValue } from '@shared/types'
  2. import { SSEParser } from '@shared/sse'
  3. import { apiRequest, ApiError, setApiCsrfToken } from './client'
  4. import { createId } from '@/utils/id'
  5. import { CHAT_TRANSCRIPT_FEATURE } from '@shared/chatTranscript'
  6. export class HTTPRPCError extends Error {
  7. constructor(message: string, readonly code?: string | number, readonly data?: JsonValue) { super(message) }
  8. }
  9. export class HTTPRealtimeChannel {
  10. private id = ''
  11. private cursor = ''
  12. private stopped = false
  13. private abort?: AbortController
  14. private ready = false
  15. constructor(readonly onFrame: (frame: string) => Promise<unknown>, readonly onFailure: (error: Error) => void) {}
  16. static async requireSupport(): Promise<string[]> {
  17. let value: { protocolVersion: number; csrfToken?: string; features?: string[] }
  18. try { value = await apiRequest('/api/realtime/capabilities') }
  19. catch (e) {
  20. if (e instanceof ApiError && [404, 405, 410].includes(e.status)) throw new ApiError('此服务端不支持 HTTP+SSE,请升级 15300 服务', 409, 'HTTP_SSE_REQUIRED')
  21. throw e
  22. }
  23. if (value.protocolVersion !== 1) throw new ApiError('不支持的实时协议版本', 409, 'UNSUPPORTED_REALTIME_PROTOCOL')
  24. if (value.csrfToken) setApiCsrfToken(value.csrfToken)
  25. return value.features ?? []
  26. }
  27. async open(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<void> {
  28. const created = await apiRequest<{ id: string }>('/api/realtime/channels', { method: 'POST', body: { channel, ...(channel==='chat'?{chatProtocol:CHAT_TRANSCRIPT_FEATURE}:{}), ...anchor } })
  29. this.id = created.id
  30. if (this.stopped) { this.close(); return }
  31. await new Promise<void>((resolve, reject) => {
  32. const timer = window.setTimeout(() => { this.abort?.abort(); reject(new ApiError('实时事件握手超时', 0, 'REALTIME_READY_TIMEOUT')) }, 20_000)
  33. void this.readLoop(() => { window.clearTimeout(timer); this.ready = true; resolve() }).catch(error => {
  34. window.clearTimeout(timer)
  35. if (!this.ready) reject(error)
  36. if (!this.stopped) this.onFailure(error instanceof Error ? error : new Error(String(error)))
  37. })
  38. })
  39. }
  40. private async readLoop(onReady: () => void): Promise<void> {
  41. let attempt = 0
  42. while (!this.stopped) {
  43. const attemptAbort = new AbortController()
  44. this.abort = attemptAbort
  45. let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
  46. let watchdog: number | undefined
  47. try {
  48. watchdog = window.setTimeout(() => attemptAbort.abort(), 45_000)
  49. const headers: Record<string, string> = { Accept: 'text/event-stream' }
  50. if (this.cursor) headers['Last-Event-ID'] = this.cursor
  51. const response = await fetch(`/api/realtime/channels/${this.id}/events`, { credentials: 'same-origin', headers, signal: attemptAbort.signal, cache: 'no-store' })
  52. if ([401, 403, 404, 409, 428].includes(response.status)) throw new ApiError('实时连接需要重新同步', response.status, 'REALTIME_RESET_REQUIRED')
  53. if (!response.ok) throw new Error(`SSE HTTP ${response.status}`)
  54. if (!response.headers.get('content-type')?.startsWith('text/event-stream') || !response.body) throw new ApiError('无效的事件流响应', 502, 'INVALID_EVENT_STREAM')
  55. reader = response.body.getReader()
  56. const parser = new SSEParser(), decoder = new TextDecoder()
  57. while (!this.stopped) {
  58. window.clearTimeout(watchdog)
  59. watchdog = window.setTimeout(() => attemptAbort.abort(), 45_000)
  60. const { value, done } = await reader.read()
  61. if (done) throw new Error('SSE disconnected')
  62. attempt = 0
  63. for (const entry of parser.feed(decoder.decode(value, { stream: true }))) {
  64. if (entry.event === 'reset') throw new ApiError('事件需要重新同步', 409, 'REALTIME_RESET_REQUIRED')
  65. if (entry.event === 'ready') continue
  66. if (entry.event !== 'frame') continue
  67. if (entry.id && entry.id === this.cursor) continue
  68. await this.onFrame(entry.data)
  69. if (entry.id) this.cursor = entry.id
  70. const frame = JSON.parse(entry.data)
  71. if (frame.type === 'group.ready' || frame.params?.type === 'gateway.ready') onReady()
  72. }
  73. }
  74. } catch (error) {
  75. if (this.stopped) return
  76. if (error instanceof ApiError) throw error
  77. } finally { window.clearTimeout(watchdog); await reader?.cancel().catch(() => {}); reader?.releaseLock() }
  78. await new Promise(r => window.setTimeout(r, Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5))))
  79. }
  80. }
  81. async request(method: string, params: Record<string, JsonValue>, timeoutMs: number, requestId = createId('http-rpc')): Promise<JsonValue> {
  82. if (!this.ready || this.stopped) throw new ApiError('事件通道尚未就绪', 0, 'REALTIME_NOT_READY')
  83. type Receipt = { state: string; response?: { result?: JsonValue; error?: { message?: string; code?: string | number; data?: JsonValue } } }
  84. let receipt: Receipt
  85. try {
  86. receipt = await apiRequest(`/api/realtime/channels/${this.id}/commands`, {
  87. method: 'POST', headers: { 'Idempotency-Key': requestId }, body: { jsonrpc: '2.0', method, params }, timeoutMs,
  88. })
  89. } catch (error) {
  90. if (error instanceof ApiError && error.status >= 400 && error.status < 500) throw error
  91. try { receipt = await apiRequest(`/api/realtime/commands/${encodeURIComponent(requestId)}`) }
  92. catch { throw new ApiError('提交结果未知,请同步历史确认', 0, 'SUBMISSION_UNCERTAIN') }
  93. }
  94. if (receipt.state === 'unknown' || receipt.state === 'pending') throw new ApiError('提交结果未知,请同步历史确认', 0, 'SUBMISSION_UNCERTAIN')
  95. if (receipt.response?.error) throw new HTTPRPCError(receipt.response.error.message ?? '上游拒绝请求', receipt.response.error.code, receipt.response.error.data)
  96. if (receipt.state !== 'confirmed') throw new ApiError('无效的命令回执', 502, 'INVALID_RECEIPT')
  97. return receipt.response?.result ?? null
  98. }
  99. close(): void {
  100. this.stopped = true; this.abort?.abort()
  101. if (this.id) { const id = this.id; this.id = ''; void apiRequest(`/api/realtime/channels/${id}`, { method: 'DELETE' }).catch(() => {}) }
  102. }
  103. }