Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/src/event-stream/decoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 13 additions & 6 deletions packages/core/src/event-stream/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 === '') {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions tests/bun/tests/event-stream-decoder.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
])
}
}
})
})
Loading