feat(planning): grille hebdomadaire complète avec API et filtres

- Connexion API via proxy Angular (résolution CORS, base path /api)
- Import CSS ng-zorro global pour les modales et composants
- Filtres Camion/Show câblés sur l'affichage de la grille
- Camions affichés via TrucksService (linkés au show du même créneau)
- Panneau de détails : spectacles + camions du jour sélectionné
- Modale de création de spectacle stylisée avec fond et centrage
- Positionnement précis des events à la minute dans leur créneau
- Auto-scroll vers l'heure courante au chargement
- Ligne "maintenant" sur la colonne du jour actuel
- Régénération des services OpenAPI (nouveaux noms de types)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:36:03 +02:00
parent 150b97cd2e
commit 654b297e2e
3131 changed files with 149304 additions and 104334 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Espen Hovlandsdal <espen@hovlandsdal.com>
Copyright (c) 2025 Espen Hovlandsdal <espen@hovlandsdal.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+44 -104
View File
@@ -5,7 +5,6 @@ class ParseError extends Error {
super(message), this.name = "ParseError", this.type = options.type, this.field = options.field, this.value = options.value, this.line = options.line;
}
}
const LF = 10, CR = 13, SPACE = 32;
function noop(_arg) {
}
function createParser(callbacks) {
@@ -13,109 +12,39 @@ function createParser(callbacks) {
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, pendingFragments = [];
let isFirstChunk = !0, id, data = "", dataLines = 0, eventType;
function feed(chunk) {
if (isFirstChunk && (isFirstChunk = !1, chunk.charCodeAt(0) === 239 && chunk.charCodeAt(1) === 187 && chunk.charCodeAt(2) === 191 && (chunk = chunk.slice(3))), pendingFragments.length === 0) {
const trailing2 = processLines(chunk);
trailing2 !== "" && pendingFragments.push(trailing2);
return;
}
if (chunk.indexOf(`
`) === -1 && chunk.indexOf("\r") === -1) {
pendingFragments.push(chunk);
return;
}
pendingFragments.push(chunk);
const input = pendingFragments.join("");
pendingFragments.length = 0;
const trailing = processLines(input);
trailing !== "" && pendingFragments.push(trailing);
const { onEvent = noop, onError = noop, onRetry = noop, onComment } = callbacks;
let incompleteLine = "", isFirstChunk = !0, id, data = "", eventType = "";
function feed(newChunk) {
const chunk = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk}`);
for (const line of complete)
parseLine(line);
incompleteLine = incomplete, isFirstChunk = !1;
}
function processLines(chunk) {
let searchIndex = 0;
if (chunk.indexOf("\r") === -1) {
let lfIndex = chunk.indexOf(`
`, searchIndex);
for (; lfIndex !== -1; ) {
if (searchIndex === lfIndex) {
dataLines > 0 && onEvent({ id, event: eventType, data }), id = void 0, data = "", dataLines = 0, eventType = void 0, searchIndex = lfIndex + 1, lfIndex = chunk.indexOf(`
`, searchIndex);
continue;
}
const firstCharCode = chunk.charCodeAt(searchIndex);
if (isDataPrefix(chunk, searchIndex, firstCharCode)) {
const valueStart = chunk.charCodeAt(searchIndex + 5) === SPACE ? searchIndex + 6 : searchIndex + 5, value = chunk.slice(valueStart, lfIndex);
if (dataLines === 0 && chunk.charCodeAt(lfIndex + 1) === LF) {
onEvent({ id, event: eventType, data: value }), id = void 0, data = "", eventType = void 0, searchIndex = lfIndex + 2, lfIndex = chunk.indexOf(`
`, searchIndex);
continue;
}
data = dataLines === 0 ? value : `${data}
${value}`, dataLines++;
} else isEventPrefix(chunk, searchIndex, firstCharCode) ? eventType = chunk.slice(
chunk.charCodeAt(searchIndex + 6) === SPACE ? searchIndex + 7 : searchIndex + 6,
lfIndex
) || void 0 : parseLine(chunk, searchIndex, lfIndex);
searchIndex = lfIndex + 1, lfIndex = chunk.indexOf(`
`, searchIndex);
}
return chunk.slice(searchIndex);
}
for (; searchIndex < chunk.length; ) {
const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(`
`, searchIndex);
let lineEnd = -1;
if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = crIndex < lfIndex ? crIndex : lfIndex : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1)
break;
parseLine(chunk, searchIndex, lineEnd), searchIndex = lineEnd + 1, chunk.charCodeAt(searchIndex - 1) === CR && chunk.charCodeAt(searchIndex) === LF && searchIndex++;
}
return chunk.slice(searchIndex);
}
function parseLine(chunk, start, end) {
if (start === end) {
function parseLine(line) {
if (line === "") {
dispatchEvent();
return;
}
const firstCharCode = chunk.charCodeAt(start);
if (isDataPrefix(chunk, start, firstCharCode)) {
const valueStart = chunk.charCodeAt(start + 5) === SPACE ? start + 6 : start + 5, value2 = chunk.slice(valueStart, end);
data = dataLines === 0 ? value2 : `${data}
${value2}`, dataLines++;
if (line.startsWith(":")) {
onComment && onComment(line.slice(line.startsWith(": ") ? 2 : 1));
return;
}
if (isEventPrefix(chunk, start, firstCharCode)) {
eventType = chunk.slice(chunk.charCodeAt(start + 6) === SPACE ? start + 7 : start + 6, end) || void 0;
const fieldSeparatorIndex = line.indexOf(":");
if (fieldSeparatorIndex !== -1) {
const field = line.slice(0, fieldSeparatorIndex), offset = line[fieldSeparatorIndex + 1] === " " ? 2 : 1, value = line.slice(fieldSeparatorIndex + offset);
processField(field, value, line);
return;
}
if (firstCharCode === 105 && chunk.charCodeAt(start + 1) === 100 && chunk.charCodeAt(start + 2) === 58) {
const value2 = chunk.slice(chunk.charCodeAt(start + 3) === SPACE ? start + 4 : start + 3, end);
id = value2.includes("\0") ? void 0 : value2;
return;
}
if (firstCharCode === 58) {
if (onComment) {
const line2 = chunk.slice(start, end);
onComment(line2.slice(chunk.charCodeAt(start + 1) === SPACE ? 2 : 1));
}
return;
}
const line = chunk.slice(start, end), fieldSeparatorIndex = line.indexOf(":");
if (fieldSeparatorIndex === -1) {
processField(line, "", line);
return;
}
const field = line.slice(0, fieldSeparatorIndex), offset = line.charCodeAt(fieldSeparatorIndex + 1) === SPACE ? 2 : 1, value = line.slice(fieldSeparatorIndex + offset);
processField(field, value, line);
processField(line, "", line);
}
function processField(field, value, line) {
switch (field) {
case "event":
eventType = value || void 0;
eventType = value;
break;
case "data":
data = dataLines === 0 ? value : `${data}
${value}`, dataLines++;
data = `${data}${value}
`;
break;
case "id":
id = value.includes("\0") ? void 0 : value;
@@ -140,26 +69,37 @@ ${value}`, dataLines++;
}
}
function dispatchEvent() {
dataLines > 0 && onEvent({
data.length > 0 && onEvent({
id,
event: eventType,
data
}), id = void 0, data = "", dataLines = 0, eventType = void 0;
event: eventType || void 0,
// 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(`
`) ? data.slice(0, -1) : data
}), id = void 0, data = "", eventType = "";
}
function reset(options = {}) {
if (options.consume && pendingFragments.length > 0) {
const incompleteLine = pendingFragments.join("");
parseLine(incompleteLine, 0, incompleteLine.length);
}
isFirstChunk = !0, id = void 0, data = "", dataLines = 0, eventType = void 0, pendingFragments.length = 0;
incompleteLine && options.consume && parseLine(incompleteLine), isFirstChunk = !0, id = void 0, data = "", eventType = "", incompleteLine = "";
}
return { feed, reset };
}
function isDataPrefix(chunk, i, firstCharCode) {
return firstCharCode === 100 && chunk.charCodeAt(i + 1) === 97 && chunk.charCodeAt(i + 2) === 116 && chunk.charCodeAt(i + 3) === 97 && chunk.charCodeAt(i + 4) === 58;
}
function isEventPrefix(chunk, i, firstCharCode) {
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 splitLines(chunk) {
const lines = [];
let incompleteLine = "", searchIndex = 0;
for (; searchIndex < chunk.length; ) {
const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(`
`, searchIndex);
let lineEnd = -1;
if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) {
incompleteLine = chunk.slice(searchIndex);
break;
} else {
const line = chunk.slice(searchIndex, lineEnd);
lines.push(line), searchIndex = lineEnd + 1, chunk[searchIndex - 1] === "\r" && chunk[searchIndex] === `
` && searchIndex++;
}
}
return [lines, incompleteLine];
}
exports.ParseError = ParseError;
exports.createParser = createParser;
File diff suppressed because one or more lines are too long
+21 -23
View File
@@ -10,15 +10,13 @@
* @returns A new EventSource parser, with `parse` and `reset` methods.
* @public
*/
export declare function createParser(
callbacks: ParserCallbacks,
): EventSourceParser;
export declare function createParser(callbacks: ParserCallbacks): EventSourceParser
/**
* The type of error that occurred.
* @public
*/
export declare type ErrorType = "invalid-retry" | "unknown-field";
export declare type ErrorType = 'invalid-retry' | 'unknown-field'
/**
* A parsed EventSource message event
@@ -31,16 +29,16 @@ export declare interface EventSourceMessage {
* implementation in that browsers will default this to `message`, whereas this parser will
* leave this as `undefined` if not explicitly declared.
*/
event?: string | undefined;
event?: string | undefined
/**
* ID of the message, if any was provided by the server. Can be used by clients to keep the
* last received message ID in sync when reconnecting.
*/
id?: string | undefined;
id?: string | undefined
/**
* The data received for this message
*/
data: string;
data: string
}
/**
@@ -59,7 +57,7 @@ export declare interface EventSourceParser {
* @param chunk - The chunk to parse. Can be a partial, eg in the case of streaming messages.
* @public
*/
feed(chunk: string): void;
feed(chunk: string): void
/**
* Resets the parser state. This is required when you have a new stream of messages -
* for instance in the case of a client being disconnected and reconnecting.
@@ -71,7 +69,7 @@ export declare interface EventSourceParser {
*
* @public
*/
reset(options?: { consume?: boolean }): void;
reset(options?: {consume?: boolean}): void
}
/**
@@ -83,28 +81,28 @@ export declare class ParseError extends Error {
/**
* The type of error that occurred.
*/
type: ErrorType;
type: ErrorType
/**
* In the case of an unknown field encountered in the stream, this will be the field name.
*/
field?: string | undefined;
field?: string | undefined
/**
* In the case of an unknown field encountered in the stream, this will be the value of the field.
*/
value?: string | undefined;
value?: string | undefined
/**
* The line that caused the error, if available.
*/
line?: string | undefined;
line?: string | undefined
constructor(
message: string,
options: {
type: ErrorType;
field?: string;
value?: string;
line?: string;
type: ErrorType
field?: string
value?: string
line?: string
},
);
)
}
/**
@@ -120,19 +118,19 @@ export declare interface ParserCallbacks {
*
* @param event - The parsed event/message
*/
onEvent?: ((event: EventSourceMessage) => void) | undefined;
onEvent?: ((event: EventSourceMessage) => void) | undefined
/**
* Callback for when the server sends a new reconnection interval through the `retry` field.
*
* @param retry - The number of milliseconds to wait before reconnecting.
*/
onRetry?: ((retry: number) => void) | undefined;
onRetry?: ((retry: number) => void) | undefined
/**
* Callback for when a comment is encountered in the stream.
*
* @param comment - The comment encountered in the stream.
*/
onComment?: ((comment: string) => void) | undefined;
onComment?: ((comment: string) => void) | undefined
/**
* Callback for when an error occurs during parsing. This is a catch-all for any errors
* that occur during parsing, and can be used to handle them in a custom way. Most clients
@@ -140,7 +138,7 @@ export declare interface ParserCallbacks {
*
* @param error - The error that occurred during parsing
*/
onError?: ((error: ParseError) => void) | undefined;
onError?: ((error: ParseError) => void) | undefined
}
export {};
export {}
+21 -23
View File
@@ -10,15 +10,13 @@
* @returns A new EventSource parser, with `parse` and `reset` methods.
* @public
*/
export declare function createParser(
callbacks: ParserCallbacks,
): EventSourceParser;
export declare function createParser(callbacks: ParserCallbacks): EventSourceParser
/**
* The type of error that occurred.
* @public
*/
export declare type ErrorType = "invalid-retry" | "unknown-field";
export declare type ErrorType = 'invalid-retry' | 'unknown-field'
/**
* A parsed EventSource message event
@@ -31,16 +29,16 @@ export declare interface EventSourceMessage {
* implementation in that browsers will default this to `message`, whereas this parser will
* leave this as `undefined` if not explicitly declared.
*/
event?: string | undefined;
event?: string | undefined
/**
* ID of the message, if any was provided by the server. Can be used by clients to keep the
* last received message ID in sync when reconnecting.
*/
id?: string | undefined;
id?: string | undefined
/**
* The data received for this message
*/
data: string;
data: string
}
/**
@@ -59,7 +57,7 @@ export declare interface EventSourceParser {
* @param chunk - The chunk to parse. Can be a partial, eg in the case of streaming messages.
* @public
*/
feed(chunk: string): void;
feed(chunk: string): void
/**
* Resets the parser state. This is required when you have a new stream of messages -
* for instance in the case of a client being disconnected and reconnecting.
@@ -71,7 +69,7 @@ export declare interface EventSourceParser {
*
* @public
*/
reset(options?: { consume?: boolean }): void;
reset(options?: {consume?: boolean}): void
}
/**
@@ -83,28 +81,28 @@ export declare class ParseError extends Error {
/**
* The type of error that occurred.
*/
type: ErrorType;
type: ErrorType
/**
* In the case of an unknown field encountered in the stream, this will be the field name.
*/
field?: string | undefined;
field?: string | undefined
/**
* In the case of an unknown field encountered in the stream, this will be the value of the field.
*/
value?: string | undefined;
value?: string | undefined
/**
* The line that caused the error, if available.
*/
line?: string | undefined;
line?: string | undefined
constructor(
message: string,
options: {
type: ErrorType;
field?: string;
value?: string;
line?: string;
type: ErrorType
field?: string
value?: string
line?: string
},
);
)
}
/**
@@ -120,19 +118,19 @@ export declare interface ParserCallbacks {
*
* @param event - The parsed event/message
*/
onEvent?: ((event: EventSourceMessage) => void) | undefined;
onEvent?: ((event: EventSourceMessage) => void) | undefined
/**
* Callback for when the server sends a new reconnection interval through the `retry` field.
*
* @param retry - The number of milliseconds to wait before reconnecting.
*/
onRetry?: ((retry: number) => void) | undefined;
onRetry?: ((retry: number) => void) | undefined
/**
* Callback for when a comment is encountered in the stream.
*
* @param comment - The comment encountered in the stream.
*/
onComment?: ((comment: string) => void) | undefined;
onComment?: ((comment: string) => void) | undefined
/**
* Callback for when an error occurs during parsing. This is a catch-all for any errors
* that occur during parsing, and can be used to handle them in a custom way. Most clients
@@ -140,7 +138,7 @@ export declare interface ParserCallbacks {
*
* @param error - The error that occurred during parsing
*/
onError?: ((error: ParseError) => void) | undefined;
onError?: ((error: ParseError) => void) | undefined
}
export {};
export {}
+44 -104
View File
@@ -3,7 +3,6 @@ class ParseError extends Error {
super(message), this.name = "ParseError", this.type = options.type, this.field = options.field, this.value = options.value, this.line = options.line;
}
}
const LF = 10, CR = 13, SPACE = 32;
function noop(_arg) {
}
function createParser(callbacks) {
@@ -11,109 +10,39 @@ function createParser(callbacks) {
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, pendingFragments = [];
let isFirstChunk = !0, id, data = "", dataLines = 0, eventType;
function feed(chunk) {
if (isFirstChunk && (isFirstChunk = !1, chunk.charCodeAt(0) === 239 && chunk.charCodeAt(1) === 187 && chunk.charCodeAt(2) === 191 && (chunk = chunk.slice(3))), pendingFragments.length === 0) {
const trailing2 = processLines(chunk);
trailing2 !== "" && pendingFragments.push(trailing2);
return;
}
if (chunk.indexOf(`
`) === -1 && chunk.indexOf("\r") === -1) {
pendingFragments.push(chunk);
return;
}
pendingFragments.push(chunk);
const input = pendingFragments.join("");
pendingFragments.length = 0;
const trailing = processLines(input);
trailing !== "" && pendingFragments.push(trailing);
const { onEvent = noop, onError = noop, onRetry = noop, onComment } = callbacks;
let incompleteLine = "", isFirstChunk = !0, id, data = "", eventType = "";
function feed(newChunk) {
const chunk = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk}`);
for (const line of complete)
parseLine(line);
incompleteLine = incomplete, isFirstChunk = !1;
}
function processLines(chunk) {
let searchIndex = 0;
if (chunk.indexOf("\r") === -1) {
let lfIndex = chunk.indexOf(`
`, searchIndex);
for (; lfIndex !== -1; ) {
if (searchIndex === lfIndex) {
dataLines > 0 && onEvent({ id, event: eventType, data }), id = void 0, data = "", dataLines = 0, eventType = void 0, searchIndex = lfIndex + 1, lfIndex = chunk.indexOf(`
`, searchIndex);
continue;
}
const firstCharCode = chunk.charCodeAt(searchIndex);
if (isDataPrefix(chunk, searchIndex, firstCharCode)) {
const valueStart = chunk.charCodeAt(searchIndex + 5) === SPACE ? searchIndex + 6 : searchIndex + 5, value = chunk.slice(valueStart, lfIndex);
if (dataLines === 0 && chunk.charCodeAt(lfIndex + 1) === LF) {
onEvent({ id, event: eventType, data: value }), id = void 0, data = "", eventType = void 0, searchIndex = lfIndex + 2, lfIndex = chunk.indexOf(`
`, searchIndex);
continue;
}
data = dataLines === 0 ? value : `${data}
${value}`, dataLines++;
} else isEventPrefix(chunk, searchIndex, firstCharCode) ? eventType = chunk.slice(
chunk.charCodeAt(searchIndex + 6) === SPACE ? searchIndex + 7 : searchIndex + 6,
lfIndex
) || void 0 : parseLine(chunk, searchIndex, lfIndex);
searchIndex = lfIndex + 1, lfIndex = chunk.indexOf(`
`, searchIndex);
}
return chunk.slice(searchIndex);
}
for (; searchIndex < chunk.length; ) {
const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(`
`, searchIndex);
let lineEnd = -1;
if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = crIndex < lfIndex ? crIndex : lfIndex : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1)
break;
parseLine(chunk, searchIndex, lineEnd), searchIndex = lineEnd + 1, chunk.charCodeAt(searchIndex - 1) === CR && chunk.charCodeAt(searchIndex) === LF && searchIndex++;
}
return chunk.slice(searchIndex);
}
function parseLine(chunk, start, end) {
if (start === end) {
function parseLine(line) {
if (line === "") {
dispatchEvent();
return;
}
const firstCharCode = chunk.charCodeAt(start);
if (isDataPrefix(chunk, start, firstCharCode)) {
const valueStart = chunk.charCodeAt(start + 5) === SPACE ? start + 6 : start + 5, value2 = chunk.slice(valueStart, end);
data = dataLines === 0 ? value2 : `${data}
${value2}`, dataLines++;
if (line.startsWith(":")) {
onComment && onComment(line.slice(line.startsWith(": ") ? 2 : 1));
return;
}
if (isEventPrefix(chunk, start, firstCharCode)) {
eventType = chunk.slice(chunk.charCodeAt(start + 6) === SPACE ? start + 7 : start + 6, end) || void 0;
const fieldSeparatorIndex = line.indexOf(":");
if (fieldSeparatorIndex !== -1) {
const field = line.slice(0, fieldSeparatorIndex), offset = line[fieldSeparatorIndex + 1] === " " ? 2 : 1, value = line.slice(fieldSeparatorIndex + offset);
processField(field, value, line);
return;
}
if (firstCharCode === 105 && chunk.charCodeAt(start + 1) === 100 && chunk.charCodeAt(start + 2) === 58) {
const value2 = chunk.slice(chunk.charCodeAt(start + 3) === SPACE ? start + 4 : start + 3, end);
id = value2.includes("\0") ? void 0 : value2;
return;
}
if (firstCharCode === 58) {
if (onComment) {
const line2 = chunk.slice(start, end);
onComment(line2.slice(chunk.charCodeAt(start + 1) === SPACE ? 2 : 1));
}
return;
}
const line = chunk.slice(start, end), fieldSeparatorIndex = line.indexOf(":");
if (fieldSeparatorIndex === -1) {
processField(line, "", line);
return;
}
const field = line.slice(0, fieldSeparatorIndex), offset = line.charCodeAt(fieldSeparatorIndex + 1) === SPACE ? 2 : 1, value = line.slice(fieldSeparatorIndex + offset);
processField(field, value, line);
processField(line, "", line);
}
function processField(field, value, line) {
switch (field) {
case "event":
eventType = value || void 0;
eventType = value;
break;
case "data":
data = dataLines === 0 ? value : `${data}
${value}`, dataLines++;
data = `${data}${value}
`;
break;
case "id":
id = value.includes("\0") ? void 0 : value;
@@ -138,26 +67,37 @@ ${value}`, dataLines++;
}
}
function dispatchEvent() {
dataLines > 0 && onEvent({
data.length > 0 && onEvent({
id,
event: eventType,
data
}), id = void 0, data = "", dataLines = 0, eventType = void 0;
event: eventType || void 0,
// 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(`
`) ? data.slice(0, -1) : data
}), id = void 0, data = "", eventType = "";
}
function reset(options = {}) {
if (options.consume && pendingFragments.length > 0) {
const incompleteLine = pendingFragments.join("");
parseLine(incompleteLine, 0, incompleteLine.length);
}
isFirstChunk = !0, id = void 0, data = "", dataLines = 0, eventType = void 0, pendingFragments.length = 0;
incompleteLine && options.consume && parseLine(incompleteLine), isFirstChunk = !0, id = void 0, data = "", eventType = "", incompleteLine = "";
}
return { feed, reset };
}
function isDataPrefix(chunk, i, firstCharCode) {
return firstCharCode === 100 && chunk.charCodeAt(i + 1) === 97 && chunk.charCodeAt(i + 2) === 116 && chunk.charCodeAt(i + 3) === 97 && chunk.charCodeAt(i + 4) === 58;
}
function isEventPrefix(chunk, i, firstCharCode) {
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 splitLines(chunk) {
const lines = [];
let incompleteLine = "", searchIndex = 0;
for (; searchIndex < chunk.length; ) {
const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(`
`, searchIndex);
let lineEnd = -1;
if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) {
incompleteLine = chunk.slice(searchIndex);
break;
} else {
const line = chunk.slice(searchIndex, lineEnd);
lines.push(line), searchIndex = lineEnd + 1, chunk[searchIndex - 1] === "\r" && chunk[searchIndex] === `
` && searchIndex++;
}
}
return [lines, incompleteLine];
}
export {
ParseError,
+1 -1
View File
File diff suppressed because one or more lines are too long
+19 -22
View File
@@ -2,7 +2,7 @@
* The type of error that occurred.
* @public
*/
export declare type ErrorType = "invalid-retry" | "unknown-field";
export declare type ErrorType = 'invalid-retry' | 'unknown-field'
/**
* A parsed EventSource message event
@@ -15,16 +15,16 @@ export declare interface EventSourceMessage {
* implementation in that browsers will default this to `message`, whereas this parser will
* leave this as `undefined` if not explicitly declared.
*/
event?: string | undefined;
event?: string | undefined
/**
* ID of the message, if any was provided by the server. Can be used by clients to keep the
* last received message ID in sync when reconnecting.
*/
id?: string | undefined;
id?: string | undefined
/**
* The data received for this message
*/
data: string;
data: string
}
/**
@@ -48,11 +48,8 @@ export declare interface EventSourceMessage {
*
* @public
*/
export declare class EventSourceParserStream extends TransformStream<
string,
EventSourceMessage
> {
constructor({ onError, onRetry, onComment }?: StreamOptions);
export declare class EventSourceParserStream extends TransformStream<string, EventSourceMessage> {
constructor({onError, onRetry, onComment}?: StreamOptions)
}
/**
@@ -64,28 +61,28 @@ export declare class ParseError extends Error {
/**
* The type of error that occurred.
*/
type: ErrorType;
type: ErrorType
/**
* In the case of an unknown field encountered in the stream, this will be the field name.
*/
field?: string | undefined;
field?: string | undefined
/**
* In the case of an unknown field encountered in the stream, this will be the value of the field.
*/
value?: string | undefined;
value?: string | undefined
/**
* The line that caused the error, if available.
*/
line?: string | undefined;
line?: string | undefined
constructor(
message: string,
options: {
type: ErrorType;
field?: string;
value?: string;
line?: string;
type: ErrorType
field?: string
value?: string
line?: string
},
);
)
}
/**
@@ -103,19 +100,19 @@ export declare interface StreamOptions {
*
* @defaultValue `undefined`
*/
onError?: ("terminate" | ((error: Error) => void)) | undefined;
onError?: ('terminate' | ((error: Error) => void)) | undefined
/**
* Callback for when a reconnection interval is sent from the server.
*
* @param retry - The number of milliseconds to wait before reconnecting.
*/
onRetry?: ((retry: number) => void) | undefined;
onRetry?: ((retry: number) => void) | undefined
/**
* Callback for when a comment is encountered in the stream.
*
* @param comment - The comment encountered in the stream.
*/
onComment?: ((comment: string) => void) | undefined;
onComment?: ((comment: string) => void) | undefined
}
export {};
export {}
+19 -22
View File
@@ -2,7 +2,7 @@
* The type of error that occurred.
* @public
*/
export declare type ErrorType = "invalid-retry" | "unknown-field";
export declare type ErrorType = 'invalid-retry' | 'unknown-field'
/**
* A parsed EventSource message event
@@ -15,16 +15,16 @@ export declare interface EventSourceMessage {
* implementation in that browsers will default this to `message`, whereas this parser will
* leave this as `undefined` if not explicitly declared.
*/
event?: string | undefined;
event?: string | undefined
/**
* ID of the message, if any was provided by the server. Can be used by clients to keep the
* last received message ID in sync when reconnecting.
*/
id?: string | undefined;
id?: string | undefined
/**
* The data received for this message
*/
data: string;
data: string
}
/**
@@ -48,11 +48,8 @@ export declare interface EventSourceMessage {
*
* @public
*/
export declare class EventSourceParserStream extends TransformStream<
string,
EventSourceMessage
> {
constructor({ onError, onRetry, onComment }?: StreamOptions);
export declare class EventSourceParserStream extends TransformStream<string, EventSourceMessage> {
constructor({onError, onRetry, onComment}?: StreamOptions)
}
/**
@@ -64,28 +61,28 @@ export declare class ParseError extends Error {
/**
* The type of error that occurred.
*/
type: ErrorType;
type: ErrorType
/**
* In the case of an unknown field encountered in the stream, this will be the field name.
*/
field?: string | undefined;
field?: string | undefined
/**
* In the case of an unknown field encountered in the stream, this will be the value of the field.
*/
value?: string | undefined;
value?: string | undefined
/**
* The line that caused the error, if available.
*/
line?: string | undefined;
line?: string | undefined
constructor(
message: string,
options: {
type: ErrorType;
field?: string;
value?: string;
line?: string;
type: ErrorType
field?: string
value?: string
line?: string
},
);
)
}
/**
@@ -103,19 +100,19 @@ export declare interface StreamOptions {
*
* @defaultValue `undefined`
*/
onError?: ("terminate" | ((error: Error) => void)) | undefined;
onError?: ('terminate' | ((error: Error) => void)) | undefined
/**
* Callback for when a reconnection interval is sent from the server.
*
* @param retry - The number of milliseconds to wait before reconnecting.
*/
onRetry?: ((retry: number) => void) | undefined;
onRetry?: ((retry: number) => void) | undefined
/**
* Callback for when a comment is encountered in the stream.
*
* @param comment - The comment encountered in the stream.
*/
onComment?: ((comment: string) => void) | undefined;
onComment?: ((comment: string) => void) | undefined
}
export {};
export {}
+81 -58
View File
@@ -1,34 +1,12 @@
{
"name": "eventsource-parser",
"version": "3.0.8",
"version": "3.0.6",
"description": "Streaming, source-agnostic EventSource/Server-Sent Events parser",
"keywords": [
"eventsource",
"server-sent-events",
"sse"
],
"homepage": "https://github.com/rexxars/eventsource-parser#readme",
"bugs": {
"url": "https://github.com/rexxars/eventsource-parser/issues"
},
"license": "MIT",
"author": "Espen Hovlandsdal <espen@hovlandsdal.com>",
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/rexxars/eventsource-parser.git"
},
"files": [
"dist",
"!dist/stats.html",
"!dist/index.min.js",
"src",
"stream.js"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"type": "module",
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"main": "./dist/index.cjs",
"exports": {
".": {
"source": "./src/index.ts",
@@ -44,16 +22,33 @@
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"stream": [
"./dist/stream.d.ts"
]
}
},
"engines": {
"node": ">=18.0.0"
},
"browserslist": [
"node >= 20",
"chrome >= 71",
"safari >= 14.1",
"firefox >= 105",
"edge >= 79"
],
"files": [
"dist",
"!dist/stats.html",
"src",
"stream.js"
],
"scripts": {
"build": "pkg-utils build && pkg-utils --strict",
"clean": "rimraf dist coverage",
"check": "npm run clean && npm run format && npm run lint && npm run build && vitest run",
"format": "oxfmt",
"format:check": "oxfmt --check",
"bench": "node --expose-gc --experimental-strip-types --no-warnings=ExperimentalWarning bench/parse.bench.ts",
"bundle-size": "node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/bundle-size.ts",
"knip": "knip",
"lint": "oxlint && tsc --noEmit",
"lint": "eslint . && tsc --noEmit",
"posttest": "npm run lint",
"prebuild": "npm run clean",
"prepublishOnly": "npm run build",
@@ -62,31 +57,59 @@
"test:deno": "deno run --allow-write --allow-net --allow-run --allow-sys --allow-ffi --allow-env --allow-read npm:vitest",
"test:node": "vitest --reporter=verbose"
},
"devDependencies": {
"@sanity/pkg-utils": "^10.4.15",
"@sanity/semantic-release-preset": "^6.0.0",
"@sanity/tsconfig": "^2.1.0",
"@types/node": "^20.19.0",
"eventsource-encoder": "^1.0.1",
"knip": "^6.4.1",
"mitata": "^1.0.34",
"oxfmt": "^0.45.0",
"oxlint": "^1.60.0",
"rimraf": "^6.1.3",
"rollup-plugin-visualizer": "^6.0.3",
"semantic-release": "^25.0.3",
"terser": "^5.46.1",
"typescript": "^5.9.3",
"vitest": "^4.1.4"
},
"browserslist": [
"node >= 18",
"chrome >= 71",
"safari >= 14.1",
"firefox >= 105",
"edge >= 79"
"author": "Espen Hovlandsdal <espen@hovlandsdal.com>",
"keywords": [
"sse",
"eventsource",
"server-sent-events"
],
"engines": {
"node": ">=18.0.0"
"devDependencies": {
"@sanity/pkg-utils": "^8.0.0",
"@sanity/semantic-release-preset": "^5.0.0",
"@types/node": "^20.19.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.51.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-sanity": "^7.1.2",
"eventsource-encoder": "^1.0.1",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"rollup-plugin-visualizer": "^6.0.3",
"semantic-release": "^24.2.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
},
"homepage": "https://github.com/rexxars/eventsource-parser#readme",
"bugs": {
"url": "https://github.com/rexxars/eventsource-parser/issues"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/rexxars/eventsource-parser.git"
},
"license": "MIT",
"prettier": {
"bracketSpacing": false,
"printWidth": 100,
"semi": false,
"singleQuote": true
},
"eslintConfig": {
"parserOptions": {
"ecmaFeatures": {
"modules": true
},
"ecmaVersion": 9,
"sourceType": "module"
},
"extends": [
"sanity",
"sanity/typescript",
"prettier"
],
"ignorePatterns": [
"lib/**/"
]
}
}
+109 -272
View File
@@ -5,12 +5,7 @@
import {ParseError} from './errors.ts'
import type {EventSourceParser, ParserCallbacks} from './types.ts'
// ASCII codes used in the hot parsing paths.
const LF = 10
const CR = 13
const SPACE = 32
// oxlint-disable-next-line no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function noop(_arg: unknown) {
// intentional noop
}
@@ -36,247 +31,66 @@ export function createParser(callbacks: ParserCallbacks): EventSourceParser {
const {onEvent = noop, onError = noop, onRetry = noop, onComment} = callbacks
// 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[] = []
let incompleteLine = ''
let isFirstChunk = true
let id: string | undefined
let data = ''
let dataLines = 0
let eventType: string | undefined
let eventType = ''
/**
* 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, a UTF-8 BOM (0xEF 0xBB 0xBF) at the start of the very
* first chunk is stripped before parsing.
*
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream
*/
function feed(chunk: string) {
if (isFirstChunk) {
isFirstChunk = false
// Match and strip UTF-8 BOM from the start of the stream, if present.
// (Per the spec, this is only valid at the very start of the stream)
if (
chunk.charCodeAt(0) === 0xef &&
chunk.charCodeAt(1) === 0xbb &&
chunk.charCodeAt(2) === 0xbf
) {
chunk = chunk.slice(3)
}
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)
}
// 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 === 0) {
const trailing = processLines(chunk)
if (trailing !== '') pendingFragments.push(trailing)
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) {
pendingFragments.push(chunk)
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
const trailing = processLines(input)
if (trailing !== '') pendingFragments.push(trailing)
incompleteLine = incomplete
isFirstChunk = false
}
/**
* 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 (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) {
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) {
function parseLine(line: string) {
// If the line is empty (a blank line), dispatch the event
if (line === '') {
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)
id = value.includes('\0') ? undefined : value
return
}
// Comment line — ':' = 58
if (firstCharCode === 58) {
// If the line starts with a U+003A COLON character (:), ignore the line.
if (line.startsWith(':')) {
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))
onComment(line.slice(line.startsWith(': ') ? 2 : 1))
}
return
}
const line = chunk.slice(start, end)
// If the line contains a U+003A COLON character (:)
const fieldSeparatorIndex = line.indexOf(':')
if (fieldSeparatorIndex === -1) {
processField(line, '', line)
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
}
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)
// 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) {
@@ -284,11 +98,12 @@ export function createParser(callbacks: ParserCallbacks): EventSourceParser {
switch (field) {
case 'event':
// Set the `event type` buffer to field value
eventType = value || undefined
eventType = value
break
case 'data':
data = dataLines === 0 ? value : `${data}\n${value}`
dataLines++
// 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
@@ -324,72 +139,94 @@ export function createParser(callbacks: ParserCallbacks): EventSourceParser {
}
function dispatchEvent() {
if (dataLines > 0) {
const shouldDispatch = data.length > 0
if (shouldDispatch) {
onEvent({
id,
event: eventType,
data,
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 = ''
dataLines = 0
eventType = undefined
eventType = ''
}
function reset(options: {consume?: boolean} = {}) {
if (options.consume && pendingFragments.length > 0) {
const incompleteLine = pendingFragments.join('')
parseLine(incompleteLine, 0, incompleteLine.length)
if (incompleteLine && options.consume) {
parseLine(incompleteLine)
}
isFirstChunk = true
id = undefined
data = ''
dataLines = 0
eventType = undefined
pendingFragments.length = 0
eventType = ''
incompleteLine = ''
}
return {feed, reset}
}
/**
* Checks if `chunk` starts with the literal `data:` at index `i`.
* For the given `chunk`, split it into lines according to spec, and return any remaining incomplete line.
*
* 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
* @param chunk - The chunk to split into lines
* @returns A tuple containing an array of complete lines, and any remaining incomplete line
* @internal
*/
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
)
}
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
/**
* 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
)
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) {
// CR at the end of a chunk might be part of a CRLF sequence that spans chunks,
// so we shouldn't treat it as a line terminator (yet)
if (crIndex === chunk.length - 1) {
lineEnd = -1
} else {
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]
}