Merge branch 'sse-session-reconnect' into 'development'

Feat(SSE): SSE Session auto-reconnect

See merge request GeneralProtocols/xo/stack!90
This commit is contained in:
Jim Hamill
2025-05-26 02:36:23 +00:00
4 changed files with 163 additions and 47 deletions
@@ -1,5 +1,5 @@
import { BaseStore } from './base-store.js';
import { SSEClient } from '../utils/index.js';
import { SSESession } from '../utils/index.js';
import {
Bytes,
Encrypter,
@@ -96,7 +96,7 @@ export class StoreRemoteHTTP extends BaseStore {
// NOTE: SSE only supports text-encoding, so we use Extended JSON instead of msgpackr (which is binary).
// TODO: It would be really nice to somehow be able to support msgpackr for consistency though.
// But, from a bandwidth point of view, this is not crucial: These payloads are "live" payloads (thus, there's not much data).
await SSEClient.from(`${this.baseURL}events`, {
await SSESession.from(`${this.baseURL}events`, {
method: 'POST',
headers,
onMessage: (e) => {
+2 -2
View File
@@ -3,6 +3,6 @@ export * from './ext-error.js';
export * from './inverse-bloom-filter.js';
export * from './memoizer.js';
export * from './misc.js';
export * from './sse-client.js';
export * from './sse-client-msgpackr.js';
export * from './sse-session.js';
export * from './sse-session-msgpackr.js';
export * from './uuid.js';
@@ -1,18 +1,18 @@
import { SSEClient, SSEClientOptions, SSEvent } from './sse-client.js';
import { SSESession, SSESessionOptions, SSEvent } from './sse-session.js';
import { unpackMultiple } from 'msgpackr';
/**
* SSEClientMsgpackr is a subclass of SSEClient that uses msgpackr to parse the events.
*/
export class SSEClientMsgpackr extends SSEClient {
export class SSEClientMsgpackr extends SSESession {
/**
* Creates a new SSEClientMsgpackr instance.
* @param {string} url The URL to connect to
* @param {SSEClientOptions} options The options for the SSEClient
* @returns {SSEClientMsgpackr} A new SSEClientMsgpackr instance
*/
public static async from(url: string, options: SSEClientOptions) {
public static async from(url: string, options: SSESessionOptions) {
const client = new SSEClientMsgpackr(url, options);
await client.connect();
return client;
@@ -1,25 +1,19 @@
// TODO: 1. Handle reconnection logic.
// 2. Allow specifying custom
// 3. Try to make it so that connect DOES NOT resolve until connection is actually establish.
// There's a very slim chance of a race-condition otherwise where we might miss an event.
// Note that this IS NOT trivial. Claude will probably NOT be able to do this for you!
/**
* A Server-Sent Events client implementation using fetch API.
* Supports custom headers, POST requests, and is non-blocking.
*/
export class SSEClient {
export class SSESession {
/**
* Creates and connects a new SSEClient instance.
* Creates and connects a new SSESession instance.
* @param url The URL to connect to
* @param options Configuration options
* @returns A new connected SSEClient instance
* @returns A new connected SSESession instance
*/
public static async from(
url: string,
options: Partial<SSEClientOptions> = {},
): Promise<SSEClient> {
const client = new SSEClient(url, options);
options: Partial<SSESessionOptions> = {},
): Promise<SSESession> {
const client = new SSESession(url, options);
await client.connect();
return client;
}
@@ -28,35 +22,68 @@ export class SSEClient {
private url: string;
private controller: AbortController;
private connected: boolean = false;
protected options: SSEClientOptions;
protected options: SSESessionOptions;
protected messageBuffer: Uint8Array = new Uint8Array();
// Listener for when the tab is hidden or shown.
private visibilityChangeHandler: ((event: Event) => void) | null = null;
// Keep track of the current reconnection attempt to use for exponential backoff.
protected currentAttempt: number = 0;
// Text decoders and encoders for parsing the message buffer.
private textDecoder: TextDecoder = new TextDecoder();
private textEncoder: TextEncoder = new TextEncoder();
/**
* Creates a new SSEClient instance.
* Creates a new SSESession instance.
* @param url The URL to connect to
* @param options Configuration options
*/
constructor(url: string, options: Partial<SSEClientOptions> = {}) {
constructor(url: string, options: Partial<SSESessionOptions> = {}) {
this.url = url;
this.options = {
// Use default fetch function.
fetch: fetch,
fetch: (...args) => fetch(...args),
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
onOpen: () => {},
onConnected: () => {},
onMessage: () => {},
onError: (error) => console.error('SSEClient error:', error),
onClose: () => {},
onError: (error) => console.error('SSESession error:', error),
onDisconnected: () => {},
// Reconnection options
attemptReconnect: true,
retryDelay: 1000,
persistent: false,
...options,
};
this.controller = new AbortController();
// Set up visibility change handling if in mobile browser environment
if (typeof document !== 'undefined') {
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
}
}
/**
* Handles visibility change events in the browser.
*/
private async handleVisibilityChange(): Promise<void> {
// When going to background, close the current connection cleanly
// This allows us to reconnect mobile devices when they come back after leaving the tab or browser app.
if (document.visibilityState === 'hidden') {
this.controller.abort();
}
// When coming back to foreground, attempt to reconnect if not connected
if (document.visibilityState === 'visible' && !this.connected) {
await this.attemptReconnect();
}
}
/**
@@ -66,6 +93,9 @@ export class SSEClient {
if (this.connected) return;
this.connected = true;
const abortController = new AbortController();
this.controller = abortController;
const { method, headers, body } = this.options;
const fetchOptions: RequestInit = {
@@ -92,10 +122,11 @@ export class SSEClient {
throw new Error('Response body is null');
}
// Notify that the connection has been established
if (this.options.onOpen) {
this.options.onOpen();
}
// Reset the current attempt.
this.currentAttempt = 0;
// Call the onConnected callback.
this.handleCallback(this.options.onConnected, undefined);
const reader = response.body.getReader();
@@ -112,9 +143,15 @@ export class SSEClient {
if (done) {
this.connected = false;
if (this.options.onClose) {
this.options.onClose();
// Call the onDisconnected callback.
this.handleCallback(this.options.onDisconnected, undefined);
// If the connection was closed by the server, we want to attempt a reconnect if the connection should be persistent.
if (this.options.persistent) {
await this.attemptReconnect();
}
break;
}
@@ -124,7 +161,7 @@ export class SSEClient {
// Handle each complete event
for (const event of events) {
if (this.options.onMessage) {
this.options.onMessage(event);
this.handleCallback(this.options.onMessage, event);
}
}
@@ -134,15 +171,28 @@ export class SSEClient {
} catch (error) {
this.connected = false;
if (this.options.onError) {
// Call the onDisconnected callback.
this.handleCallback(this.options.onDisconnected, error);
// If the connection was aborted using the controller, we don't need to call onError.
if (abortController.signal.aborted) {
return;
}
// Call the onError callback.
// NOTE: we dont use the handleCallback here because it would result in 2 error callbacks.
try {
this.options.onError(error);
} catch (error) {
console.log(`SSE Session: onError callback error:`, error);
}
if (this.options.onClose) {
this.options.onClose();
// Attempt to reconnect if enabled
if (this.options.attemptReconnect) {
return await this.attemptReconnect();
}
// Now we'll reject the promise instead of resolving
// Reject the promise
reject(error);
}
};
@@ -214,13 +264,45 @@ export class SSEClient {
}
/**
* Closes the SSE connection.
* Attempts to reconnect to the SSE endpoint.
* @returns void
*/
protected attemptReconnect(): void {
// Don't attempt to reconnect if it's not enabled or if we are already connected
if (!this.options.attemptReconnect || this.connected) return;
// Exponential backoff with a maximum of 10 seconds
// TODO: We should update this to use an Exponential Backoff utility when we have one.
this.currentAttempt++;
const delay = Math.min(this.options.retryDelay * Math.pow(1.3, this.currentAttempt - 1), 10000);
console.log(`Attempting to reconnect in ${delay}ms`);
setTimeout(async () => {
await this.connect();
}, delay);
}
/**
* Override the onMessage callback.
*
* @param onMessage The callback to set.
*/
public setOnMessage(onMessage: (event: SSEvent) => void): void {
this.options.onMessage = onMessage;
}
/**
* Closes the SSE connection and cleans up event listeners.
*/
public close(): void {
if (!this.connected) return;
// Clean up everything including the visibility handler
this.controller.abort();
this.connected = false;
// Remove the visibility handler (This is only required on browsers)
if (this.visibilityChangeHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
this.visibilityChangeHandler = null;
}
}
/**
@@ -230,12 +312,31 @@ export class SSEClient {
public isConnected(): boolean {
return this.connected;
}
/**
* Will handle thrown errors from the callback and call the onError callback.
* This is to avoid the sse-session from disconnecting from errors that are not a result of the sse-session itself.
*
* @param callback The callback to handle.
* @param args The arguments to pass to the callback.
*/
private handleCallback<T>(callback: (args: T) => void, args: T): void {
try {
callback(args);
} catch (error) {
try {
this.options.onError(error);
} catch (error) {
console.log(`SSE Session: onError callback error:`, error);
}
}
}
}
/**
* Configuration options for the SSEClient.
* Configuration options for the SSESession.
*/
export interface SSEClientOptions {
export interface SSESessionOptions {
/**
* The fetch function to use.
*
@@ -264,22 +365,37 @@ export interface SSEClientOptions {
/**
* Called when the connection is established.
*/
onOpen?: () => void;
onConnected: () => void;
/**
* Called when a message is received.
*/
onMessage?: (event: SSEvent) => void;
onMessage: (event: SSEvent) => void;
/**
* Called when an error occurs.
*/
onError?: (error: unknown) => void;
onError: (error: unknown) => void;
/**
* Called when the connection is closed.
*/
onClose?: () => void;
onDisconnected: () => void;
/**
* Whether to attempt to reconnect.
*/
attemptReconnect: boolean;
/**
* The delay in milliseconds between reconnection attempts.
*/
retryDelay: number;
/**
* Whether to reconnect when the session is terminated by the server.
*/
persistent: boolean;
}
/**