From 886bc1dc02393df96d6b6d4c05cf3390ae2d94f0 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 20:37:58 +0700 Subject: [PATCH] perf(core): speed up event stream decoding on JavaScriptCore The message delimiter regex used a quantified group, `(?:...){2,}`, which JavaScriptCore (Bun, Safari) scans about 100x slower than V8. Replace it with an equivalent pattern built from character classes, drop the redundant lookahead from the line ending regex, and skip two per-feed costs that every single-message chunk paid: the leading line ending regex and joining a one-element pending buffer. A report claimed the old regex also splits messages on a single CR under JavaScriptCore; that did not reproduce on Bun 1.4.1/1.4.2. A Bun test now asserts CR, LF and CRLF decoding on JavaScriptCore to guard it. --- .../core/src/event-stream/decoder.test.ts | 10 +++++ packages/core/src/event-stream/decoder.ts | 19 +++++--- tests/bun/tests/event-stream-decoder.test.ts | 45 +++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 tests/bun/tests/event-stream-decoder.test.ts 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' }, + ]) + } + } + }) +})