// 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 { /** * Creates and connects a new SSEClient instance. * @param url The URL to connect to * @param options Configuration options * @returns A new connected SSEClient instance */ public static async from( url: string, options: Partial = {}, ): Promise { const client = new SSEClient(url, options); await client.connect(); return client; } // State. private url: string; private controller: AbortController; private connected: boolean = false; protected options: SSEClientOptions; protected messageBuffer: Uint8Array = new Uint8Array(); // Text decoders and encoders for parsing the message buffer. private textDecoder: TextDecoder = new TextDecoder(); private textEncoder: TextEncoder = new TextEncoder(); /** * Creates a new SSEClient instance. * @param url The URL to connect to * @param options Configuration options */ constructor(url: string, options: Partial = {}) { this.url = url; this.options = { // Use default fetch function. fetch: fetch, method: 'GET', headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', }, onOpen: () => {}, onMessage: () => {}, onError: (error) => console.error('SSEClient error:', error), onClose: () => {}, ...options, }; this.controller = new AbortController(); } /** * Connects to the SSE endpoint. */ public async connect(): Promise { if (this.connected) return; this.connected = true; const { method, headers, body } = this.options; const fetchOptions: RequestInit = { method, headers, body, signal: this.controller.signal, cache: 'no-store', }; // Using a regular Promise executor (not async) return new Promise((resolve, reject) => { // Move the async logic inside a separate function const establishConnection = async () => { try { // Fetch from the provided URL using our fetch function. const response = await this.options.fetch(this.url, fetchOptions); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } if (!response.body) { throw new Error('Response body is null'); } // Notify that the connection has been established if (this.options.onOpen) { this.options.onOpen(); } const reader = response.body.getReader(); // Start the read here so we can resolve and then start handling the stream // We will await this at the start of the loop, and redeclare it at the end of each loop iteration let readPromise = reader.read(); // Resolve before starting the loop so that we can start performing other actions // Keep this out of the loop to avoid flags or `already resolved` errors. resolve(); while (true) { const { done, value } = await readPromise; if (done) { this.connected = false; if (this.options.onClose) { this.options.onClose(); } break; } // Decode and parse the chunk const events = this.parseEvents(value); // Handle each complete event for (const event of events) { if (this.options.onMessage) { this.options.onMessage(event); } } // Start reading the next chunk readPromise = reader.read(); } } catch (error) { this.connected = false; if (this.options.onError) { this.options.onError(error); } if (this.options.onClose) { this.options.onClose(); } // Now we'll reject the promise instead of resolving reject(error); } }; // Execute the async function establishConnection(); }); } protected parseEvents(chunk: Uint8Array): SSEvent[] { // Append new chunk to existing buffer this.messageBuffer = new Uint8Array([...this.messageBuffer, ...chunk]); const events: SSEvent[] = []; const lines = this.textDecoder.decode(this.messageBuffer).split(/\r\n|\r|\n/); let currentEvent: Partial = {}; let completeEventCount = 0; // Iterate over the lines to find complete events for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Empty line signals the end of an event if (line === '') { if (currentEvent.data) { // Remove trailing newline if present currentEvent.data = currentEvent.data.replace(/\n$/, ''); events.push(currentEvent as SSEvent); currentEvent = {}; completeEventCount = i + 1; } continue; } // Parse field: value format const colonIndex = line.indexOf(':'); if (colonIndex === -1) continue; const field = line.slice(0, colonIndex); // Skip initial space after colon if present const valueStartIndex = colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0); const value = line.slice(valueStartIndex); if (field === 'data') { currentEvent.data = currentEvent.data ? currentEvent.data + '\n' + value : value; } else if (field === 'event') { currentEvent.event = value; } else if (field === 'id') { currentEvent.id = value; } else if (field === 'retry') { const retryMs = parseInt(value, 10); if (!isNaN(retryMs)) { currentEvent.retry = retryMs; } } } // Store the remainder of the buffer for the next chunk const remainder = lines.slice(completeEventCount).join('\n'); this.messageBuffer = this.textEncoder.encode(remainder); return events; } /** * Closes the SSE connection. */ public close(): void { if (!this.connected) return; this.controller.abort(); this.connected = false; } /** * Checks if the client is currently connected. * @returns Whether the client is connected */ public isConnected(): boolean { return this.connected; } } /** * Configuration options for the SSEClient. */ export interface SSEClientOptions { /** * 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". */ fetch: (url: string, options: RequestInit) => Promise; /** * HTTP method to use (GET or POST). */ method: 'GET' | 'POST'; /** * HTTP headers to send with the request. */ headers?: Record; /** * Body to send with POST requests. */ body?: string | FormData | URLSearchParams; /** * Called when the connection is established. */ onOpen?: () => void; /** * Called when a message is received. */ onMessage?: (event: SSEvent) => void; /** * Called when an error occurs. */ onError?: (error: unknown) => void; /** * Called when the connection is closed. */ onClose?: () => void; } /** * 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; }