fix(frontend): harden the streaming ui and extract copy and constants
This commit is contained in:
parent
036ef3cc6f
commit
a3af8f45cc
36 changed files with 1662 additions and 226 deletions
|
|
@ -1,7 +1,10 @@
|
|||
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
|
||||
|
||||
|
|
@ -17,7 +20,7 @@ function parseLine(line: string): StreamEvent {
|
|||
try {
|
||||
value = JSON.parse(line)
|
||||
} catch {
|
||||
throw new StreamTransportError('parse', 'The response contained invalid JSON.')
|
||||
throw new StreamTransportError('parse', messages.recommendationStreamInvalidJson)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -30,6 +33,43 @@ function parseLine(line: string): StreamEvent {
|
|||
}
|
||||
}
|
||||
|
||||
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
||||
if (phase === 'metadata') {
|
||||
if (event.type === 'metadata') return 'events'
|
||||
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<Uint8Array> | null): Promise<void> {
|
||||
try {
|
||||
await body?.cancel()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function releaseReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Post a recommendation request and consume its NDJSON event stream. */
|
||||
export async function streamRecommendations(
|
||||
request: RecommendationRequest,
|
||||
|
|
@ -47,26 +87,29 @@ export async function streamRecommendations(
|
|||
})
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error
|
||||
throw new StreamTransportError('network', 'The recommendation service could not be reached.')
|
||||
throw new StreamTransportError('network', messages.recommendationStreamUnavailable)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
await cancelBody(response.body)
|
||||
throw new StreamTransportError(
|
||||
'http',
|
||||
`The recommendation service returned ${response.status}.`,
|
||||
formatMessage('recommendationStreamHttpFailure', { status: response.status }),
|
||||
)
|
||||
}
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0].trim()
|
||||
if (contentType !== 'application/x-ndjson') {
|
||||
throw new StreamTransportError('protocol', 'The response was not an NDJSON stream.')
|
||||
await cancelBody(response.body)
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamInvalidContentType)
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new StreamTransportError('protocol', 'The response stream was empty.')
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamEmpty)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let phase: StreamPhase = 'metadata'
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
|
@ -79,9 +122,10 @@ export async function streamRecommendations(
|
|||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const event = parseLine(line)
|
||||
phase = advancePhase(phase, event)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') {
|
||||
await reader.cancel()
|
||||
if (phase === 'terminal') {
|
||||
await cancelReader(reader)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -92,20 +136,19 @@ export async function streamRecommendations(
|
|||
const finalLine = buffer.trim()
|
||||
if (finalLine) {
|
||||
const event = parseLine(finalLine)
|
||||
phase = advancePhase(phase, event)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') return
|
||||
if (phase === 'terminal') return
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted || error instanceof StreamTransportError) throw error
|
||||
throw new StreamTransportError('network', 'The recommendation stream was interrupted.')
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
throw new StreamTransportError(
|
||||
'unexpected_eof',
|
||||
'The recommendation stream ended before a final event arrived.',
|
||||
)
|
||||
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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue