fix(sse): make CRLF normalization chunk-boundary safe (#1359)

* fix(sse): handle CRLF split across chunk boundaries

* style: apply prettier formatting
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-23 21:44:32 -04:00
committed by GitHub
parent 21764d5b90
commit 2ec23c6c3a
2 changed files with 30 additions and 1 deletions
+16 -1
View File
@@ -35,12 +35,14 @@ export class SSEParser {
private eventCount: number;
private maxBufferSize: number;
private throwOnMalformedJson: boolean;
private pendingCR: boolean;
constructor(options: SSEParserOptions = {}) {
this.buffer = '';
this.eventCount = 0;
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
this.throwOnMalformedJson = options.throwOnMalformedJson === true;
this.pendingCR = false;
}
/**
@@ -49,7 +51,19 @@ export class SSEParser {
* @returns Array of parsed events
*/
parse(chunk: Buffer | string): SSEEvent[] {
this.buffer += chunk.toString().replace(/\r\n?/g, '\n');
let normalizedChunk = chunk.toString();
if (this.pendingCR) {
if (normalizedChunk.startsWith('\n')) {
normalizedChunk = normalizedChunk.slice(1);
}
this.pendingCR = false;
}
const endsWithCR = normalizedChunk.endsWith('\r');
normalizedChunk = normalizedChunk.replace(/\r\n?/g, '\n');
this.pendingCR = endsWithCR;
this.buffer += normalizedChunk;
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
if (this.buffer.length > this.maxBufferSize) {
@@ -127,5 +141,6 @@ export class SSEParser {
reset(): void {
this.buffer = '';
this.eventCount = 0;
this.pendingCR = false;
}
}
+14
View File
@@ -46,4 +46,18 @@ describe('SSEParser', () => {
?.delta?.content
).toBe('Legacy');
});
it('does not split events when CRLF is split across chunks', () => {
const parser = new SSEParser({ throwOnMalformedJson: true });
expect(parser.parse('data: {"choices":[\r')).toEqual([]);
const events = parser.parse('\ndata: {"delta":{"content":"Hello"}}\r\ndata: ]}\r\n\r\n');
expect(events).toHaveLength(1);
expect(
(events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0]
?.delta?.content
).toBe('Hello');
});
});