import { formatMessage, messages } from './messages' import type { RecommendationRequest, StreamEvent } from './types' import type { TransportFailureKind } from './models' import { parseStreamEvent, StreamParseError } from './streamParser' type StreamPhase = 'metadata' | 'events' | 'terminal' export class StreamTransportError extends Error { readonly kind: TransportFailureKind constructor(kind: TransportFailureKind, message: string) { super(message) this.name = 'StreamTransportError' this.kind = kind } } function parseLine(line: string): StreamEvent { let value: unknown try { value = JSON.parse(line) } catch { throw new StreamTransportError('parse', messages.recommendationStreamInvalidJson) } try { return parseStreamEvent(value) } catch (error) { if (error instanceof StreamParseError) { throw new StreamTransportError('parse', error.message) } throw error } } function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase { if (phase === 'metadata') { if (event.type === 'metadata') return 'events' if (event.type === 'error') return 'terminal' throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata) } if (phase === 'events') { if (event.type === 'track' || event.type === 'warning') return 'events' if (event.type === 'done' || event.type === 'error') return 'terminal' throw new StreamTransportError('protocol', messages.recommendationStreamDuplicateMetadata) } throw new StreamTransportError('protocol', messages.recommendationStreamAfterFinalEvent) } async function cancelBody(body: ReadableStream | null): Promise { try { await body?.cancel() } catch { return } } async function cancelReader(reader: ReadableStreamDefaultReader): Promise { try { await reader.cancel() } catch { return } } function releaseReader(reader: ReadableStreamDefaultReader): void { try { reader.releaseLock() } catch { return } } /** Post a recommendation request and consume its NDJSON event stream. */ export async function streamRecommendations( request: RecommendationRequest, signal: AbortSignal, onEvent: (event: StreamEvent) => void, ): Promise { let response: Response try { response = await fetch('/api/recommendations', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal, }) } catch (error) { if (signal.aborted) throw error throw new StreamTransportError('network', messages.recommendationStreamUnavailable) } if (!response.ok) { await cancelBody(response.body) throw new StreamTransportError( 'http', formatMessage('recommendationStreamHttpFailure', { status: response.status }), ) } const contentType = response.headers.get('content-type')?.split(';')[0].trim() if (contentType !== 'application/x-ndjson') { await cancelBody(response.body) throw new StreamTransportError('protocol', messages.recommendationStreamInvalidContentType) } if (!response.body) { throw new StreamTransportError('protocol', messages.recommendationStreamEmpty) } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' let phase: StreamPhase = 'metadata' try { while (true) { const { done, value } = await reader.read() buffer += decoder.decode(value, { stream: !done }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const rawLine of lines) { const line = rawLine.trim() if (!line) continue const event = parseLine(line) phase = advancePhase(phase, event) onEvent(event) if (phase === 'terminal') { await cancelReader(reader) return } } if (done) break } const finalLine = buffer.trim() if (finalLine) { const event = parseLine(finalLine) phase = advancePhase(phase, event) onEvent(event) if (phase === 'terminal') return } throw new StreamTransportError('unexpected_eof', messages.recommendationStreamUnexpectedEnd) } catch (error) { await cancelReader(reader) if (signal.aborted || error instanceof StreamTransportError) throw error throw new StreamTransportError('network', messages.recommendationStreamInterrupted) } finally { releaseReader(reader) } } /** Return whether a rejected stream operation was intentionally aborted. */ export function isAbortError(error: unknown, signal: AbortSignal): boolean { return signal.aborted || (error instanceof DOMException && error.name === 'AbortError') }