Add tests. Add exponential backoff contexts. Update error handling. Imrove Event Parser compatibility. Simplify SSE Session. Simplify Async Iterator.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { SSEvent } from './types.js';
|
||||
import type { SSEvent } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Optional encoders used when decoding incoming SSE bytes and re-encoding
|
||||
@@ -7,11 +7,34 @@ import type { SSEvent } from './types.js';
|
||||
export interface SSEEventParserOptions {
|
||||
/** Decodes raw stream bytes into text. Defaults to a new `TextDecoder`. */
|
||||
textDecoder: TextDecoder;
|
||||
|
||||
/** Encodes buffered remainder bytes between parse calls. Defaults to a new `TextEncoder`. */
|
||||
textEncoder: TextEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex that splits decoded SSE text into lines.
|
||||
*
|
||||
* The SSE wire format is line-oriented (`field: value` per line). Servers may
|
||||
* send `\r\n` (HTTP default), `\n` (Unix), or `\r` (legacy Mac). Matching all
|
||||
* three keeps parsing correct regardless of platform or server implementation.
|
||||
*/
|
||||
export const SSE_LINE_ENDINGS = /\r\n|\r|\n/;
|
||||
|
||||
/**
|
||||
* Regex that matches the single optional leading space in an SSE field value.
|
||||
*
|
||||
* Per the SSE spec, `field: value` may include one space immediately after the
|
||||
* colon; that space is not part of the value. Used with `.replace()` to strip
|
||||
* it when parsing lines such as `data: hello` → `hello`.
|
||||
*/
|
||||
export const SSE_FIELD_VALUE_REGEX = /^ /;
|
||||
|
||||
/**
|
||||
* The newline character used when normalizing SSE text internally.
|
||||
*
|
||||
* Used to join consecutive `data:` lines into one payload and to reassemble
|
||||
* buffered partial lines between streamed chunks before the next parse call.
|
||||
*/
|
||||
export const NEW_LINE = "\n";
|
||||
|
||||
/**
|
||||
* Incrementally parses Server-Sent Events (SSE) from streamed byte chunks.
|
||||
*
|
||||
@@ -38,10 +61,9 @@ export interface SSEEventParserOptions {
|
||||
*/
|
||||
export class SSEEventParser {
|
||||
private readonly textDecoder: TextDecoder;
|
||||
private readonly textEncoder: TextEncoder;
|
||||
|
||||
/** Bytes from a partial line or incomplete event, carried over to the next chunk. */
|
||||
private messageBuffer: Uint8Array = new Uint8Array();
|
||||
private messageBuffer: string = "";
|
||||
|
||||
/**
|
||||
* Creates a parser for one SSE stream.
|
||||
@@ -53,7 +75,6 @@ export class SSEEventParser {
|
||||
*/
|
||||
constructor(options: Partial<SSEEventParserOptions> = {}) {
|
||||
this.textDecoder = options.textDecoder ?? new TextDecoder();
|
||||
this.textEncoder = options.textEncoder ?? new TextEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +84,11 @@ export class SSEEventParser {
|
||||
* stale bytes to incoming chunks.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.messageBuffer = new Uint8Array();
|
||||
// Clear the message buffer
|
||||
this.messageBuffer = "";
|
||||
|
||||
// Reset the decoder to clear any buffered bytes
|
||||
this.textDecoder.decode();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,18 +105,26 @@ export class SSEEventParser {
|
||||
public parseEvents(chunk: Uint8Array): SSEvent[] {
|
||||
const lines = this.getBufferedLines(chunk);
|
||||
|
||||
// The final split item is the incomplete remainder after the final line
|
||||
// ending. It must not be processed as a complete SSE line.
|
||||
const completeLines = lines.slice(0, -1);
|
||||
|
||||
const events: SSEvent[] = [];
|
||||
let event: Partial<SSEvent> = {};
|
||||
let processedLineCount = 0;
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
for (const [index, line] of completeLines.entries()) {
|
||||
// A blank line indicates the end of an event. If we have received data, we can complete the event
|
||||
if (line === "") {
|
||||
if (event.data) {
|
||||
events.push(this.completeEvent(event));
|
||||
event = {};
|
||||
processedLineCount = index + 1;
|
||||
if (event.data !== undefined) {
|
||||
// Cast event when pushing to an SSE Event so it changes from Partial<SSEvent> to SSEvent
|
||||
// The "Partial" can safely be removed as we have guaranteed that `data` is defined
|
||||
events.push(event as SSEvent);
|
||||
}
|
||||
|
||||
event = {};
|
||||
processedLineCount = index + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,46 +144,55 @@ export class SSEEventParser {
|
||||
* regardless of server or platform conventions.
|
||||
*/
|
||||
private getBufferedLines(chunk: Uint8Array): string[] {
|
||||
this.messageBuffer = new Uint8Array([
|
||||
...this.messageBuffer,
|
||||
...chunk,
|
||||
]);
|
||||
this.messageBuffer += this.textDecoder.decode(chunk, { stream: true });
|
||||
|
||||
return this.textDecoder
|
||||
.decode(this.messageBuffer)
|
||||
.split(/\r\n|\r|\n/);
|
||||
return this.messageBuffer.split(SSE_LINE_ENDINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one SSE field line into an in-progress event.
|
||||
*
|
||||
* Lines without a colon are ignored. A single optional space after the colon
|
||||
* Lines starting with a colon are ignored as comments. A single optional space after the colon
|
||||
* is stripped from the field value, per the SSE spec.
|
||||
*/
|
||||
private parseLine(line: string, event: Partial<SSEvent>): void {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) return;
|
||||
// Split the line at the first colon. Before the first colon is the field, after the first colon is the value.
|
||||
const [field, ...valueArray] = line.split(":");
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
const value = line.slice(colonIndex + 1).replace(/^ /, "");
|
||||
// Join the rest of the array back together to get the value as it was before the split.
|
||||
// If the value has a space at the start, remove it (according to the SSE spec)
|
||||
const value = valueArray.join(":").replace(SSE_FIELD_VALUE_REGEX, "");
|
||||
|
||||
switch (field) {
|
||||
case "data":
|
||||
event.data = event.data
|
||||
? `${event.data}\n${value}`
|
||||
: value;
|
||||
// If this is NOT the first time we are adding a data line, this will be an empty string and we will add a new line.
|
||||
if (event.data !== undefined) {
|
||||
event.data += NEW_LINE;
|
||||
}
|
||||
|
||||
// If event.data hasnt been defined yet, set it to an empty string.
|
||||
event.data ??= "";
|
||||
|
||||
// Add the value to the event.data
|
||||
event.data += value;
|
||||
|
||||
// event.data = event.data !== undefined ? `${event.data}${NEW_LINE}${value}` : value;
|
||||
|
||||
return;
|
||||
|
||||
case "event":
|
||||
event.event = value;
|
||||
|
||||
return;
|
||||
|
||||
case "id":
|
||||
event.id = value;
|
||||
|
||||
return;
|
||||
|
||||
case "retry":
|
||||
this.parseRetry(value, event);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -168,19 +210,6 @@ export class SSEEventParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a completed SSE event from accumulated fields.
|
||||
*
|
||||
* Trims a trailing newline from multi-line `data` values so callers receive
|
||||
* the payload without an extra line break at the end.
|
||||
*/
|
||||
private completeEvent(event: Partial<SSEvent>): SSEvent {
|
||||
return {
|
||||
...event,
|
||||
data: event.data!.replace(/\n$/, ""),
|
||||
} as SSEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves incomplete trailing lines for the next received chunk.
|
||||
*
|
||||
@@ -191,10 +220,6 @@ export class SSEEventParser {
|
||||
lines: string[],
|
||||
processedLineCount: number,
|
||||
): void {
|
||||
const remainder = lines
|
||||
.slice(processedLineCount)
|
||||
.join("\n");
|
||||
|
||||
this.messageBuffer = this.textEncoder.encode(remainder);
|
||||
this.messageBuffer = lines.slice(processedLineCount).join(NEW_LINE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user