// Vendored from https://git.harvmaster.com/Harvmaster/sse-session (commit c08267e85c). // Local changes: import paths, uses ../event-emitter.ts, tryAsync folded into SSESession. import type { SSESessionOptions, SSESessionEventMap, SSEvent } from './types.js' import { EventEmitter } from '../event-emitter.js' import { AsyncPushIterator } from './async-push-iterator.js' import { ExponentialBackoff } from './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 { /** * 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 = {}): Promise { const client = new SSESession(url, options) 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 { let lastEventId: string | undefined client.on('message', (event) => { // If the event has no id, we should not update the lastEventId and instead keep the old one. lastEventId = event.id ?? lastEventId }) const originalOnRequest = client.options.onRequest client.options.onRequest = async (request: RequestInit): Promise => { 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}. */ public 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 | null = null /** * 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 = new AsyncPushIterator() public constructor(url: string, options: Partial = {}) { 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 }, } } /** * 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 { // If there is already a controller present, we are already connected. if (this.controller) 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. // NOTE: The resetEventParser may be out of spec and should not be done here: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation this.options.eventParser.reset() // If the last messages iterator wasnt closed, we keep it around as this is likely a reconnect. if (this.messages.closed) { this.messages = new AsyncPushIterator() } const controller = new AbortController() this.controller = controller const { method, headers, body } = this.options const fetchBody = method === 'POST' ? body : null const fetchOptions: RequestInit = { method, headers: headers || {}, body: fetchBody ?? null, signal: controller.signal, cache: 'no-store', } let reader: ReadableStreamDefaultReader 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.controller !== controller) return this.controller = null await this.notifyDisconnected() await this.notifyError(error) this.messages.close() throw error } // Connection succeeded but was already replaced (for example abort during fetch). if (this.controller !== controller) { await reader.cancel() return } await SSESession.tryAsync( () => this.options.onConnected(), (error) => this.notifyError(error), ) this.emit('connected', undefined) // Fire-and-forget: connect() resolves while the stream is consumed. this.readStream(reader, controller).catch((error) => { this.options.onError(error) }) } /** * 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 { if (!this.controller) return // Grab the current controller to ensure we are aborting the correct one. const controller = this.controller this.controller = null // Invalidate any in-flight read loop and fetch for this transport. controller.abort() this.options.eventParser.reset() 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 { this.messages.close() this.emit('closed', undefined) if (this.controller) { await this.abort() } else { this.options.eventParser.reset() } } /** * 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> { const requestOptions = await this.options.onRequest(fetchOptions) const response = await this.options.fetch(this.url, requestOptions) if (!response.ok) { const responseCode = response.status const responseText = await response.text() const error = new Error(`HTTP error: ${responseCode} ${responseText}`) void this.notifyError(error) throw error } if (!response.body) { const error = new Error('HTTP error: Response body is null') void this.notifyError(error) throw error } 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, controller: AbortController): Promise { try { while (this.controller === controller) { const { done, value } = await reader.read() // abort() or a newer connect() may have landed while we were awaiting. if (this.controller !== controller) return if (done) { this.controller = null await this.notifyDisconnected() if (this.options.persistent) { // Server closed gracefully — reopen unless the consumer opted out. await this.connect() } else { this.messages.close() } 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 the controller is different, we already started a new connection and it would be confusing to handle this error. if (controller !== this.controller) return // Invalidate the current controller to allow for reconnection if needed this.controller = null 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.messages.close() } } } /** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */ private async notifyDisconnected(): Promise { await SSESession.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 { const errorInstance = error instanceof Error ? error : new Error(String(error)) await SSESession.tryAsync( () => this.options.onError(errorInstance), (callbackError) => console.error('SSESession error:', callbackError), ) this.emit('error', errorInstance) } /** Runs `fn`, passing any error to `onError` instead of throwing. */ private static async tryAsync(fn: () => unknown, onError?: (error: Error) => void): Promise { try { await fn() } catch (error) { onError?.(error instanceof Error ? error : new Error(`${error}`)) } } }