azure-devops-mcp
by RyanCardin15
Verified
/**
* 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 {
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)
}
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): [Array<string>, 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 = ''
const totalLength = chunk.length
for (let i = 0; i < totalLength; i++) {
const char = chunk[i]
if (char === '\r' && chunk[i + 1] === '\n') {
// CRLF
lines.push(incompleteLine)
incompleteLine = ''
i++ // Skip the LF character
} else if (char === '\r') {
// Standalone CR
lines.push(incompleteLine)
incompleteLine = ''
} else if (char === '\n') {
// Standalone LF
lines.push(incompleteLine)
incompleteLine = ''
} else {
incompleteLine += char
}
}
return [lines, incompleteLine]
}