Files
simstudioai--sim/apps/sim/lib/core/utils/sse.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

659 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import {
encodeSSE,
readSSEEvents,
readSSELines,
readSSEStream,
SSE_HEADERS,
} from '@/lib/core/utils/sse'
function createStreamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let index = 0
return new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(chunks[index])
index++
} else {
controller.close()
}
},
})
}
function createSSEChunk(data: object): Uint8Array {
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
}
describe('SSE_HEADERS', () => {
it.concurrent('should have correct Content-Type', () => {
expect(SSE_HEADERS['Content-Type']).toBe('text/event-stream')
})
it.concurrent('should have correct Cache-Control', () => {
expect(SSE_HEADERS['Cache-Control']).toBe('no-cache')
})
it.concurrent('should have Connection keep-alive', () => {
expect(SSE_HEADERS.Connection).toBe('keep-alive')
})
it.concurrent('should disable buffering', () => {
expect(SSE_HEADERS['X-Accel-Buffering']).toBe('no')
})
})
describe('encodeSSE', () => {
it.concurrent('should encode data as SSE format', () => {
const data = { chunk: 'hello' }
const result = encodeSSE(data)
const decoded = new TextDecoder().decode(result)
expect(decoded).toBe('data: {"chunk":"hello"}\n\n')
})
it.concurrent('should handle complex objects', () => {
const data = { chunk: 'test', nested: { value: 123 } }
const result = encodeSSE(data)
const decoded = new TextDecoder().decode(result)
expect(decoded).toBe('data: {"chunk":"test","nested":{"value":123}}\n\n')
})
it.concurrent('should handle strings with special characters', () => {
const data = { chunk: 'Hello, 世界! 🌍' }
const result = encodeSSE(data)
const decoded = new TextDecoder().decode(result)
expect(decoded).toContain('Hello, 世界! 🌍')
})
})
describe('readSSEStream', () => {
it.concurrent('should accumulate content from chunks', async () => {
const chunks = [
createSSEChunk({ chunk: 'Hello' }),
createSSEChunk({ chunk: ' World' }),
createSSEChunk({ done: true }),
]
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream)
expect(result).toBe('Hello World')
})
it.concurrent('should call onChunk callback for each chunk', async () => {
const onChunk = vi.fn()
const chunks = [createSSEChunk({ chunk: 'A' }), createSSEChunk({ chunk: 'B' })]
const stream = createStreamFromChunks(chunks)
await readSSEStream(stream, { onChunk })
expect(onChunk).toHaveBeenCalledTimes(2)
expect(onChunk).toHaveBeenNthCalledWith(1, 'A')
expect(onChunk).toHaveBeenNthCalledWith(2, 'B')
})
it.concurrent('should call onAccumulated callback with accumulated content', async () => {
const onAccumulated = vi.fn()
const chunks = [createSSEChunk({ chunk: 'A' }), createSSEChunk({ chunk: 'B' })]
const stream = createStreamFromChunks(chunks)
await readSSEStream(stream, { onAccumulated })
expect(onAccumulated).toHaveBeenCalledTimes(2)
expect(onAccumulated).toHaveBeenNthCalledWith(1, 'A')
expect(onAccumulated).toHaveBeenNthCalledWith(2, 'AB')
})
it.concurrent('should skip [DONE] messages', async () => {
const encoder = new TextEncoder()
const chunks = [createSSEChunk({ chunk: 'content' }), encoder.encode('data: [DONE]\n\n')]
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream)
expect(result).toBe('content')
})
it.concurrent('should skip lines with error field', async () => {
const chunks = [
createSSEChunk({ error: 'Something went wrong' }),
createSSEChunk({ chunk: 'valid content' }),
]
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream)
expect(result).toBe('valid content')
})
it.concurrent('should handle abort signal', async () => {
const controller = new AbortController()
controller.abort()
const chunks = [createSSEChunk({ chunk: 'content' })]
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream, { signal: controller.signal })
expect(result).toBe('')
})
it.concurrent('should skip unparseable lines', async () => {
const encoder = new TextEncoder()
const chunks = [encoder.encode('data: invalid-json\n\n'), createSSEChunk({ chunk: 'valid' })]
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream)
expect(result).toBe('valid')
})
describe('multi-byte UTF-8 character handling', () => {
it.concurrent('should handle Turkish characters split across chunks', async () => {
const text = 'Merhaba dünya! Öğretmen şarkı söyledi.'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const splitPoint = Math.floor(bytes.length / 2)
const chunk1 = bytes.slice(0, splitPoint)
const chunk2 = bytes.slice(splitPoint)
const stream = createStreamFromChunks([chunk1, chunk2])
const result = await readSSEStream(stream)
expect(result).toBe(text)
})
it.concurrent('should handle emoji split across chunks', async () => {
const text = 'Hello 🚀 World 🌍 Test 🎯'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const emojiIndex = fullData.indexOf('🚀')
const byteOffset = new TextEncoder().encode(fullData.slice(0, emojiIndex)).length
const splitPoint = byteOffset + 2
const chunk1 = bytes.slice(0, splitPoint)
const chunk2 = bytes.slice(splitPoint)
const stream = createStreamFromChunks([chunk1, chunk2])
const result = await readSSEStream(stream)
expect(result).toBe(text)
})
it.concurrent('should handle CJK characters split across chunks', async () => {
const text = '你好世界!日本語テスト。한국어도 됩니다.'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const third = Math.floor(bytes.length / 3)
const chunk1 = bytes.slice(0, third)
const chunk2 = bytes.slice(third, third * 2)
const chunk3 = bytes.slice(third * 2)
const stream = createStreamFromChunks([chunk1, chunk2, chunk3])
const result = await readSSEStream(stream)
expect(result).toBe(text)
})
it.concurrent('should handle mixed multi-byte content split at byte boundaries', async () => {
const text = 'Ö is Turkish, 中 is Chinese, 🎉 is emoji'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const chunks: Uint8Array[] = []
for (let i = 0; i < bytes.length; i += 3) {
chunks.push(bytes.slice(i, Math.min(i + 3, bytes.length)))
}
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream)
expect(result).toBe(text)
})
it.concurrent('should handle SSE message split across chunks', async () => {
const encoder = new TextEncoder()
const message1 = { chunk: 'First' }
const message2 = { chunk: 'Second' }
const fullText = `data: ${JSON.stringify(message1)}\n\ndata: ${JSON.stringify(message2)}\n\n`
const bytes = encoder.encode(fullText)
const delimiterIndex = fullText.indexOf('\n\n') + 1
const byteOffset = encoder.encode(fullText.slice(0, delimiterIndex)).length
const chunk1 = bytes.slice(0, byteOffset)
const chunk2 = bytes.slice(byteOffset)
const stream = createStreamFromChunks([chunk1, chunk2])
const result = await readSSEStream(stream)
expect(result).toBe('FirstSecond')
})
it.concurrent('should handle 2-byte UTF-8 character (Ö) split at byte boundary', async () => {
const text = 'AÖB'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const textStart = fullData.indexOf('"') + 1 + text.indexOf('Ö')
const byteOffset = new TextEncoder().encode(fullData.slice(0, textStart)).length
const chunk1 = bytes.slice(0, byteOffset + 1)
const chunk2 = bytes.slice(byteOffset + 1)
const stream = createStreamFromChunks([chunk1, chunk2])
const result = await readSSEStream(stream)
expect(result).toBe(text)
})
it.concurrent(
'should handle 3-byte UTF-8 character (中) split at byte boundaries',
async () => {
const text = 'A中B'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const textStart = fullData.indexOf('"') + 1 + text.indexOf('中')
const byteOffset = new TextEncoder().encode(fullData.slice(0, textStart)).length
const chunk1 = bytes.slice(0, byteOffset + 1)
const chunk2 = bytes.slice(byteOffset + 1, byteOffset + 2)
const chunk3 = bytes.slice(byteOffset + 2)
const stream = createStreamFromChunks([chunk1, chunk2, chunk3])
const result = await readSSEStream(stream)
expect(result).toBe(text)
}
)
it.concurrent(
'should handle 4-byte UTF-8 character (🚀) split at byte boundaries',
async () => {
const text = 'A🚀B'
const fullData = `data: ${JSON.stringify({ chunk: text })}\n\n`
const bytes = new TextEncoder().encode(fullData)
const textStart = fullData.indexOf('"') + 1 + text.indexOf('🚀')
const byteOffset = new TextEncoder().encode(fullData.slice(0, textStart)).length
const chunk1 = bytes.slice(0, byteOffset + 1)
const chunk2 = bytes.slice(byteOffset + 1, byteOffset + 2)
const chunk3 = bytes.slice(byteOffset + 2, byteOffset + 3)
const chunk4 = bytes.slice(byteOffset + 3)
const stream = createStreamFromChunks([chunk1, chunk2, chunk3, chunk4])
const result = await readSSEStream(stream)
expect(result).toBe(text)
}
)
})
describe('SSE message buffering', () => {
it.concurrent('should handle incomplete SSE message waiting for more data', async () => {
const encoder = new TextEncoder()
const chunk1 = encoder.encode('data: {"chu')
const chunk2 = encoder.encode('nk":"hello"}\n\n')
const stream = createStreamFromChunks([chunk1, chunk2])
const result = await readSSEStream(stream)
expect(result).toBe('hello')
})
it.concurrent('should handle multiple complete messages in one chunk', async () => {
const encoder = new TextEncoder()
const multiMessage = 'data: {"chunk":"A"}\n\ndata: {"chunk":"B"}\n\ndata: {"chunk":"C"}\n\n'
const chunk = encoder.encode(multiMessage)
const stream = createStreamFromChunks([chunk])
const result = await readSSEStream(stream)
expect(result).toBe('ABC')
})
it.concurrent('should handle message that ends exactly at chunk boundary', async () => {
const chunks = [createSSEChunk({ chunk: 'First' }), createSSEChunk({ chunk: 'Second' })]
const stream = createStreamFromChunks(chunks)
const result = await readSSEStream(stream)
expect(result).toBe('FirstSecond')
})
})
})
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('emits a final data: line that has no trailing newline (stream tail)', async () => {
const stream = streamFromStringChunks(['data: {"n":1}\n', 'data: {"n":2}'])
const events: number[] = []
await readSSEEvents<{ n: number }>(stream, {
onEvent: (e) => {
events.push(e.n)
},
})
expect(events).toEqual([1, 2])
})
it('flushes a multi-byte character in the final unterminated line', async () => {
const encoder = new TextEncoder()
const euro = encoder.encode('€')
const chunk1 = new Uint8Array([...encoder.encode('data: {"s":"'), euro[0], euro[1]])
const chunk2 = new Uint8Array([euro[2], ...encoder.encode('"}')])
const stream = createStreamFromChunks([chunk1, chunk2])
const events: Array<{ s: string }> = []
await readSSEEvents<{ s: string }>(stream, {
onEvent: (e) => {
events.push(e)
},
})
expect(events).toEqual([{ s: '€' }])
})
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()
})
})