http.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. // 请求与 SSE 的公共部分:AgentClient(/v1)与 BotClient(/bot/v1)共用
  2. // SSE 用 fetch + ReadableStream 实现:可携带 Authorization 头,断线后按 Last-Event-ID 续传
  3. import type { AgentEvent, ApiErrorBody, BrowserTokenDto } from '@agent-server/shared'
  4. export interface AgentClientOptions {
  5. /** 服务地址,例如 https://agent.example.com;同源时可为空字符串 */
  6. baseUrl: string
  7. /** Agent Key:只在业务平台的后端使用,不要放进浏览器代码 */
  8. apiKey?: string
  9. /**
  10. * 浏览器中使用:返回浏览器令牌(由平台后端用 Agent Key 换取,见 browserTokenProvider)。
  11. * 令牌失效(401)时会以 { refresh: true } 再调用一次并重试
  12. */
  13. getToken?: (opts: { refresh: boolean }) => string | Promise<string>
  14. fetch?: typeof fetch
  15. }
  16. export class AgentApiError extends Error {
  17. constructor(readonly status: number, readonly code: string, message: string, readonly details?: unknown) {
  18. super(message)
  19. }
  20. }
  21. export class HttpClient {
  22. private readonly fetchImpl: typeof fetch
  23. constructor(protected readonly opts: AgentClientOptions) {
  24. this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
  25. }
  26. private async headers(extra?: Record<string, string>, refresh = false): Promise<Record<string, string>> {
  27. const token = this.opts.getToken ? await this.opts.getToken({ refresh }) : this.opts.apiKey
  28. return { ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra }
  29. }
  30. /** 发请求;使用浏览器令牌时,令牌失效则刷新后重试一次。Cookie 只在同源(后台)时携带 */
  31. protected async send(path: string, init: RequestInit & { headers?: Record<string, string> }): Promise<Response> {
  32. const go = async (refresh: boolean) => this.fetchImpl(`${this.opts.baseUrl}${path}`, {
  33. ...init, credentials: 'same-origin', headers: await this.headers(init.headers, refresh),
  34. })
  35. const res = await go(false)
  36. if (res.status === 401 && this.opts.getToken) {
  37. await res.body?.cancel()
  38. return go(true)
  39. }
  40. return res
  41. }
  42. async request<T>(method: string, path: string, body?: unknown): Promise<T> {
  43. const isForm = typeof FormData !== 'undefined' && body instanceof FormData
  44. const res = await this.send(path, {
  45. method,
  46. headers: body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined,
  47. body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
  48. })
  49. if (!res.ok) {
  50. const err = (await res.json().catch(() => null)) as ApiErrorBody | null
  51. throw new AgentApiError(res.status, err?.error.code ?? 'http_error', err?.error.message ?? `HTTP ${res.status}`, err?.error.details)
  52. }
  53. if (res.status === 204) return undefined as T
  54. return (await res.json()) as T
  55. }
  56. /** 订阅 SSE 事件流;断线自动重连并从最后收到的 seq 续传。返回取消函数 */
  57. protected stream<E extends { seq: number }>(path: string, handlers: StreamHandlers<E>, after: number): () => void {
  58. let lastSeq = after
  59. let stopped = false
  60. let controller: AbortController | null = null
  61. let retry = 0
  62. const connect = async () => {
  63. while (!stopped) {
  64. controller = new AbortController()
  65. try {
  66. const res = await this.send(path, {
  67. headers: { accept: 'text/event-stream', 'last-event-id': String(lastSeq) },
  68. signal: controller.signal,
  69. })
  70. if (!res.ok || !res.body) {
  71. const err = (await res.json().catch(() => null)) as ApiErrorBody | null
  72. throw new AgentApiError(res.status, err?.error.code ?? 'stream_error', err?.error.message ?? `HTTP ${res.status}`)
  73. }
  74. retry = 0
  75. handlers.onStatus?.('open')
  76. for await (const event of parseSse<E>(res.body)) {
  77. lastSeq = Math.max(lastSeq, event.seq)
  78. handlers.onEvent(event)
  79. }
  80. } catch (err) {
  81. if (stopped) return
  82. if (err instanceof AgentApiError && (err.status === 401 || err.status === 404)) {
  83. handlers.onStatus?.('closed')
  84. handlers.onError?.(err)
  85. return
  86. }
  87. handlers.onError?.(err)
  88. }
  89. if (stopped) return
  90. handlers.onStatus?.('reconnecting')
  91. await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** retry++, 15000)))
  92. }
  93. }
  94. void connect()
  95. return () => {
  96. stopped = true
  97. controller?.abort()
  98. handlers.onStatus?.('closed')
  99. }
  100. }
  101. }
  102. /**
  103. * 浏览器令牌缓存:fetchToken 调用平台自己的后端(后端再用 Agent Key 调 issueBrowserToken)。
  104. * 在过期前 60 秒自动换新,并发请求共用同一次获取
  105. */
  106. export function browserTokenProvider(fetchToken: () => Promise<BrowserTokenDto>): NonNullable<AgentClientOptions['getToken']> {
  107. let current: BrowserTokenDto | null = null
  108. let pending: Promise<BrowserTokenDto> | null = null
  109. return async ({ refresh }) => {
  110. const fresh = current && Date.parse(current.expiresAt) - Date.now() > 60_000
  111. if (fresh && !refresh) return current!.token
  112. pending ??= fetchToken().finally(() => { pending = null })
  113. current = await pending
  114. return current.token
  115. }
  116. }
  117. export interface StreamHandlers<E = AgentEvent> {
  118. onEvent: (e: E) => void
  119. onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
  120. onError?: (err: unknown) => void
  121. }
  122. async function* parseSse<E>(body: ReadableStream<Uint8Array>): AsyncGenerator<E> {
  123. const reader = body.getReader()
  124. const decoder = new TextDecoder()
  125. let buf = ''
  126. while (true) {
  127. const { value, done } = await reader.read()
  128. if (done) return
  129. buf += decoder.decode(value, { stream: true })
  130. let idx: number
  131. while ((idx = buf.indexOf('\n\n')) >= 0) {
  132. const chunk = buf.slice(0, idx)
  133. buf = buf.slice(idx + 2)
  134. const data = chunk.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('\n')
  135. if (data) yield JSON.parse(data) as E
  136. }
  137. }
  138. }
  139. export function query(q: Record<string, string | number | boolean | undefined>) {
  140. const s = Object.entries(q).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&')
  141. return s ? `?${s}` : ''
  142. }