SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%
6.2 KB · 191 lines typescript
Raw Blame History
1/*2 *  sse.ts3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  fetch + ReadableStream SSE parsing, ported from the native SSEParser /9 *  StreamingService. One parser handles both stream shapes: OpenAI-style10 *  anonymous `data:` events and Anthropic's named `event:` blocks.11 */1213import type { Provider } from '../types'14import { ProviderError } from './types'1516/** One Server-Sent Event as parsed off the wire. */17export interface SSEEvent {18  /** The `event:` field, if the stream names its events (Anthropic does). */19  event: string | null20  /** Joined `data:` lines. */21  data: string22}2324/**25 * Incremental SSE parser. Feed it raw lines (without trailing newlines) and it26 * yields complete events at blank-line boundaries, ignoring `:` comment lines27 * (DeepSeek sends `: keep-alive`) and unknown fields.28 */29export class SSEParser {30  private currentEvent: string | null = null31  private currentData: string[] = []3233  /** Consumes one line. Returns a completed event at a blank separator, else null. */34  consume(line: string): SSEEvent | null {35    if (line === '') {36      if (this.currentData.length === 0 && this.currentEvent === null) return null37      const event: SSEEvent = { event: this.currentEvent, data: this.currentData.join('\n') }38      this.currentEvent = null39      this.currentData = []40      return event.data === '' && event.event === null ? null : event41    }42    if (line.startsWith(':')) return null // comment / keep-alive43    if (line.startsWith('event:')) {44      this.currentEvent = line.slice(6).trim()45    } else if (line.startsWith('data:')) {46      let value = line.slice(5)47      if (value.startsWith(' ')) value = value.slice(1)48      this.currentData.push(value)49    }50    // id:/retry:/unknown fields are ignored.51    return null52  }53}5455/**56 * POSTs `body` as JSON and yields the SSE events of the response.57 * Throws ProviderError on non-2xx status (reading the full error body).58 * Cancellation: abort the signal — surfaces as a `cancelled` ProviderError.59 */60export async function* sseEvents(61  url: string,62  init: { headers: Record<string, string>; body: string },63  provider: Provider,64  signal?: AbortSignal65): AsyncGenerator<SSEEvent> {66  let res: Response67  try {68    res = await fetch(url, {69      method: 'POST',70      headers: init.headers,71      body: init.body,72      ...(signal ? { signal } : {}),73    })74  } catch (err) {75    if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled()76    throw ProviderError.network(provider, err)77  }7879  if (!res.ok) {80    const body = await res.text().catch(() => '')81    const retryAfter = parseRetryAfter(res)82    throw ProviderError.from(res.status, body, provider, retryAfter)83  }84  if (!res.body) {85    throw ProviderError.invalidResponse(provider, 'response had no body')86  }8788  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()89  const parser = new SSEParser()90  let buffer = ''9192  try {93    for (;;) {94      let chunk: ReadableStreamReadResult<string>95      try {96        chunk = await reader.read()97      } catch (err) {98        if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled()99        throw ProviderError.network(provider, err)100      }101      if (chunk.done) break102      buffer += chunk.value103      // Split on newlines, preserving blank lines (they are event separators).104      let newlineIndex: number105      while ((newlineIndex = buffer.indexOf('\n')) !== -1) {106        let line = buffer.slice(0, newlineIndex)107        buffer = buffer.slice(newlineIndex + 1)108        if (line.endsWith('\r')) line = line.slice(0, -1)109        const event = parser.consume(line)110        if (event) yield event111      }112    }113    // Flush a trailing line + event if the stream ended without a final114    // newline / blank separator.115    if (buffer !== '') {116      const event = parser.consume(buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer)117      if (event) yield event118    }119    const finalEvent = parser.consume('')120    if (finalEvent) yield finalEvent121  } finally {122    reader.cancel().catch(() => {})123  }124}125126/**127 * Non-streaming JSON request with exponential backoff on 429/5xx (3 attempts,128 * honoring Retry-After). Returns the response body text.129 */130export async function requestJSON(131  url: string,132  init: { method: 'GET' | 'POST'; headers: Record<string, string>; body?: string },133  provider: Provider,134  signal?: AbortSignal135): Promise<string> {136  const maxAttempts = 3137  let lastError: ProviderError = ProviderError.invalidResponse(provider, 'no attempts made')138  for (let attempt = 1; attempt <= maxAttempts; attempt++) {139    let res: Response140    try {141      res = await fetch(url, {142        method: init.method,143        headers: init.headers,144        ...(init.body !== undefined ? { body: init.body } : {}),145        ...(signal ? { signal } : {}),146      })147    } catch (err) {148      if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled()149      throw ProviderError.network(provider, err)150    }151    const body = await res.text().catch(() => '')152    if (res.ok) return body153    const retryAfter = parseRetryAfter(res)154    const error = ProviderError.from(res.status, body, provider, retryAfter)155    if (attempt < maxAttempts && (res.status === 429 || res.status >= 500)) {156      lastError = error157      const delay = (retryAfter ?? 2 ** attempt * 2) * 1000 // 4s, 8s158      await sleep(delay, signal)159      continue160    }161    throw error162  }163  throw lastError164}165166function parseRetryAfter(res: Response): number | undefined {167  const header = res.headers.get('Retry-After')168  if (!header) return undefined169  const seconds = Number(header)170  return Number.isFinite(seconds) ? seconds : undefined171}172173function sleep(ms: number, signal?: AbortSignal): Promise<void> {174  return new Promise((resolve, reject) => {175    const timer = setTimeout(resolve, ms)176    signal?.addEventListener(177      'abort',178      () => {179        clearTimeout(timer)180        reject(ProviderError.cancelled())181      },182      { once: true }183    )184  })185}186187/** Joins a base URL and a path, preserving base path components. */188export function joinURL(base: string, path: string): string {189  return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`190}191