refactor(sse): consolidate client SSE readers behind a single typed primitive (#5195)

Replace four hand-rolled client SSE decode loops with two layered
primitives in lib/core/utils/sse.ts:

- readSSELines: the single byte-stream decode engine. Splits on \n,
  strips trailing \r, tolerates data: with/without a leading space,
  skips the [DONE] sentinel, honors an AbortSignal before each chunk and
  between events, and releases the reader lock only when it acquired it.
- readSSEEvents<T>: a thin JSON layer that parses each payload and routes
  unparseable lines to onParseError (default: skip).

An SSESource union accepts a Response, a ReadableStream, or an
already-acquired reader so callers that must stash the reader for
external cancellation keep ownership of the lock.

Migrates use-execution-stream, chat use-chat-streaming, home use-chat
(via readSSELines for schema-validated decode), and the workflow chat
panel. Legacy server/wand exports (encodeSSE, SSE_HEADERS,
readSSEStream) are untouched. Behavior is preserved across abort, RAF
batching, TTS, [DONE], delimiter tolerance, and reader-lock ownership.

Tests in sse.test.ts pin the prior behavior: \n and \n\n framing,
mid-chunk splits, [DONE], data: with/without leading space, \r\n
stripping, sync/async early-stop, pre-aborted and mid-stream abort,
lock release/non-release per source, lock release on a throwing
handler, and Response/stream/reader sources.
This commit is contained in:
Waleed
2026-06-24 10:46:18 -07:00
committed by GitHub
parent b52fcc094e
commit 038e8f0d84
7 changed files with 839 additions and 392 deletions
+231 -244
View File
@@ -3,6 +3,7 @@
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { readSSEEvents } from '@/lib/core/utils/sse'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
@@ -125,14 +126,12 @@ export function useChatStreaming() {
streamingOptions?.voiceSettings?.autoPlayResponses &&
streamingOptions?.audioStreamHandler
const reader = response.body?.getReader()
if (!reader) {
if (!response.body) {
setIsLoading(false)
setIsStreamingResponse(false)
return
}
const decoder = new TextDecoder()
let accumulatedText = ''
let lastAudioPosition = 0
@@ -192,264 +191,252 @@ export function useChatStreaming() {
setIsLoading(false)
let terminated = false
try {
while (true) {
// Check if aborted
if (abortControllerRef.current === null) {
break
await readSSEEvents<{
blockId?: string
chunk?: string
event?: string
error?: string
data?: {
success: boolean
error?: string | { message?: string }
output?: Record<string, Record<string, any>>
}
}>(response.body, {
signal: abortControllerRef.current.signal,
onParseError: (_data, parseError) => {
logger.error('Error parsing stream data:', parseError)
},
onEvent: async (json) => {
const { blockId, chunk: contentChunk, event: eventType } = json
const { done, value } = await reader.read()
if (done) {
flushUI()
// Stream any remaining text for TTS
if (
shouldPlayAudio &&
streamingOptions?.audioStreamHandler &&
accumulatedText.length > lastAudioPosition
) {
const remainingText = accumulatedText.substring(lastAudioPosition).trim()
if (remainingText) {
try {
await streamingOptions.audioStreamHandler(remainingText)
} catch (error) {
logger.error('TTS error for remaining text:', error)
}
}
}
break
}
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6)
if (data === '[DONE]') {
continue
}
try {
const json = JSON.parse(data)
const { blockId, chunk: contentChunk, event: eventType } = json
if (eventType === 'error' || json.event === 'error') {
const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? {
...msg,
content: errorMessage,
isStreaming: false,
type: 'assistant' as const,
}
: msg
)
)
setIsLoading(false)
return
}
if (eventType === 'final' && json.data) {
flushUI()
const finalData = json.data as {
success: boolean
error?: string | { message?: string }
output?: Record<string, Record<string, any>>
}
const outputConfigs = streamingOptions?.outputConfigs
const formattedOutputs: string[] = []
let extractedFiles: ChatFile[] = []
const formatValue = (value: any): string | null => {
if (value === null || value === undefined) {
return null
}
if (isUserFileWithMetadata(value)) {
return null
}
if (Array.isArray(value) && value.length === 0) {
return null
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'object') {
try {
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``
} catch {
return String(value)
if (eventType === 'error' || json.event === 'error') {
const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? {
...msg,
content: errorMessage,
isStreaming: false,
type: 'assistant' as const,
}
}
: msg
)
)
setIsLoading(false)
terminated = true
return true
}
if (eventType === 'final' && json.data) {
flushUI()
const finalData = json.data
const outputConfigs = streamingOptions?.outputConfigs
const formattedOutputs: string[] = []
let extractedFiles: ChatFile[] = []
const formatValue = (value: any): string | null => {
if (value === null || value === undefined) {
return null
}
if (isUserFileWithMetadata(value)) {
return null
}
if (Array.isArray(value) && value.length === 0) {
return null
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'object') {
try {
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``
} catch {
return String(value)
}
}
const getOutputValue = (blockOutputs: Record<string, any>, path?: string) => {
if (!path || path === 'content') {
if (blockOutputs.content !== undefined) return blockOutputs.content
if (blockOutputs.result !== undefined) return blockOutputs.result
return blockOutputs
return String(value)
}
const getOutputValue = (blockOutputs: Record<string, any>, path?: string) => {
if (!path || path === 'content') {
if (blockOutputs.content !== undefined) return blockOutputs.content
if (blockOutputs.result !== undefined) return blockOutputs.result
return blockOutputs
}
if (blockOutputs[path] !== undefined) {
return blockOutputs[path]
}
if (path.includes('.')) {
return path.split('.').reduce<any>((current, segment) => {
if (current && typeof current === 'object' && segment in current) {
return current[segment]
}
if (blockOutputs[path] !== undefined) {
return blockOutputs[path]
}
if (path.includes('.')) {
return path.split('.').reduce<any>((current, segment) => {
if (current && typeof current === 'object' && segment in current) {
return current[segment]
}
return undefined
}, blockOutputs)
}
return undefined
}
if (outputConfigs?.length && finalData.output) {
for (const config of outputConfigs) {
const blockOutputs = finalData.output[config.blockId]
if (!blockOutputs) continue
const value = getOutputValue(blockOutputs, config.path)
if (isUserFileWithMetadata(value)) {
extractedFiles.push({
id: value.id,
name: value.name,
url: value.url,
key: value.key,
size: value.size,
type: value.type,
context: value.context,
})
continue
}
const nestedFiles = extractFilesFromData(value)
if (nestedFiles.length > 0) {
extractedFiles = [...extractedFiles, ...nestedFiles]
continue
}
const formatted = formatValue(value)
if (formatted) {
formattedOutputs.push(formatted)
}
}
}
let finalContent = accumulatedText
if (formattedOutputs.length > 0) {
const nonEmptyOutputs = formattedOutputs.filter((output) => output.trim())
if (nonEmptyOutputs.length > 0) {
const combinedOutputs = nonEmptyOutputs.join('\n\n')
finalContent = finalContent
? `${finalContent.trim()}\n\n${combinedOutputs}`
: combinedOutputs
}
}
if (!finalContent && extractedFiles.length === 0) {
if (finalData.error) {
if (typeof finalData.error === 'string') {
finalContent = finalData.error
} else if (typeof finalData.error?.message === 'string') {
finalContent = finalData.error.message
}
} else if (finalData.success && finalData.output) {
const fallbackOutput = Object.values(finalData.output)
.map((block) => formatValue(block)?.trim())
.filter(Boolean)[0]
if (fallbackOutput) {
finalContent = fallbackOutput
}
}
}
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? {
...msg,
isStreaming: false,
content: finalContent ?? msg.content,
files: extractedFiles.length > 0 ? extractedFiles : undefined,
}
: msg
)
)
accumulatedTextRef.current = ''
lastStreamedPositionRef.current = 0
lastDisplayedPositionRef.current = 0
audioStreamingActiveRef.current = false
return
}, blockOutputs)
}
if (blockId && contentChunk) {
if (!messageIdMap.has(blockId)) {
messageIdMap.set(blockId, messageId)
return undefined
}
if (outputConfigs?.length && finalData.output) {
for (const config of outputConfigs) {
const blockOutputs = finalData.output[config.blockId]
if (!blockOutputs) continue
const value = getOutputValue(blockOutputs, config.path)
if (isUserFileWithMetadata(value)) {
extractedFiles.push({
id: value.id,
name: value.name,
url: value.url,
key: value.key,
size: value.size,
type: value.type,
context: value.context,
})
continue
}
accumulatedText += contentChunk
accumulatedTextRef.current = accumulatedText
logger.debug('[useChatStreaming] Received chunk', {
blockId,
chunkLength: contentChunk.length,
totalLength: accumulatedText.length,
messageId,
chunk: contentChunk.substring(0, 20),
})
uiDirty = true
scheduleUIFlush()
const nestedFiles = extractFilesFromData(value)
if (nestedFiles.length > 0) {
extractedFiles = [...extractedFiles, ...nestedFiles]
continue
}
// Real-time TTS for voice mode
if (shouldPlayAudio && streamingOptions?.audioStreamHandler) {
const newText = accumulatedText.substring(lastAudioPosition)
const sentenceEndings = ['. ', '! ', '? ', '.\n', '!\n', '?\n', '.', '!', '?']
let sentenceEnd = -1
for (const ending of sentenceEndings) {
const index = newText.indexOf(ending)
if (index > 0) {
sentenceEnd = index + ending.length
break
}
}
if (sentenceEnd > 0) {
const sentence = newText.substring(0, sentenceEnd).trim()
if (sentence && sentence.length >= 3) {
try {
await streamingOptions.audioStreamHandler(sentence)
lastAudioPosition += sentenceEnd
} catch (error) {
logger.error('TTS error:', error)
}
}
}
const formatted = formatValue(value)
if (formatted) {
formattedOutputs.push(formatted)
}
} else if (blockId && eventType === 'end') {
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
)
}
} catch (parseError) {
logger.error('Error parsing stream data:', parseError)
}
let finalContent = accumulatedText
if (formattedOutputs.length > 0) {
const nonEmptyOutputs = formattedOutputs.filter((output) => output.trim())
if (nonEmptyOutputs.length > 0) {
const combinedOutputs = nonEmptyOutputs.join('\n\n')
finalContent = finalContent
? `${finalContent.trim()}\n\n${combinedOutputs}`
: combinedOutputs
}
}
if (!finalContent && extractedFiles.length === 0) {
if (finalData.error) {
if (typeof finalData.error === 'string') {
finalContent = finalData.error
} else if (typeof finalData.error?.message === 'string') {
finalContent = finalData.error.message
}
} else if (finalData.success && finalData.output) {
const fallbackOutput = Object.values(finalData.output)
.map((block) => formatValue(block)?.trim())
.filter(Boolean)[0]
if (fallbackOutput) {
finalContent = fallbackOutput
}
}
}
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? {
...msg,
isStreaming: false,
content: finalContent ?? msg.content,
files: extractedFiles.length > 0 ? extractedFiles : undefined,
}
: msg
)
)
accumulatedTextRef.current = ''
lastStreamedPositionRef.current = 0
lastDisplayedPositionRef.current = 0
audioStreamingActiveRef.current = false
terminated = true
return true
}
if (blockId && contentChunk) {
if (!messageIdMap.has(blockId)) {
messageIdMap.set(blockId, messageId)
}
accumulatedText += contentChunk
accumulatedTextRef.current = accumulatedText
logger.debug('[useChatStreaming] Received chunk', {
blockId,
chunkLength: contentChunk.length,
totalLength: accumulatedText.length,
messageId,
chunk: contentChunk.substring(0, 20),
})
uiDirty = true
scheduleUIFlush()
if (shouldPlayAudio && streamingOptions?.audioStreamHandler) {
const newText = accumulatedText.substring(lastAudioPosition)
const sentenceEndings = ['. ', '! ', '? ', '.\n', '!\n', '?\n', '.', '!', '?']
let sentenceEnd = -1
for (const ending of sentenceEndings) {
const index = newText.indexOf(ending)
if (index > 0) {
sentenceEnd = index + ending.length
break
}
}
if (sentenceEnd > 0) {
const sentence = newText.substring(0, sentenceEnd).trim()
if (sentence && sentence.length >= 3) {
try {
await streamingOptions.audioStreamHandler(sentence)
lastAudioPosition += sentenceEnd
} catch (error) {
logger.error('TTS error:', error)
}
}
}
}
} else if (blockId && eventType === 'end') {
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
)
}
},
})
if (!terminated) {
flushUI()
if (
shouldPlayAudio &&
streamingOptions?.audioStreamHandler &&
accumulatedText.length > lastAudioPosition
) {
const remainingText = accumulatedText.substring(lastAudioPosition).trim()
if (remainingText) {
try {
await streamingOptions.audioStreamHandler(remainingText)
} catch (error) {
logger.error('TTS error for remaining text:', error)
}
}
}
@@ -63,6 +63,7 @@ import {
} from '@/lib/copilot/tools/client/run-tool-execution'
import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context'
import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
import { readSSELines } from '@/lib/core/utils/sse'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview'
import {
@@ -1934,7 +1935,6 @@ export function useChat(
shouldContinue?: () => boolean
}
) => {
const decoder = new TextDecoder()
const ctx = createStreamLoopContext({
workspaceId,
queryClient,
@@ -1987,71 +1987,47 @@ export function useChat(
return { sawStreamError: false, sawComplete: false }
}
streamReaderRef.current = reader
let buffer = ''
try {
const pendingLines: string[] = []
await readSSELines(reader, {
onData: (raw) => {
if (state.sawCompleteEvent) return true
if (ops.isStale()) return
while (true) {
if (pendingLines.length === 0) {
// Don't read another chunk after `complete` has drained.
if (state.sawCompleteEvent) break
const { done, value } = await reader.read()
if (done) break
if (ops.isStale()) continue
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
pendingLines.push(...lines)
if (pendingLines.length === 0) {
continue
const parsedResult = parsePersistedStreamEventEnvelopeJson(raw)
if (!parsedResult.ok) {
const error = createStreamSchemaValidationError(parsedResult, 'Live SSE event.')
logger.error('Rejected chat SSE event due to client-side schema enforcement', {
reason: parsedResult.reason,
message: parsedResult.message,
errors: parsedResult.errors,
error: error.message,
})
throw error
}
}
const parsed = parsedResult.event
const line = pendingLines.shift()
if (line === undefined) {
continue
}
if (ops.isStale()) {
pendingLines.length = 0
continue
}
if (!line.startsWith('data: ')) continue
const raw = line.slice(6)
if (parsed.trace?.requestId && parsed.trace.requestId !== state.streamRequestId) {
state.streamRequestId = parsed.trace.requestId
streamRequestIdRef.current = state.streamRequestId
ops.flush()
}
if (parsed.stream?.streamId) {
streamIdRef.current = parsed.stream.streamId
}
const eventCursor = parsed.stream?.cursor ?? String(parsed.seq)
if (isAlreadyProcessedStreamCursor(eventCursor, lastCursorRef.current)) {
return
}
if (eventCursor) {
lastCursorRef.current = eventCursor
}
const parsedResult = parsePersistedStreamEventEnvelopeJson(raw)
if (!parsedResult.ok) {
const error = createStreamSchemaValidationError(parsedResult, 'Live SSE event.')
logger.error('Rejected chat SSE event due to client-side schema enforcement', {
reason: parsedResult.reason,
message: parsedResult.message,
errors: parsedResult.errors,
error: error.message,
})
throw error
}
const parsed = parsedResult.event
if (parsed.trace?.requestId && parsed.trace.requestId !== state.streamRequestId) {
state.streamRequestId = parsed.trace.requestId
streamRequestIdRef.current = state.streamRequestId
ops.flush()
}
if (parsed.stream?.streamId) {
streamIdRef.current = parsed.stream.streamId
}
const eventCursor = parsed.stream?.cursor ?? String(parsed.seq)
if (isAlreadyProcessedStreamCursor(eventCursor, lastCursorRef.current)) {
continue
}
if (eventCursor) {
lastCursorRef.current = eventCursor
}
logger.debug('SSE event received', parsed)
dispatchStreamEvent(ctx, parsed)
}
logger.debug('SSE event received', parsed)
dispatchStreamEvent(ctx, parsed)
if (state.sawCompleteEvent) return true
},
})
} finally {
if (state.sawStreamError && !state.sawCompleteEvent) {
applyTurnTerminal(state.model, 'error')
@@ -25,6 +25,7 @@ import {
extractPathFromOutputId,
parseOutputContentSafely,
} from '@/lib/core/utils/response-format'
import { readSSEEvents } from '@/lib/core/utils/sse'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
@@ -520,12 +521,10 @@ export function Chat() {
* @param responseMessageId - ID of the message to update with streamed content
*/
const processStreamingResponse = useCallback(
async (stream: ReadableStream, responseMessageId: string) => {
async (stream: ReadableStream<Uint8Array>, responseMessageId: string) => {
const reader = stream.getReader()
streamReaderRef.current = reader
const decoder = new TextDecoder()
let accumulatedContent = ''
let buffer = ''
const BATCH_MAX_MS = 50
let pendingChunks = ''
@@ -563,63 +562,34 @@ export function Chat() {
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
flushChunks()
finalizeMessageStream(responseMessageId)
break
}
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
const separatorIndex = buffer.lastIndexOf('\n\n')
if (separatorIndex === -1) {
continue
}
const processable = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
const lines = processable.split('\n\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.substring(6)
if (data === '[DONE]') continue
try {
const json = JSON.parse(data)
const { event, data: eventData, chunk: contentChunk } = json
if (event === 'final' && eventData) {
const result = eventData as ExecutionResult
if ('success' in result && !result.success) {
const errorMessage = result.error || 'Workflow execution failed'
flushChunks()
appendMessageContent(
responseMessageId,
`${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}`
)
finalizeMessageStream(responseMessageId)
return
}
await readSSEEvents<{ event?: string; data?: ExecutionResult; chunk?: string }>(reader, {
onParseError: (_data, e) => {
logger.error('Error parsing stream data:', e)
},
onEvent: (json) => {
const { event, data: eventData, chunk: contentChunk } = json
if (event === 'final' && eventData) {
if ('success' in eventData && !eventData.success) {
const errorMessage = eventData.error || 'Workflow execution failed'
flushChunks()
finalizeMessageStream(responseMessageId)
} else if (contentChunk) {
accumulatedContent += contentChunk
pendingChunks += contentChunk
scheduleFlush()
appendMessageContent(
responseMessageId,
`${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}`
)
}
} catch (e) {
logger.error('Error parsing stream data:', e)
return true
}
}
}
if (contentChunk) {
accumulatedContent += contentChunk
pendingChunks += contentChunk
scheduleFlush()
}
},
})
flushChunks()
finalizeMessageStream(responseMessageId)
} catch (error) {
if ((error as Error)?.name !== 'AbortError') {
logger.error('Error processing stream:', error)
@@ -84,4 +84,46 @@ describe('processSSEStream', () => {
expect(onEventId).not.toHaveBeenCalled()
})
it('releases the reader lock after the stream completes', async () => {
const stream = streamEvents([])
const reader = stream.getReader()
expect(stream.locked).toBe(true)
await processSSEStream(reader, {}, 'test')
expect(stream.locked).toBe(false)
})
it('releases the reader lock even when a handler throws', async () => {
const event: ExecutionEvent = {
type: 'block:started',
eventId: 7,
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
blockId: 'block-1',
blockName: 'Block 1',
blockType: 'function',
executionOrder: 1,
},
}
const stream = streamEvents([event])
const reader = stream.getReader()
await expect(
processSSEStream(
reader,
{
onBlockStarted: () => {
throw new Error('boom')
},
},
'test'
)
).rejects.toThrow('boom')
expect(stream.locked).toBe(false)
})
})
+9 -31
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { readSSEEvents } from '@/lib/core/utils/sse'
import type {
BlockChildWorkflowStartedData,
BlockCompletedData,
@@ -82,36 +83,12 @@ export async function processSSEStream(
callbacks: ExecutionStreamCallbacks,
logPrefix: string
): Promise<void> {
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue
const data = line.substring(6).trim()
if (data === '[DONE]') {
logger.info(`${logPrefix} stream completed`)
continue
}
let event: ExecutionEvent
try {
event = JSON.parse(data) as ExecutionEvent
} catch (error) {
logger.error('Failed to parse SSE event:', error, { data })
continue
}
await readSSEEvents<ExecutionEvent>(reader, {
onParseError: (data, error) => {
logger.error('Failed to parse SSE event:', error, { data })
},
onEvent: async (event) => {
try {
switch (event.type) {
case 'execution:started':
@@ -168,8 +145,9 @@ export async function processSSEStream(
error
)
}
}
}
},
})
logger.debug(`${logPrefix} stream completed`)
} finally {
reader.releaseLock()
}
+320 -1
View File
@@ -2,7 +2,13 @@
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { encodeSSE, readSSEStream, SSE_HEADERS } from '@/lib/core/utils/sse'
import {
encodeSSE,
readSSEEvents,
readSSELines,
readSSEStream,
SSE_HEADERS,
} from '@/lib/core/utils/sse'
function createStreamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let index = 0
@@ -311,3 +317,316 @@ describe('readSSEStream', () => {
})
})
})
function streamFromStringChunks(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return createStreamFromChunks(chunks.map((c) => encoder.encode(c)))
}
describe('readSSEEvents', () => {
it('parses `\\n\\n`-framed events', async () => {
const stream = streamFromStringChunks([
'data: {"n":1}\n\n',
'data: {"n":2}\n\n',
'data: {"n":3}\n\n',
])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([1, 2, 3])
})
it('parses `\\n`-framed events', async () => {
const stream = streamFromStringChunks(['data: {"n":1}\ndata: {"n":2}\ndata: {"n":3}\n'])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([1, 2, 3])
})
it('reassembles events split across chunk boundaries', async () => {
const stream = streamFromStringChunks(['data: {"ms', 'g":"hel', 'lo"}\n\n'])
const events: Array<{ msg: string }> = []
await readSSEEvents<{ msg: string }>(stream, {
onEvent: (e) => {
events.push(e)
},
})
expect(events).toEqual([{ msg: 'hello' }])
})
it('skips the [DONE] sentinel', async () => {
const stream = streamFromStringChunks(['data: {"n":1}\n\n', 'data: [DONE]\n\n'])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([1])
})
it('accepts `data:` with and without a leading space', async () => {
const stream = streamFromStringChunks(['data:{"n":1}\n\n', 'data: {"n":2}\n\n'])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([1, 2])
})
it('strips trailing carriage returns (\\r\\n framing)', async () => {
const stream = streamFromStringChunks(['data: {"n":1}\r\n\r\n', 'data: {"n":2}\r\n\r\n'])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([1, 2])
})
it('routes unparseable payloads to onParseError and continues', async () => {
const stream = streamFromStringChunks(['data: not-json\n\n', 'data: {"n":2}\n\n'])
const events: number[] = []
const onParseError = vi.fn()
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
onParseError,
})
expect(events).toEqual([2])
expect(onParseError).toHaveBeenCalledTimes(1)
expect(onParseError).toHaveBeenCalledWith('not-json', expect.any(Error))
})
it('stops early when onEvent returns true', async () => {
const stream = streamFromStringChunks([
'data: {"n":1}\n\n',
'data: {"n":2}\n\n',
'data: {"n":3}\n\n',
])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
return e.n === 2
},
})
expect(events).toEqual([1, 2])
})
it('does not process events once the signal is aborted', async () => {
const controller = new AbortController()
const stream = streamFromStringChunks(['data: {"n":1}\n\n', 'data: {"n":2}\n\n'])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
signal: controller.signal,
onEvent: (e) => {
events.push(e.n)
controller.abort()
},
})
expect(events).toEqual([1])
})
it('returns immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const stream = streamFromStringChunks(['data: {"n":1}\n\n'])
const onEvent = vi.fn()
await readSSEEvents(stream, { signal: controller.signal, onEvent })
expect(onEvent).not.toHaveBeenCalled()
})
it('releases the reader lock for a stream source', async () => {
const stream = streamFromStringChunks(['data: {"n":1}\n\n'])
await readSSEEvents<{ n: number }>(stream, { onEvent: () => {} })
expect(() => stream.getReader()).not.toThrow()
})
it('does not release the lock for a reader source', async () => {
const stream = streamFromStringChunks(['data: {"n":1}\n\n'])
const reader = stream.getReader()
await readSSEEvents<{ n: number }>(reader, { onEvent: () => {} })
expect(() => stream.getReader()).toThrow()
reader.releaseLock()
})
it('accepts a Response source', async () => {
const response = new Response(streamFromStringChunks(['data: {"n":7}\n\n']))
const events: number[] = []
await readSSEEvents<{ n: number }>(response, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([7])
})
it('silently skips unparseable payloads when no onParseError is provided', async () => {
const stream = streamFromStringChunks(['data: not-json\n\n', 'data: {"n":2}\n\n'])
const events: number[] = []
await expect(
readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
).resolves.toBeUndefined()
expect(events).toEqual([2])
})
it('surfaces a fatal parse error when onParseError throws', async () => {
const stream = streamFromStringChunks(['data: not-json\n\n', 'data: {"n":2}\n\n'])
const events: number[] = []
await expect(
readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
onParseError: () => {
throw new Error('boom')
},
})
).rejects.toThrow('boom')
expect(events).toEqual([])
})
it('stops early when onEvent resolves true asynchronously', async () => {
const stream = streamFromStringChunks([
'data: {"n":1}\n\n',
'data: {"n":2}\n\n',
'data: {"n":3}\n\n',
])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: async (e) => {
events.push(e.n)
return e.n === 2
},
})
expect(events).toEqual([1, 2])
})
it('throws "No response body" for a Response without a body', async () => {
const response = new Response(null)
await expect(readSSEEvents(response, { onEvent: () => {} })).rejects.toThrow('No response body')
})
})
describe('readSSELines', () => {
it('delivers raw (un-parsed) data payloads', async () => {
const stream = streamFromStringChunks(['data: raw-one\n\n', 'data: {"keep":"asString"}\n\n'])
const lines: string[] = []
await readSSELines(stream, {
onData: (raw) => {
lines.push(raw)
},
})
expect(lines).toEqual(['raw-one', '{"keep":"asString"}'])
})
it('skips [DONE] and blank separator lines', async () => {
const stream = streamFromStringChunks(['data: a\n\ndata: b\n\ndata: [DONE]\n\n'])
const lines: string[] = []
await readSSELines(stream, {
onData: (raw) => {
lines.push(raw)
},
})
expect(lines).toEqual(['a', 'b'])
})
it('preserves the raw payload verbatim (no JSON parsing)', async () => {
const stream = streamFromStringChunks(['data: {"unterminated\n\n', 'data:no-space\n\n'])
const lines: string[] = []
await readSSELines(stream, {
onData: (raw) => {
lines.push(raw)
},
})
expect(lines).toEqual(['{"unterminated', 'no-space'])
})
it('strips a trailing carriage return from each line', async () => {
const stream = streamFromStringChunks(['data: one\r\n\r\ndata: two\r\n\r\n'])
const lines: string[] = []
await readSSELines(stream, {
onData: (raw) => {
lines.push(raw)
},
})
expect(lines).toEqual(['one', 'two'])
})
it('stops early when onData returns true', async () => {
const stream = streamFromStringChunks(['data: a\n\ndata: b\n\ndata: c\n\n'])
const lines: string[] = []
await readSSELines(stream, {
onData: (raw) => {
lines.push(raw)
return raw === 'b'
},
})
expect(lines).toEqual(['a', 'b'])
})
it('does not deliver any line when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const stream = streamFromStringChunks(['data: a\n\n'])
const onData = vi.fn()
await readSSELines(stream, { signal: controller.signal, onData })
expect(onData).not.toHaveBeenCalled()
})
it('stops between events in the same chunk once aborted mid-stream', async () => {
const controller = new AbortController()
const stream = streamFromStringChunks(['data: a\n\ndata: b\n\ndata: c\n\n'])
const lines: string[] = []
await readSSELines(stream, {
signal: controller.signal,
onData: (raw) => {
lines.push(raw)
if (raw === 'a') controller.abort()
},
})
expect(lines).toEqual(['a'])
})
it('releases the lock for a stream source', async () => {
const stream = streamFromStringChunks(['data: a\n\n'])
await readSSELines(stream, { onData: () => {} })
expect(() => stream.getReader()).not.toThrow()
})
it('does not release the lock for a reader source', async () => {
const stream = streamFromStringChunks(['data: a\n\n'])
const reader = stream.getReader()
await readSSELines(reader, { onData: () => {} })
expect(() => stream.getReader()).toThrow()
reader.releaseLock()
})
it('releases the lock for a stream source even when onData throws', async () => {
const stream = streamFromStringChunks(['data: a\n\n'])
await expect(
readSSELines(stream, {
onData: () => {
throw new Error('handler failed')
},
})
).rejects.toThrow('handler failed')
expect(() => stream.getReader()).not.toThrow()
})
})
+175
View File
@@ -20,6 +20,181 @@ export function encodeSSE(data: any): Uint8Array {
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
}
/**
* The sentinel value servers emit to signal end-of-stream. Lines carrying this
* payload are skipped before reaching the consumer's `onEvent` callback.
*/
const DONE_SENTINEL = '[DONE]'
/**
* A source the SSE reader can consume: a fetch `Response`, its `ReadableStream`
* body, or an already-acquired reader. Passing a `Response`/stream lets the
* primitive own `getReader()` and the reader lifecycle (lock release); passing a
* reader is supported for callers that must acquire it first (e.g. to stash it
* for external cancellation).
*/
export type SSESource =
| Response
| ReadableStream<Uint8Array>
| ReadableStreamDefaultReader<Uint8Array>
/**
* The result of an SSE event/line callback. Only the literal `true` (returned
* synchronously or resolved from a `Promise`) stops processing and returns
* early — useful for terminal events. Any other value (including the
* `undefined` a handler that returns nothing produces) keeps processing.
*
* Typed as `unknown` rather than `boolean | void | Promise<boolean | void>` so
* both sync and `async` handlers — including `async` handlers that return
* nothing (`Promise<void>`) — stay assignable, without the confusing
* `void`-inside-a-`Promise` union that the precise type would require.
*/
export type SSEStopSignal = unknown
/**
* Options for {@link readSSELines} — the low-level line engine that delivers the
* raw `data:` payload string (no JSON parsing).
*/
export interface ReadSSELinesOptions {
/** Invoked once per SSE `data:` line with the raw (un-parsed) payload string. */
onData: (rawData: string) => SSEStopSignal
/** Aborts the read; checked before each chunk and between events. */
signal?: AbortSignal
}
/**
* Options for {@link readSSEEvents} — the JSON convenience layer over
* {@link readSSELines}.
*/
export interface ReadSSEEventsOptions<T> {
/**
* Invoked once per parsed SSE `data:` event with the JSON-parsed payload.
* Return (or resolve) `true` to stop processing and return early. Callers
* narrow the typed payload.
*/
onEvent: (event: T) => SSEStopSignal
/**
* Invoked for a `data:` line whose payload is not valid JSON. Defaults to
* silently skipping the line. Throw from here to surface a fatal parse error.
*/
onParseError?: (rawData: string, error: unknown) => void
/** Aborts the read; checked before each chunk and between events. */
signal?: AbortSignal
}
/**
* Resolves an {@link SSESource} to a reader, reporting whether this call
* acquired the lock (and is therefore responsible for releasing it).
*/
function toReader(source: SSESource): {
reader: ReadableStreamDefaultReader<Uint8Array>
ownsLock: boolean
} {
if (source instanceof ReadableStream) {
return { reader: source.getReader(), ownsLock: true }
}
if (source instanceof Response) {
if (!source.body) throw new Error('No response body')
return { reader: source.body.getReader(), ownsLock: true }
}
return { reader: source, ownsLock: false }
}
/**
* Strips an optional trailing carriage return from a single SSE line, so both
* `\n`- and `\r\n`-terminated framings parse identically.
*/
function stripCarriageReturn(line: string): string {
return line.endsWith('\r') ? line.slice(0, -1) : line
}
/**
* The single client-side SSE decode engine. Reads a byte stream, decodes it
* incrementally, splits it into lines, and invokes `onData` once per `data:`
* line with its raw (un-parsed) payload string.
*
* It splits on `\n` and processes each `data:` line individually, which makes it
* tolerant of BOTH `\n`- and `\n\n`-separated framings (the blank separator
* lines between events are simply ignored). Trailing `\r` is stripped, a single
* optional space after `data:` is consumed, and the `[DONE]` sentinel is
* skipped. The reader's lock is always released on completion, abort, or error
* (only when this function acquired it).
*
* This is the low-level engine. Most callers want {@link readSSEEvents}, which
* adds JSON parsing on top. Reach for `readSSELines` only when the payload needs
* custom parsing (e.g. schema-validated decoding).
*
* @param source - A `Response`, `ReadableStream`, or stream reader.
* @param options - The `onData` callback plus an optional `signal`.
*/
export async function readSSELines(source: SSESource, options: ReadSSELinesOptions): Promise<void> {
const { onData, signal } = options
const { reader, ownsLock } = toReader(source)
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) break
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const rawLine of lines) {
if (signal?.aborted) return
const line = stripCarriageReturn(rawLine)
if (!line.startsWith('data:')) continue
let data = line.slice(5)
if (data.startsWith(' ')) data = data.slice(1)
if (data === DONE_SENTINEL) continue
if ((await onData(data)) === true) return
}
}
} finally {
if (ownsLock) reader.releaseLock()
}
}
/**
* The JSON convenience layer over {@link readSSELines}: invokes `onEvent` once
* per `data:` event with its JSON-parsed payload. Unparseable lines are passed
* to `onParseError` (default: silently skipped). All framing, `\r`, `[DONE]`,
* abort, and reader-lifecycle behavior is inherited from `readSSELines`.
*
* Higher-level concerns — UI batching, reconnect, error classification, event
* dispatch — belong in the caller's `onEvent`, not here.
*
* @typeParam T - The parsed event type the caller expects (defaults to `unknown`).
* @param source - A `Response`, `ReadableStream`, or stream reader.
* @param options - The `onEvent` callback plus optional `signal`/`onParseError`.
*/
export async function readSSEEvents<T = unknown>(
source: SSESource,
options: ReadSSEEventsOptions<T>
): Promise<void> {
const { onEvent, onParseError, signal } = options
await readSSELines(source, {
signal,
onData: (data) => {
let parsed: T
try {
parsed = JSON.parse(data) as T
} catch (error) {
onParseError?.(data, error)
return
}
return onEvent(parsed)
},
})
}
/**
* Options for reading SSE stream
*/