Skip to content

Commit 23722c3

Browse files
committed
fix: harden SSE stream parsing
1 parent 1e90b88 commit 23722c3

2 files changed

Lines changed: 86 additions & 32 deletions

File tree

src/client/stream.ts

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,41 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
1010

1111
const decoder = new TextDecoder();
1212
let buffer = '';
13+
let event: Partial<ServerSentEvent> = {};
14+
15+
const processLine = (rawLine: string): ServerSentEvent | undefined => {
16+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
17+
18+
if (line === '') {
19+
const completed = event.data !== undefined
20+
? { data: event.data, event: event.event, id: event.id }
21+
: undefined;
22+
event = {};
23+
return completed;
24+
}
25+
26+
if (line.startsWith(':')) return undefined;
27+
28+
const colonIndex = line.indexOf(':');
29+
if (colonIndex === -1) return undefined;
30+
31+
const field = line.slice(0, colonIndex);
32+
const value = line.slice(colonIndex + 1).trimStart();
33+
34+
switch (field) {
35+
case 'data':
36+
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
37+
break;
38+
case 'event':
39+
event.event = value;
40+
break;
41+
case 'id':
42+
event.id = value;
43+
break;
44+
}
45+
46+
return undefined;
47+
};
1348

1449
try {
1550
while (true) {
@@ -21,45 +56,25 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
2156
const lines = buffer.split('\n');
2257
buffer = lines.pop() || '';
2358

24-
let event: Partial<ServerSentEvent> = {};
25-
2659
for (const line of lines) {
27-
if (line === '') {
28-
if (event.data !== undefined) {
29-
yield { data: event.data, event: event.event, id: event.id };
30-
}
31-
event = {};
32-
continue;
60+
const completed = processLine(line);
61+
if (completed) {
62+
yield completed;
3363
}
64+
}
65+
}
3466

35-
if (line.startsWith(':')) continue; // comment
36-
37-
const colonIndex = line.indexOf(':');
38-
if (colonIndex === -1) continue;
39-
40-
const field = line.slice(0, colonIndex);
41-
const value = line.slice(colonIndex + 1).trimStart();
67+
buffer += decoder.decode();
4268

43-
switch (field) {
44-
case 'data':
45-
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
46-
break;
47-
case 'event':
48-
event.event = value;
49-
break;
50-
case 'id':
51-
event.id = value;
52-
break;
53-
}
69+
if (buffer.length > 0) {
70+
const completed = processLine(buffer);
71+
if (completed) {
72+
yield completed;
5473
}
5574
}
5675

57-
// Flush remaining
58-
if (buffer.trim() && buffer.includes('data:')) {
59-
const colonIndex = buffer.indexOf(':');
60-
if (colonIndex !== -1) {
61-
yield { data: buffer.slice(colonIndex + 1).trimStart() };
62-
}
76+
if (event.data !== undefined) {
77+
yield { data: event.data, event: event.event, id: event.id };
6378
}
6479
} finally {
6580
reader.releaseLock();

test/client/stream.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,45 @@ describe('parseSSE', () => {
241241
expect(events2[1].data).toBe('[DONE]');
242242
});
243243

244+
it('preserves event fields split across network chunks', async () => {
245+
const body = new ReadableStream<Uint8Array>({
246+
start(controller) {
247+
const encoder = new TextEncoder();
248+
controller.enqueue(encoder.encode('id: 7\nevent: mes'));
249+
controller.enqueue(encoder.encode('sage\ndata: hel'));
250+
controller.enqueue(encoder.encode('lo\n\n'));
251+
controller.close();
252+
},
253+
});
254+
255+
const events = await collectEvents(new Response(body));
256+
257+
expect(events).toEqual([{ id: '7', event: 'message', data: 'hello' }]);
258+
});
259+
260+
it('preserves multi-line data split across network chunks', async () => {
261+
const body = new ReadableStream<Uint8Array>({
262+
start(controller) {
263+
const encoder = new TextEncoder();
264+
controller.enqueue(encoder.encode('data: first\n'));
265+
controller.enqueue(encoder.encode('data: sec'));
266+
controller.enqueue(encoder.encode('ond\n\n'));
267+
controller.close();
268+
},
269+
});
270+
271+
const events = await collectEvents(new Response(body));
272+
273+
expect(events).toEqual([{ data: 'first\nsecond' }]);
274+
});
275+
276+
it('handles CRLF line endings', async () => {
277+
const body = 'event: message\r\ndata: hello\r\n\r\n';
278+
const events = await collectEvents(new Response(body));
279+
280+
expect(events).toEqual([{ event: 'message', data: 'hello' }]);
281+
});
282+
244283
// -------------------------------------------------------------------------
245284
// Helpers
246285
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)