/** * EventSource/Server-Sent Events parser * @see https://html.spec.whatwg.org/multipage/server-sent-events.html */ import {ParseError} from './errors.ts' import type {EventSourceParser, ParserConfig} from './types.ts' // ASCII codes used in the hot parsing paths. const LF = 10 const CR = 13 const SPACE = 32 const MAX_FIELD_PREFIX_LENGTH = 6 /** * Creates a new EventSource parser. * * @param config - Parser configuration. Accepts callbacks (see {@link ParserCallbacks}) * and options like `maxBufferSize` (see {@link ParserConfig}). * * @returns A new EventSource parser, with `feed` and `reset` methods. * @public */ export function createParser(config: ParserConfig): EventSourceParser { if (typeof config === 'function') { throw new TypeError( '`config` must be an object, got a function instead. Did you mean `createParser({onEvent: fn})`?', ) } const {maxBufferSize, onComment, onError, onEvent, onId, onRetry} = config // Trailing bytes from prior `feed()` calls that did not yet form a complete line. // Stored as an array of fragments and only joined when a line terminator arrives. // Concatenating per-feed (`prefix + chunk`) is O(N²) when a single SSE line spans // many chunks (e.g. a large `data:` payload streamed in tiny slices, or an MCP-style // server that emits one giant content block). Buffering as fragments + joining once // makes the same workload linear. const pendingFragments: string[] = [] // Running total of `pendingFragments` lengths, kept in sync with the array so the // `maxBufferSize` check doesn't have to walk the fragment list on every feed. let pendingFragmentsLength = 0 // Empty or partial leading BOM; undefined once the BOM check is complete. let bomPrefix: string | undefined = '' let id: string | undefined let data = '' let dataLines = 0 let eventType: string | undefined // Set after a `maxBufferSize` overflow. Once tripped, `feed()` throws until // `reset()` is called — see the comment on `maxBufferSize` in `ParserConfig`. let terminated = false let skippingLine = false // Set when a line (parsed or discarded) was terminated by a trailing `\r` at the very // end of a chunk. That `\r` is ambiguous: it may be a bare-CR terminator or the first // half of a `\r\n` whose `\n` lands in the next chunk. The line itself is complete // either way, but we must remember to swallow a single leading `\n` from the next // chunk so the pair is treated as one terminator rather than a blank line (which // would dispatch an event prematurely). let skipNextLineFeed = false /** * Feeds a chunk of the SSE stream to the parser. Any trailing bytes that do * not yet form a complete line are held back and prepended to the next chunk, * so callers can pass arbitrary slices of the stream without worrying about * line boundaries. * * Per the SSE spec, one leading UTF-8 BOM is stripped before parsing, * even when split across chunks. This handles both the raw 3-byte form (0xEF 0xBB * 0xBF) and a single decoded U+FEFF, so a leading BOM is ignored regardless of * how the caller decoded the bytes. * * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream */ function feed(chunk: string) { if (terminated) { throw new Error( 'Cannot feed parser: it was terminated after exceeding the configured max buffer size. Call `reset()` to resume parsing.', ) } if (bomPrefix !== undefined) { chunk = bomPrefix + chunk // Wait only while the input could still be a raw BOM. Empty chunks must // not finish the check, and a mismatched prefix must remain ordinary input. if (chunk === '' || chunk === '\xEF' || chunk === '\xEF\xBB') { bomPrefix = chunk return } bomPrefix = undefined // Strip exactly one leading BOM, in decoded or raw byte-valued form. chunk = chunk.replace(/^(?:\uFEFF|\xEF\xBB\xBF)/, '') } // Rare resume states from a prior chunk boundary: a pending `\r\n` split or a // line being discarded. Single combined check keeps the hot path to one branch; // the actual handling lives out-of-line in `resumeAfterSkip`. if (skippingLine || skipNextLineFeed) { chunk = resumeAfterSkip(chunk) if (!chunk) { return } } // Hot path: no buffered prefix from a prior partial line. Hand the chunk // straight to `processLines`, exactly like the original implementation. // Zero new work in the common case (every chunk ends with `\n\n`). if (!pendingFragments.length) { const trailing = processLines(chunk) if (trailing !== '') { storeTrailing(trailing) } checkBufferSize() return } // We have a buffered prefix. If this chunk also has no terminator, append // to the buffer without concatenating — that's the O(N²) trap we're // avoiding (large single `data:` payload split across many tiny chunks). if (chunk.indexOf('\n') === -1 && chunk.indexOf('\r') === -1) { if (pendingFragmentsLength < MAX_FIELD_PREFIX_LENGTH) { const head = pendingFragments.join('') + chunk.slice(0, MAX_FIELD_PREFIX_LENGTH - pendingFragmentsLength) if (!shouldBufferTrailing(head)) { pendingFragments.length = 0 pendingFragmentsLength = 0 skippingLine = true return } } pendingFragments.push(chunk) pendingFragmentsLength += chunk.length checkBufferSize() return } // Terminator arrived. Join the accumulated fragments + this chunk once, // process, and buffer any new trailing partial line. pendingFragments.push(chunk) const input = pendingFragments.join('') pendingFragments.length = 0 pendingFragmentsLength = 0 storeTrailing(processLines(input)) checkBufferSize() } // Out-of-line handler for the rare post-boundary states. `skipNextLineFeed` means a // line (parsed or discarded) ended with a trailing `\r` at the previous chunk boundary, // so a single leading `\n` must be swallowed to consume a split `\r\n` as one terminator. // `skippingLine` means we are discarding an invalid line until its terminator arrives. // The two states are mutually exclusive. Returns the remaining chunk to parse ('' if // this chunk was fully consumed). function resumeAfterSkip(chunk: string): string { if (chunk.length === 0) { return chunk } if (skipNextLineFeed) { skipNextLineFeed = false return chunk.charCodeAt(0) === LF ? chunk.slice(1) : chunk } const crIndex = chunk.indexOf('\r') const lfIndex = chunk.indexOf('\n') const lineEnd = crIndex === -1 ? lfIndex : lfIndex === -1 ? crIndex : crIndex < lfIndex ? crIndex : lfIndex if (lineEnd === -1) { return '' } // Trailing `\r` at the very end of the chunk: defer the CRLF/CR decision to the // next chunk (see `skipNextLineFeed`) rather than greedily consuming just the `\r`. if (lineEnd === chunk.length - 1 && chunk.charCodeAt(lineEnd) === CR) { skippingLine = false skipNextLineFeed = true return '' } skippingLine = false return chunk.slice( lineEnd + (chunk.charCodeAt(lineEnd) === CR && chunk.charCodeAt(lineEnd + 1) === LF ? 2 : 1), ) } function storeTrailing(trailing: string) { if (!trailing) return // A trailing that ends with `\r` is not a partial line: the `\r` terminates it, // and only the CRLF-vs-CR ambiguity remains (the matching `\n` may start the next // chunk). Parse the completed line now — buffering it could grow unbounded through // the no-terminator append path, and discarding it would eat the terminator and // make skip mode swallow the next line. if (trailing.charCodeAt(trailing.length - 1) === CR) { parseLine(trailing, 0, trailing.length - 1) skipNextLineFeed = true return } if (shouldBufferTrailing(trailing)) { pendingFragments.push(trailing) pendingFragmentsLength = trailing.length return } skippingLine = true } function shouldBufferTrailing(trailing: string) { const firstCharCode = trailing.charCodeAt(0) return ( (firstCharCode === 58 && !!onComment) || (firstCharCode === 100 && isPotentialField(trailing, 'data')) || (firstCharCode === 101 && isPotentialField(trailing, 'event')) || (firstCharCode === 105 && isPotentialField(trailing, 'id')) || (firstCharCode === 114 && isPotentialField(trailing, 'retry')) ) } function checkBufferSize() { if (maxBufferSize === undefined) return if (pendingFragmentsLength + data.length <= maxBufferSize) return terminated = true pendingFragments.length = 0 pendingFragmentsLength = 0 id = undefined data = '' dataLines = 0 eventType = undefined skippingLine = false skipNextLineFeed = false onError?.( new ParseError(`Buffered data exceeded max buffer size of ${maxBufferSize} characters`, { type: 'max-buffer-size-exceeded', }), ) } /** * Splits `chunk` into SSE lines and dispatches each to the appropriate handler. * Returns any trailing bytes that did not terminate with a line break, so the * caller can prepend them to the next chunk. * * The SSE spec permits three line terminators: `\n`, `\r`, and `\r\n`. Real-world * streams almost always use plain `\n`, so we take a fast path when no `\r` is * present in the chunk. The slow path is spec-correct but does more work per line. */ function processLines(chunk: string): string { let searchIndex = 0 // Fast path: LF-only chunk (the common case for typical SSE servers). // We can scan forward with a single `indexOf('\n')` per line and inline // the hot-path branches for `data:` and `event:` without the CR bookkeeping // the slow path needs. if (chunk.indexOf('\r') === -1) { let lfIndex = chunk.indexOf('\n', searchIndex) while (lfIndex !== -1) { // Blank line: end-of-event marker. Dispatch the accumulated event (if any) // and reset the buffered fields. This is hoisted out of `parseLine` because // it's the single most common line shape after `data:` lines. if (searchIndex === lfIndex) { if (id !== undefined) { onId?.(id) } if (dataLines > 0) { onEvent?.({id, event: eventType, data}) } id = undefined data = '' dataLines = 0 eventType = undefined searchIndex = lfIndex + 1 lfIndex = chunk.indexOf('\n', searchIndex) continue } const firstCharCode = chunk.charCodeAt(searchIndex) if (isDataPrefix(chunk, searchIndex, firstCharCode)) { // `data:` line — append the value to the event's data buffer. // 'data:'.length === 5, 'data: '.length === 6 const valueStart = chunk.charCodeAt(searchIndex + 5) === SPACE ? searchIndex + 6 : searchIndex + 5 const value = chunk.slice(valueStart, lfIndex) // Fast path within a fast path: if this is the first data line AND the // next char is another LF (i.e. `data:foo\n\n`), dispatch immediately // without ever writing to the `data` buffer. This is the shape of a // typical single-line SSE event (ChatGPT-style streams, etc.) and is // hot enough to be worth the duplication. if (dataLines === 0 && chunk.charCodeAt(lfIndex + 1) === LF) { if (id !== undefined) { onId?.(id) } onEvent?.({id, event: eventType, data: value}) id = undefined data = '' eventType = undefined searchIndex = lfIndex + 2 lfIndex = chunk.indexOf('\n', searchIndex) continue } // Multi-line data: concatenate with newline separator per spec. data = dataLines === 0 ? value : `${data}\n${value}` dataLines++ } else if (isEventPrefix(chunk, searchIndex, firstCharCode)) { // `event:` line — set the event type for the next dispatch. Per spec, // an empty value resets `event type` to its default (undefined here). // 'event:'.length === 6, 'event: '.length === 7 eventType = chunk.slice( chunk.charCodeAt(searchIndex + 6) === SPACE ? searchIndex + 7 : searchIndex + 6, lfIndex, ) || undefined } else { // Everything else: `id:`, `retry:`, comment lines (`:` prefix), unknown // fields, or malformed lines. These are rarer and go through the full // per-line parser, which handles the SSE field grammar in detail. parseLine(chunk, searchIndex, lfIndex) } searchIndex = lfIndex + 1 lfIndex = chunk.indexOf('\n', searchIndex) } return chunk.slice(searchIndex) } // Slow path: the chunk contains at least one `\r`, so lines may be terminated // by `\r`, `\n`, or `\r\n`. We locate the next terminator by looking at both // the nearest `\r` and `\n` and picking whichever comes first. while (searchIndex < chunk.length) { const crIndex = chunk.indexOf('\r', searchIndex) const lfIndex = chunk.indexOf('\n', searchIndex) let lineEnd = -1 if (crIndex !== -1 && lfIndex !== -1) { lineEnd = crIndex < lfIndex ? crIndex : lfIndex } else if (crIndex !== -1) { // A trailing `\r` at the very end of the chunk is ambiguous: it could be // a bare-CR terminator, or the first half of a `\r\n` whose `\n` arrives // in the next chunk. Defer until we see more input. if (crIndex === chunk.length - 1) { lineEnd = -1 } else { lineEnd = crIndex } } else if (lfIndex !== -1) { lineEnd = lfIndex } if (lineEnd === -1) { break } parseLine(chunk, searchIndex, lineEnd) searchIndex = lineEnd + 1 // If we just consumed a `\r` and the next char is `\n`, skip it so the // pair is treated as a single terminator rather than an empty line. if (chunk.charCodeAt(searchIndex - 1) === CR && chunk.charCodeAt(searchIndex) === LF) { searchIndex++ } } return chunk.slice(searchIndex) } function parseLine(chunk: string, start: number, end: number) { if (start === end) { dispatchEvent() return } const firstCharCode = chunk.charCodeAt(start) if (isDataPrefix(chunk, start, firstCharCode)) { // 'data:'.length === 5, 'data: '.length === 6 const valueStart = chunk.charCodeAt(start + 5) === SPACE ? start + 6 : start + 5 const value = chunk.slice(valueStart, end) data = dataLines === 0 ? value : `${data}\n${value}` dataLines++ return } if (isEventPrefix(chunk, start, firstCharCode)) { // 'event:'.length === 6, 'event: '.length === 7 eventType = chunk.slice(chunk.charCodeAt(start + 6) === SPACE ? start + 7 : start + 6, end) || undefined return } // Fast path for "id:" — 'i' = 105, 'd' = 100, ':' = 58 if ( firstCharCode === 105 && chunk.charCodeAt(start + 1) === 100 && chunk.charCodeAt(start + 2) === 58 ) { // 'id:'.length === 3, 'id: '.length === 4 const value = chunk.slice(chunk.charCodeAt(start + 3) === SPACE ? start + 4 : start + 3, end) // If the field value does not contain U+0000 NULL, then set the `ID` buffer to // the field value. Otherwise, ignore the field. if (!value.includes('\0')) id = value return } // Comment line — ':' = 58 if (firstCharCode === 58) { if (onComment) { const line = chunk.slice(start, end) // skip ':' (+1), or ': ' (+2) when a space follows onComment(line.slice(chunk.charCodeAt(start + 1) === SPACE ? 2 : 1)) } return } const line = chunk.slice(start, end) const fieldSeparatorIndex = line.indexOf(':') if (fieldSeparatorIndex === -1) { processField(line, '', line) return } const field = line.slice(0, fieldSeparatorIndex) // skip ':' (+1), or ': ' (+2) when a space follows const offset = line.charCodeAt(fieldSeparatorIndex + 1) === SPACE ? 2 : 1 const value = line.slice(fieldSeparatorIndex + offset) processField(field, value, line) } function processField(field: string, value: string, line: string) { // Field names must be compared literally, with no case folding performed. switch (field) { case 'event': // Set the `event type` buffer to field value eventType = value || undefined break case 'data': data = dataLines === 0 ? value : `${data}\n${value}` dataLines++ break case 'id': // If the field value does not contain U+0000 NULL, then set the `ID` buffer to // the field value. Otherwise, ignore the field. if (!value.includes('\0')) id = value break case 'retry': // If the field value consists of only ASCII digits, then interpret the field value as an // integer in base ten, and set the event stream's reconnection time to that integer. // Otherwise, ignore the field. if (/^\d+$/.test(value)) { onRetry?.(parseInt(value, 10)) } else { onError?.( new ParseError(`Invalid \`retry\` value: "${value}"`, { type: 'invalid-retry', value, line, }), ) } break default: // Otherwise, the field is ignored. onError?.( new ParseError( `Unknown field "${field.length > 20 ? `${field.slice(0, 20)}…` : field}"`, {type: 'unknown-field', field, value, line}, ), ) break } } function dispatchEvent() { if (id !== undefined) { onId?.(id) } if (dataLines > 0) { onEvent?.({ id, event: eventType, data, }) } id = undefined data = '' dataLines = 0 eventType = undefined } function reset(options: {consume?: boolean} = {}) { if (options.consume && pendingFragments.length > 0) { const incompleteLine = pendingFragments.join('') parseLine(incompleteLine, 0, incompleteLine.length) } bomPrefix = '' id = undefined data = '' dataLines = 0 eventType = undefined pendingFragments.length = 0 pendingFragmentsLength = 0 terminated = false skippingLine = false skipNextLineFeed = false } return {feed, reset} } /** * Checks if `chunk` starts with the literal `data:` at index `i`. * * Equivalent to `chunk.startsWith('data:', i)`, but benchmarks show this * hand-unrolled char-code comparison is ~20% faster on common event types. * The caller passes `firstCharCode` (the code at `i`) so it can be reused * across prefix checks. * * ASCII: 'd' = 100, 'a' = 97, 't' = 116, 'a' = 97, ':' = 58 */ function isDataPrefix(chunk: string, i: number, firstCharCode: number): boolean { return ( firstCharCode === 100 && chunk.charCodeAt(i + 1) === 97 && chunk.charCodeAt(i + 2) === 116 && chunk.charCodeAt(i + 3) === 97 && chunk.charCodeAt(i + 4) === 58 ) } /** * Checks if `chunk` starts with the literal `event:` at index `i`. * * See {@link isDataPrefix} for why this is hand-unrolled rather than using * `String.prototype.startsWith`. * * ASCII: 'e' = 101, 'v' = 118, 'e' = 101, 'n' = 110, 't' = 116, ':' = 58 */ function isEventPrefix(chunk: string, i: number, firstCharCode: number): boolean { return ( firstCharCode === 101 && chunk.charCodeAt(i + 1) === 118 && chunk.charCodeAt(i + 2) === 101 && chunk.charCodeAt(i + 3) === 110 && chunk.charCodeAt(i + 4) === 116 && chunk.charCodeAt(i + 5) === 58 ) } function isPotentialField(line: string, field: string): boolean { let i = 1 while (i < line.length && i < field.length) { if (line.charCodeAt(i) !== field.charCodeAt(i)) { return false } i++ } return line.length <= field.length || line.charCodeAt(field.length) === 58 }