diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index d6fff5d6..a4cb12ba 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -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; } } diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts index 740fc0e1..bfe35016 100644 --- a/tests/unit/glmt/sse-parser.test.ts +++ b/tests/unit/glmt/sse-parser.test.ts @@ -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'); + }); });