feat(frontend): add session and playlist API client
This commit is contained in:
parent
47cbeac87a
commit
96c69507ec
7 changed files with 837 additions and 279 deletions
114
frontend/src/lib/recommendationStream.ts
Normal file
114
frontend/src/lib/recommendationStream.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { RecommendationRequest, StreamEvent } from './types'
|
||||
import type { TransportFailureKind } from './models'
|
||||
import { parseStreamEvent, StreamParseError } from './streamParser'
|
||||
|
||||
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', 'The response contained invalid JSON.')
|
||||
}
|
||||
|
||||
try {
|
||||
return parseStreamEvent(value)
|
||||
} catch (error) {
|
||||
if (error instanceof StreamParseError) {
|
||||
throw new StreamTransportError('parse', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Post a recommendation request and consume its NDJSON event stream. */
|
||||
export async function streamRecommendations(
|
||||
request: RecommendationRequest,
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: StreamEvent) => void,
|
||||
): Promise<void> {
|
||||
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', 'The recommendation service could not be reached.')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StreamTransportError(
|
||||
'http',
|
||||
`The recommendation service returned ${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.')
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new StreamTransportError('protocol', 'The response stream was empty.')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
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)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') {
|
||||
await reader.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break
|
||||
}
|
||||
|
||||
const finalLine = buffer.trim()
|
||||
if (finalLine) {
|
||||
const event = parseLine(finalLine)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') 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.',
|
||||
)
|
||||
}
|
||||
|
||||
/** 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')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue