sse.js 3.03 KB
/**
 * SSE 帧切分与迭代 —— 自 base.js 提取的纯函数模块(职责单一硬约束)。
 *
 * 零副作用、零依赖:从 Response.body(ReadableStream)按 SSE 事件分隔(空行)分帧,
 * 抽 `data:` 负载、`[DONE]` 终止、parseFrame 解析 JSON。base.js 一行 import 接入。
 * 中止时抛出 `signal.reason`(承接 base.js 的每 attempt 组合信号),使内部超时的
 * TimeoutError reason 能透传到 map-error(区别于外部 abort 的 AbortError)。
 */

/**
 * 从 Response.body 按行解析 SSE,yield 每个 `data:` 帧的 JSON(或字符串)。
 * 兼容 `event: x\ndata: {...}` 多行帧;遇 `[DONE]` 停止。
 * @param {Response} res
 * @param {AbortSignal} [signal]
 * @returns {AsyncGenerator<any>}
 */
export async function* iterateSse(res, signal) {
  const body = res.body
  if (!body) return
  const decoder = new TextDecoder()
  let buf = ''
  const reader = body.getReader()
  try {
    for (;;) {
      if (signal?.aborted) throw abortReason(signal)
      const { value, done } = await reader.read()
      if (done) break
      buf += decoder.decode(value, { stream: true })
      // 按 SSE 事件分隔(空行)切块;块内取 data: 行拼接。
      let sepIdx
      while ((sepIdx = indexOfEventSep(buf)) !== -1) {
        const rawEvent = buf.slice(0, sepIdx)
        buf = buf.slice(sepIdx).replace(/^(\r?\n){1,2}/, '')
        const data = extractData(rawEvent)
        if (data === null) continue
        if (data === '[DONE]') return
        yield parseFrame(data)
      }
    }
    // 收尾:缓冲里可能残留最后一个无尾随空行的事件。
    const tail = extractData(buf)
    if (tail !== null && tail !== '[DONE]') yield parseFrame(tail)
  } finally {
    reader.releaseLock?.()
  }
}

/**
 * 中止原因:优先透传 signal.reason(内部超时的 TimeoutError / 外部的 AbortError),
 * 缺省造 AbortError。
 * @param {AbortSignal} signal
 * @returns {unknown}
 */
function abortReason(signal) {
  return signal.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' })
}

/**
 * 找事件分隔(`\n\n` 或 `\r\n\r\n`)的起点;无则 -1。
 * @param {string} buf
 * @returns {number}
 */
function indexOfEventSep(buf) {
  const a = buf.indexOf('\n\n')
  const b = buf.indexOf('\r\n\r\n')
  if (a === -1) return b
  if (b === -1) return a
  return Math.min(a, b)
}

/**
 * 从一个 SSE 事件块里抽出 data 负载(拼接多行 data:);无 data 行返回 null。
 * @param {string} rawEvent
 * @returns {string | null}
 */
function extractData(rawEvent) {
  const lines = rawEvent.split(/\r?\n/)
  /** @type {string[]} */
  const dataLines = []
  for (const line of lines) {
    if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
  }
  if (dataLines.length === 0) return null
  return dataLines.join('\n')
}

/**
 * data 负载若是 JSON 则解析,否则原样返回字符串。
 * @param {string} data
 * @returns {any}
 */
function parseFrame(data) {
  try {
    return JSON.parse(data)
  } catch {
    return data
  }
}