parse.ts•7.34 kB
/**
 * EventSource/Server-Sent Events parser
 * @see https://html.spec.whatwg.org/multipage/server-sent-events.html
 */
import {ParseError} from './errors.ts'
import type {EventSourceParser, ParserCallbacks} from './types.ts'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function noop(_arg: unknown) {
  // intentional noop
}
/**
 * Creates a new EventSource parser.
 *
 * @param callbacks - Callbacks to invoke on different parsing events:
 *   - `onEvent` when a new event is parsed
 *   - `onError` when an error occurs
 *   - `onRetry` when a new reconnection interval has been sent from the server
 *   - `onComment` when a comment is encountered in the stream
 *
 * @returns A new EventSource parser, with `parse` and `reset` methods.
 * @public
 */
export function createParser(callbacks: ParserCallbacks): EventSourceParser {
  if (typeof callbacks === 'function') {
    throw new TypeError(
      '`callbacks` must be an object, got a function instead. Did you mean `{onEvent: fn}`?',
    )
  }
  const {onEvent = noop, onError = noop, onRetry = noop, onComment} = callbacks
  let incompleteLine = ''
  let isFirstChunk = true
  let id: string | undefined
  let data = ''
  let eventType = ''
  function feed(newChunk: string) {
    // Strip any UTF8 byte order mark (BOM) at the start of the stream
    const chunk = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, '') : newChunk
    // If there was a previous incomplete line, append it to the new chunk,
    // so we may process it together as a new (hopefully complete) chunk.
    const [complete, incomplete] = splitLines(`${incompleteLine}${chunk}`)
    for (const line of complete) {
      parseLine(line)
    }
    incompleteLine = incomplete
    isFirstChunk = false
  }
  function parseLine(line: string) {
    // If the line is empty (a blank line), dispatch the event
    if (line === '') {
      dispatchEvent()
      return
    }
    // If the line starts with a U+003A COLON character (:), ignore the line.
    if (line.startsWith(':')) {
      if (onComment) {
        onComment(line.slice(line.startsWith(': ') ? 2 : 1))
      }
      return
    }
    // If the line contains a U+003A COLON character (:)
    const fieldSeparatorIndex = line.indexOf(':')
    if (fieldSeparatorIndex !== -1) {
      // Collect the characters on the line before the first U+003A COLON character (:),
      // and let `field` be that string.
      const field = line.slice(0, fieldSeparatorIndex)
      // Collect the characters on the line after the first U+003A COLON character (:),
      // and let `value` be that string. If value starts with a U+0020 SPACE character,
      // remove it from value.
      const offset = line[fieldSeparatorIndex + 1] === ' ' ? 2 : 1
      const value = line.slice(fieldSeparatorIndex + offset)
      processField(field, value, line)
      return
    }
    // Otherwise, the string is not empty but does not contain a U+003A COLON character (:)
    // Process the field using the whole line as the field name, and an empty string as the field value.
    // 👆 This is according to spec. That means that a line that has the value `data` will result in
    // a newline being added to the current `data` buffer, for instance.
    processField(line, '', 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
        break
      case 'data':
        // Append the field value to the `data` buffer, then append a single U+000A LINE FEED(LF)
        // character to the `data` buffer.
        data = `${data}${value}\n`
        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.
        id = value.includes('\0') ? undefined : 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() {
    const shouldDispatch = data.length > 0
    if (shouldDispatch) {
      onEvent({
        id,
        event: eventType || undefined,
        // If the data buffer's last character is a U+000A LINE FEED (LF) character,
        // then remove the last character from the data buffer.
        data: data.endsWith('\n') ? data.slice(0, -1) : data,
      })
    }
    // Reset for the next event
    id = undefined
    data = ''
    eventType = ''
  }
  function reset(options: {consume?: boolean} = {}) {
    if (incompleteLine && options.consume) {
      parseLine(incompleteLine)
    }
    isFirstChunk = true
    id = undefined
    data = ''
    eventType = ''
    incompleteLine = ''
  }
  return {feed, reset}
}
/**
 * For the given `chunk`, split it into lines according to spec, and return any remaining incomplete line.
 *
 * @param chunk - The chunk to split into lines
 * @returns A tuple containing an array of complete lines, and any remaining incomplete line
 * @internal
 */
function splitLines(chunk: string): [complete: Array<string>, incomplete: string] {
  /**
   * According to the spec, a line is terminated by either:
   * - U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair
   * - a single U+000A LINE FEED(LF) character not preceded by a U+000D CARRIAGE RETURN(CR) character
   * - a single U+000D CARRIAGE RETURN(CR) character not followed by a U+000A LINE FEED(LF) character
   */
  const lines: Array<string> = []
  let incompleteLine = ''
  let searchIndex = 0
  while (searchIndex < chunk.length) {
    // Find next line terminator
    const crIndex = chunk.indexOf('\r', searchIndex)
    const lfIndex = chunk.indexOf('\n', searchIndex)
    // Determine line end
    let lineEnd = -1
    if (crIndex !== -1 && lfIndex !== -1) {
      // CRLF case
      lineEnd = Math.min(crIndex, lfIndex)
    } else if (crIndex !== -1) {
      lineEnd = crIndex
    } else if (lfIndex !== -1) {
      lineEnd = lfIndex
    }
    // Extract line if terminator found
    if (lineEnd === -1) {
      // No terminator found, rest is incomplete
      incompleteLine = chunk.slice(searchIndex)
      break
    } else {
      const line = chunk.slice(searchIndex, lineEnd)
      lines.push(line)
      // Move past line terminator
      searchIndex = lineEnd + 1
      if (chunk[searchIndex - 1] === '\r' && chunk[searchIndex] === '\n') {
        searchIndex++
      }
    }
  }
  return [lines, incompleteLine]
}