Initial Commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/dist
|
||||
/node_modules
|
||||
1277
package-lock.json
generated
Normal file
1277
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "ssesession",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
0
src/index.ts
Normal file
0
src/index.ts
Normal file
200
src/sse-event-parser.ts
Normal file
200
src/sse-event-parser.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { SSEvent } from './types.js';
|
||||
|
||||
/**
|
||||
* Optional encoders used when decoding incoming SSE bytes and re-encoding
|
||||
* any buffered remainder between chunks.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally parses Server-Sent Events (SSE) from streamed byte chunks.
|
||||
*
|
||||
* SSE payloads are line-oriented: each event is a sequence of `field: value`
|
||||
* lines terminated by a blank line. This parser accepts arbitrary chunk
|
||||
* boundaries from a live HTTP response body and emits only complete events.
|
||||
*
|
||||
* Typical usage is one parser instance per connection, calling {@link parseEvents}
|
||||
* for each chunk received from the stream:
|
||||
*
|
||||
* ```ts
|
||||
* const parser = new SSEEventParser();
|
||||
*
|
||||
* for await (const chunk of response.body) {
|
||||
* for (const event of parser.parseEvents(chunk)) {
|
||||
* // handle event.data, event.event, event.id, event.retry
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Supported fields follow the SSE spec: `data`, `event`, `id`, and `retry`.
|
||||
* Multiple `data:` lines in one event are joined with `\n`. An event is only
|
||||
* emitted once a blank line is seen and at least one `data` field was collected.
|
||||
*/
|
||||
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();
|
||||
|
||||
/**
|
||||
* Creates a parser for one SSE stream.
|
||||
*
|
||||
* Inject custom encoders in tests or when a non-default character encoding
|
||||
* is required; production callers can rely on the defaults.
|
||||
*
|
||||
* @param options - Optional text encoders for decode/encode of stream bytes.
|
||||
*/
|
||||
constructor(options: Partial<SSEEventParserOptions> = {}) {
|
||||
this.textDecoder = options.textDecoder ?? new TextDecoder();
|
||||
this.textEncoder = options.textEncoder ?? new TextEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any buffered bytes from a partial line or incomplete event.
|
||||
*
|
||||
* Call when abandoning a transport so the next connection does not prepend
|
||||
* stale bytes to incoming chunks.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.messageBuffer = new Uint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses all complete SSE events contained in a newly received chunk.
|
||||
*
|
||||
* The chunk is appended to any bytes buffered from earlier calls. Complete
|
||||
* events (blank-line delimited blocks with at least one `data` field) are
|
||||
* returned immediately; any trailing partial line or in-progress event stays
|
||||
* in the internal buffer until a later chunk completes it.
|
||||
*
|
||||
* @param chunk - Newly received SSE stream bytes.
|
||||
* @returns Zero or more complete parsed SSE events from this chunk.
|
||||
*/
|
||||
public parseEvents(chunk: Uint8Array): SSEvent[] {
|
||||
const lines = this.getBufferedLines(chunk);
|
||||
|
||||
const events: SSEvent[] = [];
|
||||
let event: Partial<SSEvent> = {};
|
||||
let processedLineCount = 0;
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (line === "") {
|
||||
if (event.data) {
|
||||
events.push(this.completeEvent(event));
|
||||
event = {};
|
||||
processedLineCount = index + 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
this.parseLine(line, event);
|
||||
}
|
||||
|
||||
this.storeRemainingLines(lines, processedLineCount);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new chunk to the buffered bytes and splits the combined payload
|
||||
* into lines.
|
||||
*
|
||||
* Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly
|
||||
* regardless of server or platform conventions.
|
||||
*/
|
||||
private getBufferedLines(chunk: Uint8Array): string[] {
|
||||
this.messageBuffer = new Uint8Array([
|
||||
...this.messageBuffer,
|
||||
...chunk,
|
||||
]);
|
||||
|
||||
return this.textDecoder
|
||||
.decode(this.messageBuffer)
|
||||
.split(/\r\n|\r|\n/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one SSE field line into an in-progress event.
|
||||
*
|
||||
* Lines without a colon are ignored. 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;
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
const value = line.slice(colonIndex + 1).replace(/^ /, "");
|
||||
|
||||
switch (field) {
|
||||
case "data":
|
||||
event.data = event.data
|
||||
? `${event.data}\n${value}`
|
||||
: value;
|
||||
return;
|
||||
|
||||
case "event":
|
||||
event.event = value;
|
||||
return;
|
||||
|
||||
case "id":
|
||||
event.id = value;
|
||||
return;
|
||||
|
||||
case "retry":
|
||||
this.parseRetry(value, event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a numeric `retry:` field to an in-progress event.
|
||||
*
|
||||
* Non-numeric values are ignored rather than failing the parse.
|
||||
*/
|
||||
private parseRetry(value: string, event: Partial<SSEvent>): void {
|
||||
const retry = parseInt(value, 10);
|
||||
|
||||
if (!isNaN(retry)) {
|
||||
event.retry = retry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Only lines that were fully processed (through a completed event boundary)
|
||||
* are discarded; the remainder is re-encoded into {@link messageBuffer}.
|
||||
*/
|
||||
private storeRemainingLines(
|
||||
lines: string[],
|
||||
processedLineCount: number,
|
||||
): void {
|
||||
const remainder = lines
|
||||
.slice(processedLineCount)
|
||||
.join("\n");
|
||||
|
||||
this.messageBuffer = this.textEncoder.encode(remainder);
|
||||
}
|
||||
}
|
||||
542
src/sse-session.ts
Normal file
542
src/sse-session.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import type {
|
||||
SSESessionOptions,
|
||||
SSESessionEventMap,
|
||||
SSEvent,
|
||||
} from "./types.js";
|
||||
|
||||
import { tryAsync } from "./utils/misc.js";
|
||||
import { EventEmitter } from "./utils/event-emitter.js";
|
||||
import { AsyncPushIterator } from "./utils/async-push-iterator.js";
|
||||
import { ExponentialBackoff } from "./utils/exponential-backoff.js";
|
||||
|
||||
import { SSEEventParser } from "./sse-event-parser.js";
|
||||
|
||||
/**
|
||||
* A fetch-based Server-Sent Events (SSE) client with reconnect and optional
|
||||
* browser tab visibility handling.
|
||||
*
|
||||
* Each session maintains one HTTP streaming connection at a time. Incoming
|
||||
* bytes are parsed into {@link SSEvent} objects and delivered through two
|
||||
* surfaces:
|
||||
*
|
||||
* - **Events** — `"connected"`, `"message"`, `"disconnected"`, `"error"`,
|
||||
* and `"closed"` on the session itself (extends {@link EventEmitter}).
|
||||
* - **Messages** — {@link messages}, an async iterable for `for await...of`
|
||||
* consumers.
|
||||
*
|
||||
* Typical usage:
|
||||
*
|
||||
* ```ts
|
||||
* const session = await SSESession.create("/events");
|
||||
*
|
||||
* session.on("message", (event) => console.log(event.data));
|
||||
*
|
||||
* for await (const event of session.messages) {
|
||||
* handle(event);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* - {@link connect} opens (or reopens) the transport. It resolves once the
|
||||
* HTTP stream is established; reading continues in the background.
|
||||
* - {@link abort} stops the in-flight fetch without ending the session.
|
||||
* Used internally for tab visibility. The {@link messages} iterator stays
|
||||
* open so an existing consumer resumes when the tab becomes visible again.
|
||||
* - {@link disconnect} aborts the transport, closes {@link messages}, emits
|
||||
* `"closed"`, and disables attached visibility handlers until the next
|
||||
* manual {@link connect}.
|
||||
*
|
||||
* Automatic reconnect is controlled by {@link SSESessionOptions.persistent}
|
||||
* (server closed the stream) and
|
||||
* {@link SSESessionOptions.attemptReconnect} (transport error).
|
||||
*
|
||||
* ## Connection supersession
|
||||
*
|
||||
* Each {@link connect} or {@link abort} bumps an internal `connectionId`.
|
||||
* Background read loops capture their id at start and exit quietly when a
|
||||
* newer connection supersedes them, avoiding duplicate events or errors from
|
||||
* stale transports.
|
||||
*/
|
||||
export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
/**
|
||||
* Creates a session and waits until the first connection is established.
|
||||
*
|
||||
* @param url - The SSE endpoint URL.
|
||||
* @param options - Configuration merged with instance defaults.
|
||||
* @returns A connected session.
|
||||
* @throws When the initial connection cannot be established.
|
||||
*/
|
||||
static async create(
|
||||
url: string,
|
||||
options: Partial<SSESessionOptions> = {},
|
||||
): Promise<SSESession> {
|
||||
const client = new SSESession(url, options);
|
||||
await client.connect();
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a session with tab visibility handling for browser clients.
|
||||
*
|
||||
* Registers {@link addBrowserVisibilityHandler} before connecting. If the
|
||||
* document is hidden at creation time (for example a background tab), the
|
||||
* initial connect is deferred until the tab becomes visible.
|
||||
*
|
||||
* @param url - The SSE endpoint URL.
|
||||
* @param options - Session configuration.
|
||||
* @returns A session with visibility handling attached. May not yet be
|
||||
* connected when the tab is hidden.
|
||||
*/
|
||||
static async withBrowserVisibility(
|
||||
url: string,
|
||||
options: Partial<SSESessionOptions> = {},
|
||||
): Promise<SSESession> {
|
||||
const client = new SSESession(url, options);
|
||||
|
||||
SSESession.addBrowserVisibilityHandler(client);
|
||||
|
||||
// Avoid opening a connection while the tab is in the background.
|
||||
if (
|
||||
typeof document === "undefined" ||
|
||||
document.visibilityState === "visible"
|
||||
) {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables SSE resume semantics by sending `Last-Event-ID` on reconnect.
|
||||
*
|
||||
* Listens for incoming `"message"` events and remembers the most recent
|
||||
* {@link SSEvent.id}. On every subsequent connect or reconnect, the session's
|
||||
* {@link onRequest} hook is wrapped so that header is attached when an id is
|
||||
* known, allowing the server to replay only events the client has not yet
|
||||
* received.
|
||||
*
|
||||
* The existing {@link onRequest} callback is preserved and runs after the
|
||||
* header is applied, so auth or other header mutations continue to work.
|
||||
*
|
||||
* Attach as early in the session lifetime as possible. When added after
|
||||
* {@link create}, the initial connection omits the header (no id yet);
|
||||
* all later reconnects include it. To instrument before the first connect,
|
||||
* call this on the session returned from {@link withBrowserVisibility}
|
||||
* before awaiting a separate {@link connect} when the tab starts hidden.
|
||||
*
|
||||
* ```ts
|
||||
* const session = await SSESession.create(url);
|
||||
* await SSESession.addLastEventIdReconnect(session);
|
||||
* // Reconnects send Last-Event-ID once an event with an id is received.
|
||||
* ```
|
||||
*
|
||||
* @param client - The session to instrument.
|
||||
* @returns The same session, for chaining.
|
||||
*/
|
||||
static async addLastEventIdReconnect(client: SSESession): Promise<SSESession> {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
client.on("message", (event) => {
|
||||
lastEventId = event.id;
|
||||
});
|
||||
|
||||
const originalOnRequest = client.onRequest;
|
||||
|
||||
client.onRequest = async (request) => {
|
||||
if (lastEventId) {
|
||||
request.headers = { ...request.headers, "Last-Event-ID": lastEventId };
|
||||
}
|
||||
|
||||
return originalOnRequest(request);
|
||||
};
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses and resumes a session based on browser tab visibility.
|
||||
*
|
||||
* Uses the Page Visibility API (`document.visibilitychange`):
|
||||
*
|
||||
* - **hidden** — {@link abort} stops the active fetch. {@link messages}
|
||||
* stays open; `"disconnected"` fires but `"closed"` does not.
|
||||
* - **visible** — {@link connect} re-establishes the stream if needed.
|
||||
*
|
||||
* The listener is removed when {@link disconnect} emits `"closed"`, and
|
||||
* re-attached automatically on the next `"connected"` event.
|
||||
*
|
||||
* No-op in non-browser environments where `document` is undefined.
|
||||
*
|
||||
* @param client - The session to manage.
|
||||
*/
|
||||
static addBrowserVisibilityHandler(client: SSESession): SSESession {
|
||||
if (typeof document === "undefined") return client;
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
void client.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
client.connect().catch(() => {
|
||||
// connect() reports failures via onError and the "error" event.
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// Stop managing visibility after an explicit disconnect; re-register
|
||||
// when the same instance is manually connected again.
|
||||
client.once("closed", () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
client.once("connected", () => {
|
||||
SSESession.addBrowserVisibilityHandler(client);
|
||||
});
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/** SSE endpoint URL for this session. */
|
||||
private readonly url: string;
|
||||
|
||||
/**
|
||||
* Per-instance configuration.
|
||||
*
|
||||
* Defaults live on the instance field (not a shared static) so each session
|
||||
* gets its own {@link SSEEventParser} and {@link ExponentialBackoff}.
|
||||
*/
|
||||
private options: SSESessionOptions = {
|
||||
fetch: (...args) => fetch(...args),
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
body: new FormData(),
|
||||
|
||||
onRequest: (request) => Promise.resolve(request),
|
||||
onConnected: () => {},
|
||||
onDisconnected: () => {},
|
||||
onError: (error) => console.error("SSEClient error:", error),
|
||||
|
||||
// Retry the initial fetch until it succeeds (maxAttempts: 0 = unlimited).
|
||||
retry: new ExponentialBackoff({
|
||||
baseDelay: 1000,
|
||||
maxDelay: 10000,
|
||||
maxAttempts: 0,
|
||||
growthRate: 1.3,
|
||||
jitter: 0.3,
|
||||
}),
|
||||
|
||||
attemptReconnect: true,
|
||||
persistent: false,
|
||||
|
||||
eventParser: new SSEEventParser(),
|
||||
};
|
||||
|
||||
/** AbortController for the currently active fetch, if any. */
|
||||
private controller: AbortController = new AbortController();
|
||||
|
||||
/** Whether a transport is currently established or connecting. */
|
||||
private connected = false;
|
||||
|
||||
/**
|
||||
* Monotonic id bumped on each {@link connect} and {@link abort}.
|
||||
*
|
||||
* Background read loops compare against this to detect superseded transports.
|
||||
*/
|
||||
private connectionId = 0;
|
||||
|
||||
/**
|
||||
* Asynchronous stream of parsed SSE events for the active connection.
|
||||
*
|
||||
* Stays open across {@link abort} and automatic reconnects so an existing
|
||||
* `for await` consumer keeps receiving events after visibility resumes.
|
||||
*
|
||||
* Closes when:
|
||||
* - the server ends the stream and {@link SSESessionOptions.persistent}
|
||||
* is false,
|
||||
* - {@link disconnect} is called, or
|
||||
* - a transport error occurs with
|
||||
* {@link SSESessionOptions.attemptReconnect} disabled.
|
||||
*
|
||||
* A later {@link connect} replaces this with a new iterator when the
|
||||
* previous one was closed. Consumers should read from `session.messages`
|
||||
* rather than caching a reference across terminal disconnects.
|
||||
*/
|
||||
public messages: AsyncPushIterator<SSEvent> = new AsyncPushIterator<SSEvent>();
|
||||
|
||||
private constructor(url: string, options: Partial<SSESessionOptions>) {
|
||||
super();
|
||||
|
||||
this.url = url;
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options,
|
||||
// Shallow merge would drop default headers when options.headers is set.
|
||||
headers: { ...this.options.headers, ...options.headers },
|
||||
};
|
||||
}
|
||||
|
||||
get onRequest(): (request: RequestInit) => Promise<RequestInit> {
|
||||
return this.options.onRequest;
|
||||
}
|
||||
|
||||
set onRequest(callback: (request: RequestInit) => Promise<RequestInit>) {
|
||||
this.options.onRequest = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects or reconnects to the SSE endpoint.
|
||||
*
|
||||
* Resolves once the HTTP stream is established and `"connected"` has been
|
||||
* emitted. Body reading continues asynchronously in the background via
|
||||
* {@link readStream}.
|
||||
*
|
||||
* @throws When the fetch retry policy exhausts attempts or the connection
|
||||
* is superseded before the reader is handed off (in the latter case the
|
||||
* promise resolves without throwing).
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
// Prepare for a fresh transport. Parser state from an abandoned connection
|
||||
// must not bleed into the next one; reopen messages if a prior terminal
|
||||
// close ended the consumer's iteration loop.
|
||||
this.resetEventParser();
|
||||
this.ensureMessageStreamOpen();
|
||||
|
||||
const connectionId = ++this.connectionId;
|
||||
const controller = new AbortController();
|
||||
|
||||
this.connected = true;
|
||||
this.controller = controller;
|
||||
|
||||
const { method, headers, body } = this.options;
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: headers || {},
|
||||
body: body || null,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
};
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
try {
|
||||
reader = await this.options.retry.run(() =>
|
||||
this.createReader(fetchOptions),
|
||||
);
|
||||
} catch (error) {
|
||||
// A newer abort/connect superseded this attempt — leave state to the winner.
|
||||
if (!this.isCurrentConnection(connectionId, controller)) return;
|
||||
|
||||
this.connected = false;
|
||||
|
||||
await this.notifyDisconnected();
|
||||
await this.notifyError(error);
|
||||
this.closeMessageStream();
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Connection succeeded but was already replaced (for example abort during fetch).
|
||||
if (!this.isCurrentConnection(connectionId, controller)) {
|
||||
await reader.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
await tryAsync(
|
||||
() => this.options.onConnected(),
|
||||
(error) => this.options.onError(error),
|
||||
);
|
||||
this.emit("connected", undefined);
|
||||
|
||||
// Fire-and-forget: connect() resolves while the stream is consumed.
|
||||
this.readStream(reader, connectionId, controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts only the currently active transport.
|
||||
*
|
||||
* The session remains reusable: {@link messages} stays open, visibility
|
||||
* handling stays attached, and {@link connect} can reopen the stream.
|
||||
* Partial parser state from the abandoned transport is discarded.
|
||||
*
|
||||
* Emits `"disconnected"` but not `"closed"`.
|
||||
*/
|
||||
public async abort(): Promise<void> {
|
||||
if (!this.connected) return;
|
||||
|
||||
this.connected = false;
|
||||
// Invalidate any in-flight read loop and fetch for this transport.
|
||||
this.connectionId++;
|
||||
this.controller.abort();
|
||||
this.resetEventParser();
|
||||
|
||||
await this.notifyDisconnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates the session and disables attached visibility handling until
|
||||
* the same instance is manually {@link connect connected} again.
|
||||
*
|
||||
* Closes {@link messages} and emits `"closed"`.
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this.closeMessageStream();
|
||||
this.emit("closed", undefined);
|
||||
|
||||
if (this.connected) {
|
||||
await this.abort();
|
||||
} else {
|
||||
this.resetEventParser();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the HTTP request and returns a reader for the response body.
|
||||
*
|
||||
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
|
||||
* tokens or `Last-Event-ID`) before the fetch runs.
|
||||
*/
|
||||
private async createReader(
|
||||
fetchOptions: RequestInit,
|
||||
): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
||||
const requestOptions = await this.options.onRequest(fetchOptions);
|
||||
const response = await this.options.fetch(this.url, requestOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
return response.body.getReader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads bytes from an established stream until it ends, errors, or is
|
||||
* superseded by a newer connection.
|
||||
*/
|
||||
private async readStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
connectionId: number,
|
||||
controller: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
while (this.isCurrentConnection(connectionId, controller)) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
// abort() or a newer connect() may have landed while we were awaiting.
|
||||
if (!this.isCurrentConnection(connectionId, controller)) return;
|
||||
|
||||
if (done) {
|
||||
this.connected = false;
|
||||
|
||||
await this.notifyDisconnected();
|
||||
|
||||
if (this.options.persistent) {
|
||||
// Server closed gracefully — reopen unless the consumer opted out.
|
||||
await this.connect();
|
||||
} else {
|
||||
this.closeMessageStream();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Some environments yield `{ done: false, value: undefined }`.
|
||||
if (!value) continue;
|
||||
|
||||
for (const event of this.options.eventParser.parseEvents(value)) {
|
||||
this.emit("message", event);
|
||||
this.messages.push(event);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.isCurrentConnection(connectionId, controller)) return;
|
||||
|
||||
this.connected = false;
|
||||
|
||||
await this.notifyDisconnected();
|
||||
|
||||
// Expected path for abort() — do not treat as an error or reconnect.
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
await this.notifyError(error);
|
||||
|
||||
if (this.options.attemptReconnect) {
|
||||
await this.connect();
|
||||
} else {
|
||||
this.closeMessageStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears partial SSE frames left over from an abandoned transport. */
|
||||
private resetEventParser(): void {
|
||||
this.options.eventParser.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link messages} iterator when the previous one was closed
|
||||
* by a terminal disconnect or server stream end.
|
||||
*/
|
||||
private ensureMessageStreamOpen(): void {
|
||||
if (!this.messages.closed) return;
|
||||
|
||||
this.messages = new AsyncPushIterator<SSEvent>();
|
||||
}
|
||||
|
||||
/** Ends the message iteration loop for the current connection span. */
|
||||
private closeMessageStream(): void {
|
||||
if (this.messages.closed) return;
|
||||
|
||||
this.messages.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a read loop still owns the active transport.
|
||||
*
|
||||
* A loop is stale when the session disconnected, a newer connection id was
|
||||
* assigned, or the fetch was aborted.
|
||||
*/
|
||||
private isCurrentConnection(
|
||||
connectionId: number,
|
||||
controller: AbortController,
|
||||
): boolean {
|
||||
return (
|
||||
this.connected &&
|
||||
this.connectionId === connectionId &&
|
||||
!controller.signal.aborted
|
||||
);
|
||||
}
|
||||
|
||||
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
|
||||
private async notifyDisconnected(): Promise<void> {
|
||||
await tryAsync(
|
||||
() => this.options.onDisconnected(),
|
||||
(error) => this.options.onError(error),
|
||||
);
|
||||
this.emit("disconnected", undefined);
|
||||
}
|
||||
|
||||
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
|
||||
private async notifyError(error: unknown): Promise<void> {
|
||||
const errorInstance = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
await tryAsync(
|
||||
() => this.options.onError(errorInstance),
|
||||
(callbackError) =>
|
||||
console.error("SSESession error:", callbackError),
|
||||
);
|
||||
this.emit("error", errorInstance);
|
||||
}
|
||||
}
|
||||
148
src/types.ts
Normal file
148
src/types.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export type SSERequestInit = {
|
||||
/**
|
||||
* The HTTP method to use.
|
||||
*/
|
||||
method: "GET" | "POST";
|
||||
|
||||
/**
|
||||
* Request headers sent on every connect and reconnect.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Request body for POST-based SSE endpoints.
|
||||
*/
|
||||
body?: string | FormData;
|
||||
};
|
||||
|
||||
/**
|
||||
* The fetch function to use.
|
||||
*
|
||||
* NOTE: This is compatible with Browser/Node's native "fetch" function.
|
||||
* We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here).
|
||||
* For example, a LibP2P adapter might not use a standardized URL format (and might only include "path").
|
||||
* This would cause a type error as native fetch expects type "URL".
|
||||
*/
|
||||
export type SSERequestFunction = {
|
||||
fetch: (url: string, options: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lifecycle hooks invoked by {@link SSESession} during connect, read, and teardown.
|
||||
*/
|
||||
export type SSESessionCallbacks = {
|
||||
/**
|
||||
* Called before each fetch so callers can attach auth headers, cookies, or
|
||||
* a `Last-Event-ID` for resume semantics.
|
||||
*/
|
||||
onRequest: (request: RequestInit) => Promise<RequestInit>;
|
||||
|
||||
/**
|
||||
* Called after the HTTP stream is established and before body reading begins.
|
||||
*/
|
||||
onConnected: () => void;
|
||||
|
||||
/**
|
||||
* Called when the active transport ends — including {@link SSESession.abort},
|
||||
* server stream completion, and errors. Not paired with {@link SSESessionCallbacks.onConnected}
|
||||
* when the initial connect never succeeds.
|
||||
*/
|
||||
onDisconnected: () => void;
|
||||
|
||||
/**
|
||||
* Called on fetch or read failures. Not invoked for intentional
|
||||
* {@link SSESession.abort} aborts.
|
||||
*/
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export type SSESessionRetryInterface = {
|
||||
/**
|
||||
* Retry policy used while establishing the HTTP connection in
|
||||
* {@link SSESession.connect}. Defaults to {@link ExponentialBackoff} with
|
||||
* unlimited attempts.
|
||||
*/
|
||||
retry: {
|
||||
run<T>(fn: () => Promise<T>, onError?: (error: Error) => void): Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface SSEParser {
|
||||
/**
|
||||
* Incremental SSE frame parser for the response body.
|
||||
*
|
||||
* {@link SSEEventParser.reset} is called by the session when abandoning a
|
||||
* transport so partial frames do not carry over to the next connection.
|
||||
*/
|
||||
eventParser: {
|
||||
parseEvents(buffer: Uint8Array): SSEvent[];
|
||||
reset(): void;
|
||||
};
|
||||
}
|
||||
|
||||
export type SSELifecycleOptions = {
|
||||
/**
|
||||
* When true, {@link SSESession} calls {@link SSESession.connect} again after
|
||||
* a transport **error** (not an intentional abort).
|
||||
*/
|
||||
attemptReconnect: boolean,
|
||||
|
||||
/**
|
||||
* When true, {@link SSESession} calls {@link SSESession.connect} again after
|
||||
* the **server** closes the stream normally (`done`).
|
||||
*/
|
||||
persistent: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by {@link SSESession}.
|
||||
*
|
||||
* - `"connected"` — HTTP stream established.
|
||||
* - `"message"` — A complete SSE event was parsed.
|
||||
* - `"disconnected"` — The active transport ended (including {@link SSESession.abort}).
|
||||
* - `"error"` — An unexpected fetch or read failure.
|
||||
* - `"closed"` — {@link SSESession.disconnect} was called; visibility handling is detached.
|
||||
*/
|
||||
export type SSESessionEventMap = {
|
||||
connected: void;
|
||||
disconnected: void;
|
||||
error: Error;
|
||||
message: SSEvent;
|
||||
closed: void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for {@link SSESession}.
|
||||
*/
|
||||
export type SSESessionOptions =
|
||||
SSESessionCallbacks &
|
||||
SSERequestInit &
|
||||
SSERequestFunction &
|
||||
SSESessionRetryInterface &
|
||||
SSELifecycleOptions &
|
||||
SSEParser;
|
||||
|
||||
/**
|
||||
* Represents a Server-Sent Event.
|
||||
*/
|
||||
export interface SSEvent {
|
||||
/**
|
||||
* Event data.
|
||||
*/
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* Event type.
|
||||
*/
|
||||
event?: string;
|
||||
|
||||
/**
|
||||
* Event ID.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Reconnection time in milliseconds.
|
||||
*/
|
||||
retry?: number;
|
||||
}
|
||||
107
src/utils/async-push-iterator.ts
Normal file
107
src/utils/async-push-iterator.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* An async iterable queue that bridges push-based producers and pull-based consumers.
|
||||
*
|
||||
* Values are pushed from outside the iteration loop (for example, from an SSE
|
||||
* read callback) and consumed with standard async iteration:
|
||||
*
|
||||
* ```ts
|
||||
* const messages = new AsyncPushIterator<SSEvent>();
|
||||
*
|
||||
* // Producer (elsewhere)
|
||||
* messages.push(event);
|
||||
*
|
||||
* // Consumer
|
||||
* for await (const event of messages) {
|
||||
* handle(event);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* When a consumer is already waiting on {@link AsyncPushIterator.prototype.next},
|
||||
* {@link push} delivers immediately. Otherwise values are buffered in FIFO order
|
||||
* until consumed. Call {@link close} to signal end-of-stream; further
|
||||
* {@link push} calls are ignored.
|
||||
*
|
||||
* Implements `Symbol.asyncDispose` so instances can be closed with `using` when
|
||||
* the runtime supports explicit resource management.
|
||||
*/
|
||||
export class AsyncPushIterator<T> implements AsyncIterable<T> {
|
||||
/** Values pushed before a consumer was waiting to read them. */
|
||||
private queue: T[] = [];
|
||||
|
||||
/** Pending `next()` calls waiting for a pushed value or close. */
|
||||
private resolvers: ((result: IteratorResult<T>) => void)[] = [];
|
||||
|
||||
/** When true, no more values are accepted and iteration eventually completes. */
|
||||
public closed = false;
|
||||
|
||||
/**
|
||||
* Enqueues a value for the consumer.
|
||||
*
|
||||
* If a consumer is blocked on `next()`, the value is delivered immediately and
|
||||
* the queue is bypassed. After {@link close}, pushes are silently dropped.
|
||||
*
|
||||
* @param value - The next value to yield from the iterator.
|
||||
*/
|
||||
push(value: T): void {
|
||||
if (this.closed) return;
|
||||
|
||||
if (this.resolvers.length > 0) {
|
||||
// Someone is waiting for a value, resolve immediately
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value, done: false });
|
||||
} else {
|
||||
// No one waiting, buffer the value
|
||||
this.queue.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the stream.
|
||||
*
|
||||
* Marks the iterator closed so future {@link push} calls are ignored. Any
|
||||
* consumer currently waiting on `next()` receives `{ done: true }`. Buffered
|
||||
* values are still yielded before iteration completes.
|
||||
*/
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
for (const resolve of this.resolvers) {
|
||||
resolve({ value: undefined as T, done: true });
|
||||
}
|
||||
this.resolvers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an async iterator that reads from this instance's shared queue.
|
||||
*
|
||||
* Buffered values are returned first, then the iterator waits for pushes or
|
||||
* for {@link close}. Intended for a single consumer per instance.
|
||||
*/
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return {
|
||||
next: (): Promise<IteratorResult<T>> => {
|
||||
// If we have buffered values, return immediately
|
||||
if (this.queue.length > 0) {
|
||||
return Promise.resolve({ value: this.queue.shift()!, done: false });
|
||||
}
|
||||
|
||||
// If closed and no buffered values, we're done
|
||||
if (this.closed) {
|
||||
return Promise.resolve({ value: undefined as T, done: true });
|
||||
}
|
||||
|
||||
// Wait for a value to be pushed
|
||||
return new Promise((resolve) => {
|
||||
this.resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the iterator when used with explicit resource management (`using`).
|
||||
*/
|
||||
[Symbol.asyncDispose](): Promise<void> {
|
||||
this.close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
231
src/utils/event-emitter.ts
Normal file
231
src/utils/event-emitter.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// TODO: You'll probably want to use WeakRef's here.
|
||||
// NOTE: Looked into the WeakRefs, but they are extremely challenging to work well without side-effects.
|
||||
// Things like anonymous functions and closures will get cleaned up immediately and the side-effects from a developer's perspective
|
||||
// are likely more painful than leaving event listener cleanup to the developer.
|
||||
// - Harvmaster 2026-05-24
|
||||
export type EventMap = Record<string, unknown>;
|
||||
|
||||
type Listener<T> = (detail: T) => void;
|
||||
|
||||
/**
|
||||
* A listener entry.
|
||||
* @template T - The event type.
|
||||
*/
|
||||
interface ListenerEntry<T> {
|
||||
listener: Listener<T>;
|
||||
wrappedListener: Listener<T>;
|
||||
debounceTime?: number;
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
export type OffCallback = () => void;
|
||||
|
||||
/**
|
||||
* A simple event emitter implementation.
|
||||
* @template T - The event map type.
|
||||
*/
|
||||
export class EventEmitter<T extends EventMap> {
|
||||
/**
|
||||
* The listeners map.
|
||||
* @private
|
||||
*/
|
||||
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
|
||||
|
||||
/**
|
||||
* Add a listener for an event.
|
||||
* @param type - The event type.
|
||||
* @param listener - The listener function.
|
||||
* @param debounceMilliseconds - The debounce time in milliseconds.
|
||||
* @returns An off callback that can be called to stop listening for events.
|
||||
*/
|
||||
on<K extends keyof T>(
|
||||
type: K,
|
||||
listener: Listener<T[K]>,
|
||||
debounceMilliseconds?: number,
|
||||
): OffCallback {
|
||||
// Create a wrapped listener so that the debounce can be applied.
|
||||
const wrappedListener =
|
||||
debounceMilliseconds && debounceMilliseconds > 0
|
||||
? this.debounce(listener, debounceMilliseconds)
|
||||
: listener;
|
||||
|
||||
// If the listeners map does not have the event type, create a new set.
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
|
||||
// Create a listener entry.
|
||||
const listenerEntry: ListenerEntry<T[K]> = {
|
||||
listener,
|
||||
wrappedListener,
|
||||
...(debounceMilliseconds !== undefined
|
||||
? { debounceTime: debounceMilliseconds }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Add the listener entry to the listeners map.
|
||||
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
|
||||
|
||||
// Return an "off" callback that can be called to stop listening for events.
|
||||
return () => this.off(type, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a one-time listener for an event.
|
||||
* @param type - The event type.
|
||||
* @param listener - The listener function.
|
||||
* @param debounceMilliseconds - The debounce time in milliseconds.
|
||||
* @returns An off callback that can be called to stop listening for events.
|
||||
*/
|
||||
once<K extends keyof T>(
|
||||
type: K,
|
||||
listener: Listener<T[K]>,
|
||||
debounceMilliseconds?: number,
|
||||
): OffCallback {
|
||||
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
|
||||
this.off(type, listener);
|
||||
listener(detail);
|
||||
};
|
||||
|
||||
// Create a debounced listener.
|
||||
const debouncedListener =
|
||||
debounceMilliseconds && debounceMilliseconds > 0
|
||||
? this.debounce(wrappedListener, debounceMilliseconds)
|
||||
: wrappedListener;
|
||||
|
||||
// If the listeners map does not have the event type, create a new set.
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
|
||||
// Create a listener entry.
|
||||
const listenerEntry: ListenerEntry<T[K]> = {
|
||||
listener,
|
||||
wrappedListener: debouncedListener,
|
||||
once: true,
|
||||
...(debounceMilliseconds !== undefined
|
||||
? { debounceTime: debounceMilliseconds }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Add the listener entry to the listeners map.
|
||||
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
|
||||
|
||||
// Return an "off" callback that can be called to stop listening for events.
|
||||
return () => this.off(type, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a listener for an event.
|
||||
* @param type - The event type.
|
||||
* @param listener - The listener function.
|
||||
*/
|
||||
off<K extends keyof T>(type: K, listener: Listener<T[K]>): void {
|
||||
// Get the listeners for the event type.
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) return;
|
||||
|
||||
// Find the listener entry.
|
||||
const listenerEntry = Array.from(listeners).find(
|
||||
(entry) =>
|
||||
entry.listener === listener || entry.wrappedListener === listener,
|
||||
);
|
||||
|
||||
// If the listener entry is found, remove it from the listeners map.
|
||||
if (listenerEntry) {
|
||||
listeners.delete(listenerEntry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
* @param type - The event type.
|
||||
* @param payload - The event payload.
|
||||
* @returns True if there are listeners for the event, false otherwise.
|
||||
*/
|
||||
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
|
||||
// Get the listeners for the event type.
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) return false;
|
||||
|
||||
// Emit the event to all listeners.
|
||||
listeners.forEach((entry) => {
|
||||
entry.wrappedListener(payload);
|
||||
});
|
||||
|
||||
// Return true if there are listeners for the event, false otherwise.
|
||||
return listeners.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners.
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an event to be emitted.
|
||||
* @param type - The event type.
|
||||
* @param predicate - The predicate function.
|
||||
* @param timeoutMs - The timeout in milliseconds.
|
||||
* @returns The event payload.
|
||||
*/
|
||||
async waitFor<K extends keyof T>(
|
||||
type: K,
|
||||
predicate: (payload: T[K]) => boolean,
|
||||
timeoutMs?: number,
|
||||
): Promise<T[K]> {
|
||||
// Create a promise to wait for the event to be emitted.
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Create a listener function.
|
||||
const listener = (payload: T[K]) => {
|
||||
if (predicate(payload)) {
|
||||
// Clean up
|
||||
this.off(type, listener);
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(payload);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up timeout if specified
|
||||
if (timeoutMs !== undefined) {
|
||||
timeoutId = setTimeout(() => {
|
||||
this.off(type, listener);
|
||||
reject(new Error(`Timeout waiting for event "${String(type)}"`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Add the listener to the listeners map.
|
||||
this.on(type, listener);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce a function.
|
||||
* @param func - The function to debounce.
|
||||
* @param wait - The wait time in milliseconds.
|
||||
* @returns The debounced function.
|
||||
*/
|
||||
private debounce<K extends keyof T>(
|
||||
func: Listener<T[K]>,
|
||||
wait: number,
|
||||
): Listener<T[K]> {
|
||||
// Create a timeout variable.
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (detail: T[K]) => {
|
||||
// If the timeout is not null, clear it.
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
func(detail);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
155
src/utils/exponential-backoff.ts
Normal file
155
src/utils/exponential-backoff.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Exponential backoff is a technique used to retry a function after a delay.
|
||||
*
|
||||
* The delay increases exponentially with each attempt, up to a maximum delay.
|
||||
*
|
||||
* The jitter is a random amount of time added to the delay to prevent thundering herd problems.
|
||||
*
|
||||
* The growth rate is the factor by which the delay increases with each attempt.
|
||||
*/
|
||||
export class ExponentialBackoff {
|
||||
/**
|
||||
* Create a new ExponentialBackoff instance
|
||||
*
|
||||
* @param config - The configuration for the exponential backoff
|
||||
* @returns The ExponentialBackoff instance
|
||||
*/
|
||||
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
|
||||
const backoff = new ExponentialBackoff(config);
|
||||
return backoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the function with exponential backoff
|
||||
*
|
||||
* @param fn - The function to run
|
||||
* @param onError - The callback to call when an error occurs
|
||||
* @param options - The configuration for the exponential backoff
|
||||
*
|
||||
* @throws The last error if the function fails and we have hit the max attempts
|
||||
*
|
||||
* @returns The result of the function
|
||||
*/
|
||||
static run<T>(
|
||||
fn: () => Promise<T>,
|
||||
onError = (_error: Error) => {},
|
||||
options?: Partial<ExponentialBackoffOptions>,
|
||||
): Promise<T> {
|
||||
const backoff = ExponentialBackoff.from(options);
|
||||
return backoff.run(fn, onError);
|
||||
}
|
||||
|
||||
private readonly options: ExponentialBackoffOptions;
|
||||
|
||||
constructor(options?: Partial<ExponentialBackoffOptions>) {
|
||||
this.options = {
|
||||
maxDelay: 10000,
|
||||
maxAttempts: 10,
|
||||
baseDelay: 1000,
|
||||
growthRate: 2,
|
||||
jitter: 0.1,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the function with exponential backoff
|
||||
*
|
||||
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
|
||||
* and the function will be retried with an exponential delay
|
||||
*
|
||||
* If the function fails and we have hit the max attempts, the last error will be thrown
|
||||
*
|
||||
* @param fn - The function to run
|
||||
* @param onError - The callback to call when an error occurs
|
||||
*
|
||||
* @throws The last error if the function fails and we have hit the max attempts
|
||||
*
|
||||
* @returns The result of the function
|
||||
*/
|
||||
async run<T>(
|
||||
fn: () => Promise<T>,
|
||||
onError = (_error: Error) => {},
|
||||
): Promise<T> {
|
||||
let lastError: Error = new Error("Exponential backoff: Max retries hit");
|
||||
|
||||
let attempt = 0;
|
||||
|
||||
while (
|
||||
attempt < this.options.maxAttempts ||
|
||||
this.options.maxAttempts == 0
|
||||
) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
// Store the error in case we fail every attempt
|
||||
lastError = error instanceof Error ? error : new Error(`${error}`);
|
||||
onError(lastError);
|
||||
|
||||
// Wait before going to the next attempt
|
||||
const delay = this.calculateDelay(attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
// We completed the loop without ever succeeding. Throw the last error we got
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the delay before we should attempt to retry
|
||||
*
|
||||
* NOTE: The maximum delay is (maxDelay * (1 + jitter))
|
||||
*
|
||||
* @param attempt
|
||||
* @returns The time in milliseconds before another attempt should be made
|
||||
*/
|
||||
private calculateDelay(attempt: number): number {
|
||||
// Get the power of the growth rate
|
||||
const power = Math.pow(this.options.growthRate, attempt);
|
||||
|
||||
// Get the delay before jitter or limit
|
||||
const rawDelay = this.options.baseDelay * power;
|
||||
|
||||
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
|
||||
const cappedDelay = Math.min(rawDelay, this.options.maxDelay);
|
||||
|
||||
// Get the jitter direction. This will be between -1 and 1
|
||||
const jitterDirection = 2 * Math.random() - 1;
|
||||
|
||||
// Calculate the jitter
|
||||
const jitter = jitterDirection * this.options.jitter * cappedDelay;
|
||||
|
||||
// Add the jitter to the delay
|
||||
return cappedDelay + jitter;
|
||||
}
|
||||
}
|
||||
|
||||
export type ExponentialBackoffOptions = {
|
||||
/**
|
||||
* The maximum delay between attempts in milliseconds
|
||||
*/
|
||||
maxDelay: number;
|
||||
|
||||
/**
|
||||
* The maximum number of attempts. Passing 0 will result in infinite attempts.
|
||||
*/
|
||||
maxAttempts: number;
|
||||
|
||||
/**
|
||||
* The base delay between attempts in milliseconds
|
||||
*/
|
||||
baseDelay: number;
|
||||
|
||||
/**
|
||||
* The growth rate of the delay
|
||||
*/
|
||||
growthRate: number;
|
||||
|
||||
/**
|
||||
* The jitter of the delay as a percentage of growthRate
|
||||
*/
|
||||
jitter: number;
|
||||
};
|
||||
16
src/utils/misc.ts
Normal file
16
src/utils/misc.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Tries to execute an async function and handles any errors that occur.
|
||||
* @param fn - The function to execute.
|
||||
* @param onError - The callback to call if the function fails.
|
||||
* @returns The result of the function.
|
||||
*/
|
||||
export const tryAsync = async (fn: () => any, onError?: (error: Error) => void): Promise<void> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
|
||||
|
||||
onError?.(errorInstance);
|
||||
|
||||
}
|
||||
}
|
||||
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
|
||||
// Environment Settings
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"types": [],
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user