diff --git a/src/utils/eventsource-parser.mjs b/src/utils/eventsource-parser.mjs index 802a17f0..436700ea 100644 --- a/src/utils/eventsource-parser.mjs +++ b/src/utils/eventsource-parser.mjs @@ -10,6 +10,7 @@ function createParser(onParse) { let eventName let data let extra + let discardTrailingNewline reset() return { feed, @@ -24,6 +25,7 @@ function createParser(onParse) { eventId = void 0 eventName = void 0 data = '' + discardTrailingNewline = false } function feed(chunk) { @@ -35,7 +37,6 @@ function createParser(onParse) { isFirstChunk = false const length = buffer.length let position = 0 - let discardTrailingNewline = false while (position < length) { if (discardTrailingNewline) { if (buffer[position] === '\n') { @@ -46,7 +47,11 @@ function createParser(onParse) { let lineLength = -1 let fieldLength = startingFieldLength let character - for (let index = startingPosition; lineLength < 0 && index < length; ++index) { + for ( + let index = position + startingPosition; + lineLength < 0 && index < length; + ++index + ) { character = buffer[index] if (character === ':' && fieldLength < 0) { fieldLength = index - position diff --git a/tests/unit/utils/eventsource-parser.test.mjs b/tests/unit/utils/eventsource-parser.test.mjs index c999454b..debe86ee 100644 --- a/tests/unit/utils/eventsource-parser.test.mjs +++ b/tests/unit/utils/eventsource-parser.test.mjs @@ -6,6 +6,13 @@ const encoder = new TextEncoder() const toBytes = (text) => encoder.encode(text) +const parseChunks = (...chunks) => { + const parsed = [] + const parser = createParser((event) => parsed.push(event)) + for (const chunk of chunks) parser.feed(chunk) + return parsed +} + test('createParser parses basic SSE event data', () => { const parsed = [] const parser = createParser((event) => parsed.push(event)) @@ -72,6 +79,50 @@ test('createParser handles \\r\\n line endings', () => { assert.equal(parsed[0].data, 'hello\nworld') }) +test('createParser keeps CRLF separators intact across chunks', () => { + const parsed = [] + const parser = createParser((event) => parsed.push(event)) + + parser.feed(toBytes('data: hello\r')) + parser.feed(toBytes('\ndata: world\r')) + parser.feed(toBytes('\n\r')) + parser.feed(toBytes('\n')) + + assert.equal(parsed.length, 1) + assert.equal(parsed[0].data, 'hello\nworld') +}) + +test('createParser preserves an LF delimiter after a complete CRLF line', () => { + const parsed = parseChunks(toBytes('data: a\r\n'), toBytes('\n')) + + assert.equal(parsed.length, 1) + assert.equal(parsed[0].data, 'a') +}) + +test('createParser preserves mixed CRLF and LF event lines across chunks', () => { + const parsed = parseChunks(toBytes('data: a\r\ndata: b\n'), toBytes('\n')) + + assert.equal(parsed.length, 1) + assert.equal(parsed[0].data, 'a\nb') +}) + +test('createParser preserves split CRLF state across empty chunks', () => { + const parsed = parseChunks(toBytes('data: a\r'), toBytes(''), toBytes('\n\n')) + + assert.equal(parsed.length, 1) + assert.equal(parsed[0].data, 'a') +}) + +test('createParser produces the same events at every single chunk boundary', () => { + const stream = toBytes('data: alpha\r\n\ndata: beta\ndata: gamma\r\r') + const expected = parseChunks(stream) + + for (let split = 0; split <= stream.length; ++split) { + const actual = parseChunks(stream.slice(0, split), stream.slice(split)) + assert.deepEqual(actual, expected, `split at byte ${split}`) + } +}) + test('createParser handles \\r only line endings', () => { const parsed = [] const parser = createParser((event) => parsed.push(event))