diff --git a/packages/core/src/event-stream/decoder.test.ts b/packages/core/src/event-stream/decoder.test.ts index a7945b90..fda451f9 100644 --- a/packages/core/src/event-stream/decoder.test.ts +++ b/packages/core/src/event-stream/decoder.test.ts @@ -303,6 +303,16 @@ describe('eventStreamDecoder', () => { { event: 'message', data: 'second' }, ]) }) + + it('does not join line endings across a chunk without any', () => { + for (const eol of ['\n', '\r', '\r\n']) { + const events = feedAll([`data: a${eol}`, 'data: b', `${eol}${eol}`]) + + expect(events, `line ending ${JSON.stringify(eol)}`).toEqual([ + { event: 'message', data: 'a\nb' }, + ]) + } + }) }) describe('end', () => { diff --git a/packages/core/src/event-stream/decoder.ts b/packages/core/src/event-stream/decoder.ts index 70175e25..25d85098 100644 --- a/packages/core/src/event-stream/decoder.ts +++ b/packages/core/src/event-stream/decoder.ts @@ -3,10 +3,11 @@ import { isEventStreamMessageId, isEventStreamMessageRetry } from './encoder' import { EventStreamDecoderError } from './error' // A line ending is CR, LF or CRLF. -const LINE_ENDING_REGEX = /\r\n|\r(?!\n)|\n/ +const LINE_ENDING_REGEX = /\r\n?|\n/ // A message ends at a blank line; any extra blank lines after it are part of -// the same delimiter, since the spec treats them as no-ops. -const MESSAGE_DELIMITER_REGEX = /(?:\r\n|\r(?!\n)|\n){2,}/g +// the same delimiter, since the spec treats them as no-ops. No quantified group, +// which JavaScriptCore runs ~100x slower; {3,} goes first to consume the run. +const MESSAGE_DELIMITER_REGEX = /[\r\n]{3,}|\r\r|\n[\r\n]/g const LEADING_LINE_ENDINGS_REGEX = /^[\r\n]+/ // JS `\d` matches ASCII digits only, as the spec requires for retry. @@ -18,9 +19,11 @@ const ASCII_DIGITS_REGEX = /^\d+$/ const MAX_DELIMITER_OVERLAP = 2 const SPACE = 0x20 +const LF = 0x0A +const CR = 0x0D export function decodeEventStreamMessage(encoded: string): EventStreamMessage { - const message: EventStreamMessage & { comments?: string[] } = {} + const message: EventStreamMessage = {} for (const line of encoded.split(LINE_ENDING_REGEX)) { if (line === '') { @@ -104,7 +107,11 @@ export class EventStreamDecoder { // Line endings between messages are extra blank lines (or the '\n' of a // CRLF split after a delimiter), so they carry no content. if (this.pending.length === 0) { - chunk = chunk.replace(LEADING_LINE_ENDINGS_REGEX, '') + const first = chunk.charCodeAt(0) + + if (first === LF || first === CR) { + chunk = chunk.replace(LEADING_LINE_ENDINGS_REGEX, '') + } } // empty chunk has no meaningful content to process @@ -123,7 +130,7 @@ export class EventStreamDecoder { return } - const buffered = this.pending.join('') + const buffered = this.pending.length === 1 ? chunk : this.pending.join('') const offset = buffered.length - scan.length const parts: string[] = [] let start = 0 diff --git a/tests/bun/tests/event-stream-decoder.test.ts b/tests/bun/tests/event-stream-decoder.test.ts new file mode 100644 index 00000000..3dc4d897 --- /dev/null +++ b/tests/bun/tests/event-stream-decoder.test.ts @@ -0,0 +1,45 @@ +import type { EventStreamMessage } from '@standard-server/core' +import { EventStreamDecoder } from '@standard-server/core' +import { describe, expect, it } from 'bun:test' + +function feedAll(chunks: string[]): EventStreamMessage[] { + const events: EventStreamMessage[] = [] + const decoder = new EventStreamDecoder(event => events.push(event)) + + for (const chunk of chunks) { + decoder.feed(chunk) + } + + decoder.end() + + return events +} + +/** + * The decoder's unit tests run on Node (V8). These repeat the line ending + * cases on JavaScriptCore, whose regex engine is a separate implementation. + */ +describe('event stream decoder on JavaScriptCore', () => { + it('does not treat a single CR or CRLF line ending as a blank line', () => { + for (const eol of ['\n', '\r', '\r\n']) { + expect(feedAll([`event: update${eol}data: 42${eol}${eol}`]), `line ending ${JSON.stringify(eol)}`).toEqual([ + { event: 'update', data: '42' }, + ]) + } + }) + + it('handles every delimiter split at every position', () => { + for (const delimiter of ['\n\n', '\r\r', '\n\r', '\n\r\n', '\r\n\n', '\r\n\r\n', '\n\n\n', '\r\r\r', '\r\n\r\n\r\n', '\n\r\n\r\n']) { + const stream = `${delimiter}data: first${delimiter}data: second${delimiter}` + + for (let split = 1; split < stream.length; split++) { + const events = feedAll([stream.slice(0, split), stream.slice(split)]) + + expect(events, `delimiter ${JSON.stringify(delimiter)} split at ${split}`).toEqual([ + { event: 'message', data: 'first' }, + { event: 'message', data: 'second' }, + ]) + } + } + }) +})