/* * sse.ts * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * fetch + ReadableStream SSE parsing, ported from the native SSEParser / * StreamingService. One parser handles both stream shapes: OpenAI-style * anonymous `data:` events and Anthropic's named `event:` blocks. */ import type { Provider } from '../types' import { ProviderError } from './types' /** One Server-Sent Event as parsed off the wire. */ export interface SSEEvent { /** The `event:` field, if the stream names its events (Anthropic does). */ event: string | null /** Joined `data:` lines. */ data: string } /** * Incremental SSE parser. Feed it raw lines (without trailing newlines) and it * yields complete events at blank-line boundaries, ignoring `:` comment lines * (DeepSeek sends `: keep-alive`) and unknown fields. */ export class SSEParser { private currentEvent: string | null = null private currentData: string[] = [] /** Consumes one line. Returns a completed event at a blank separator, else null. */ consume(line: string): SSEEvent | null { if (line === '') { if (this.currentData.length === 0 && this.currentEvent === null) return null const event: SSEEvent = { event: this.currentEvent, data: this.currentData.join('\n') } this.currentEvent = null this.currentData = [] return event.data === '' && event.event === null ? null : event } if (line.startsWith(':')) return null // comment / keep-alive if (line.startsWith('event:')) { this.currentEvent = line.slice(6).trim() } else if (line.startsWith('data:')) { let value = line.slice(5) if (value.startsWith(' ')) value = value.slice(1) this.currentData.push(value) } // id:/retry:/unknown fields are ignored. return null } } /** * POSTs `body` as JSON and yields the SSE events of the response. * Throws ProviderError on non-2xx status (reading the full error body). * Cancellation: abort the signal — surfaces as a `cancelled` ProviderError. */ export async function* sseEvents( url: string, init: { headers: Record; body: string }, provider: Provider, signal?: AbortSignal ): AsyncGenerator { let res: Response try { res = await fetch(url, { method: 'POST', headers: init.headers, body: init.body, ...(signal ? { signal } : {}), }) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled() throw ProviderError.network(provider, err) } if (!res.ok) { const body = await res.text().catch(() => '') const retryAfter = parseRetryAfter(res) throw ProviderError.from(res.status, body, provider, retryAfter) } if (!res.body) { throw ProviderError.invalidResponse(provider, 'response had no body') } const reader = res.body.pipeThrough(new TextDecoderStream()).getReader() const parser = new SSEParser() let buffer = '' try { for (;;) { let chunk: ReadableStreamReadResult try { chunk = await reader.read() } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled() throw ProviderError.network(provider, err) } if (chunk.done) break buffer += chunk.value // Split on newlines, preserving blank lines (they are event separators). let newlineIndex: number while ((newlineIndex = buffer.indexOf('\n')) !== -1) { let line = buffer.slice(0, newlineIndex) buffer = buffer.slice(newlineIndex + 1) if (line.endsWith('\r')) line = line.slice(0, -1) const event = parser.consume(line) if (event) yield event } } // Flush a trailing line + event if the stream ended without a final // newline / blank separator. if (buffer !== '') { const event = parser.consume(buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer) if (event) yield event } const finalEvent = parser.consume('') if (finalEvent) yield finalEvent } finally { reader.cancel().catch(() => {}) } } /** * Non-streaming JSON request with exponential backoff on 429/5xx (3 attempts, * honoring Retry-After). Returns the response body text. */ export async function requestJSON( url: string, init: { method: 'GET' | 'POST'; headers: Record; body?: string }, provider: Provider, signal?: AbortSignal ): Promise { const maxAttempts = 3 let lastError: ProviderError = ProviderError.invalidResponse(provider, 'no attempts made') for (let attempt = 1; attempt <= maxAttempts; attempt++) { let res: Response try { res = await fetch(url, { method: init.method, headers: init.headers, ...(init.body !== undefined ? { body: init.body } : {}), ...(signal ? { signal } : {}), }) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') throw ProviderError.cancelled() throw ProviderError.network(provider, err) } const body = await res.text().catch(() => '') if (res.ok) return body const retryAfter = parseRetryAfter(res) const error = ProviderError.from(res.status, body, provider, retryAfter) if (attempt < maxAttempts && (res.status === 429 || res.status >= 500)) { lastError = error const delay = (retryAfter ?? 2 ** attempt * 2) * 1000 // 4s, 8s await sleep(delay, signal) continue } throw error } throw lastError } function parseRetryAfter(res: Response): number | undefined { const header = res.headers.get('Retry-After') if (!header) return undefined const seconds = Number(header) return Number.isFinite(seconds) ? seconds : undefined } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms) signal?.addEventListener( 'abort', () => { clearTimeout(timer) reject(ProviderError.cancelled()) }, { once: true } ) }) } /** Joins a base URL and a path, preserving base path components. */ export function joinURL(base: string, path: string): string { return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}` }