/** * StreamManager.ts — Safari-safe streaming engine * * Features: * - cancel, resume (reconnect), retry * - normalize (strips SSE wrapper) * - buffer + token batching (reduces React re-renders) * - Safari iOS safe: handles broken pipes, readableStream drops */ export type StreamState = "idle" | "connecting" | "streaming" | "paused" | "done" | "error"; export interface StreamManagerOptions { onChunk: (text: string) => void; onStatus?: (state: StreamState) => void; onError?: (err: Error) => void; onDone?: () => void; batchIntervalMs?: number; maxRetries?: number; retryDelayMs?: number; timeoutMs?: number; } interface StreamSession { abortCtrl: AbortController; reader?: ReadableStreamDefaultReader; } export class StreamManager { private state: StreamState = "idle"; private session: StreamSession | null = null; private buffer = ""; private batchTimer: ReturnType | null = null; private pendingChunks = ""; private readonly opts: Required; constructor(opts: StreamManagerOptions) { this.opts = { batchIntervalMs: 32, maxRetries: 2, retryDelayMs: 800, timeoutMs: 60_000, onStatus: () => {}, onError: () => {}, onDone: () => {}, ...opts, }; } private setState(s: StreamState) { this.state = s; this.opts.onStatus(s); } private startBatch() { if (this.batchTimer) return; this.batchTimer = setInterval(() => { if (this.pendingChunks) { this.opts.onChunk(this.pendingChunks); this.pendingChunks = ""; } }, this.opts.batchIntervalMs); } private stopBatch() { if (this.batchTimer) { clearInterval(this.batchTimer); this.batchTimer = null; } if (this.pendingChunks) { this.opts.onChunk(this.pendingChunks); this.pendingChunks = ""; } } private addChunk(text: string) { this.pendingChunks += text; } async stream(fetcher: () => Promise): Promise { let attempt = 0; while (attempt <= this.opts.maxRetries) { attempt++; const ctrl = new AbortController(); const timeoutId = setTimeout(() => ctrl.abort(), this.opts.timeoutMs); this.session = { abortCtrl: ctrl }; this.setState("connecting"); try { const response = await fetcher(); if (!response.ok) { throw Object.assign( new Error(`HTTP ${response.status}: ${response.statusText}`), { status: response.status }, ); } if (!response.body) throw new Error("Risposta senza body"); const reader = response.body.getReader(); this.session.reader = reader; const decoder = new TextDecoder(); this.setState("streaming"); this.startBatch(); while (true) { let done: boolean; let value: Uint8Array | undefined; try { ({ done, value } = await reader.read()); } catch (readErr) { const e = readErr as Error; if (e.name === "AbortError" || ctrl.signal.aborted) break; throw e; } if (done) break; if (!value) continue; this.buffer += decoder.decode(value, { stream: true }); this.processBuffer(); } clearTimeout(timeoutId); this.stopBatch(); this.setState("done"); this.opts.onDone(); return; } catch (err) { clearTimeout(timeoutId); this.stopBatch(); const e = err instanceof Error ? err : new Error(String(err)); if (this.state === "idle") return; if (e.name === "AbortError") { this.setState("done"); return; } const isRetryable = attempt <= this.opts.maxRetries && !("status" in e && (e as {status:number}).status < 500); if (isRetryable) { this.setState("paused"); await new Promise(r => setTimeout(r, this.opts.retryDelayMs * attempt)); this.buffer = ""; continue; } this.setState("error"); this.opts.onError(e); return; } } } private processBuffer() { const lines = this.buffer.split("\n"); this.buffer = lines[lines.length - 1]; for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].trim(); if (!line) continue; if (line.startsWith("data: ")) { const data = line.slice(6).trim(); if (data === "[DONE]") { this.stopBatch(); this.setState("done"); this.opts.onDone(); return; } const text = this.extractText(data); if (text) this.addChunk(text); } } } private extractText(data: string): string { try { const json = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; text?: string }>; content?: string; text?: string; }; return ( json.choices?.[0]?.delta?.content ?? json.choices?.[0]?.text ?? json.content ?? json.text ?? "" ); } catch { return data; } } cancel() { this.setState("idle"); this.session?.abortCtrl.abort(); this.session?.reader?.cancel().catch(() => {}); this.session = null; this.stopBatch(); this.buffer = ""; } getState(): StreamState { return this.state; } }