UNPKG

@andreasnicolaou/reactive-event-source

Version:

A lightweight reactive wrapper around EventSource using RxJS, providing automatic reconnection and buffering.

167 lines (166 loc) 7.1 kB
import { Subject, fromEvent, throwError, timer, merge, defer, EMPTY, ReplaySubject, of } from 'rxjs'; import { retry, catchError, switchMap, takeUntil, finalize, share, raceWith } from 'rxjs/operators'; import { EventSourceError } from './error'; export class ReactiveEventSource { destroy$ = new Subject(); eventSource$; eventSubjects = new Map(); lastEventSource; options; url; /** * Creates an instance of reactive event source. * @param url - The URL or URL object for the SSE connection. * @param options - Optional configuration for retry behavior and credentials. */ constructor(url, options) { this.url = url; this.options = { maxRetries: 3, initialDelay: 1000, maxDelay: 10000, connectionTimeout: 15000, withCredentials: false, ...options, }; this.eventSource$ = this.createEventSource(); } /** * Gets the current connection state of the EventSource * @returns {0 | 1 | 2} The ready state: * - 0 (CONNECTING): The connection is being established * - 1 (OPEN): The connection is active and receiving events * - 2 (CLOSED): The connection is closed or failed * @author Andreas Nicolaou * @memberof ReactiveEventSource */ get readyState() { return this.lastEventSource?.readyState ?? 2; } /** * Determines if credentials (cookies, HTTP auth) are sent with requests * @returns {boolean} True if credentials are being sent, false otherwise * @author Andreas Nicolaou * @memberof ReactiveEventSource */ get withCredentials() { return this.options.withCredentials ?? false; } /** * Gets the resolved URL string used for the EventSource connection * @returns {string} The fully qualified URL as a string * @author Andreas Nicolaou * @memberof ReactiveEventSource */ get URL() { return this.url.toString(); } /** * Closes the SSE connection and completes all internal subjects and observables. * @author Andreas Nicolaou * @memberof ReactiveEventSource */ close() { this.lastEventSource?.close(); this.eventSubjects.forEach((subject) => { if (!subject.closed) { subject.complete(); } }); this.eventSubjects.clear(); this.destroy$.next(); this.destroy$.complete(); } /** * Subscribes to a specific SSE event type and returns it as an Observable. * @param eventType - The name of the event to listen for (e.g., "message", "error", "open"). * @returns An Observable that emits events of the given type. * @author Andreas Nicolaou * @memberof ReactiveEventSource */ on(eventType = 'message') { if (!this.eventSubjects.has(eventType)) { this.eventSubjects.set(eventType, new ReplaySubject(1)); // Buffers last event this.eventSource$ .pipe(takeUntil(this.destroy$), switchMap((eventSource) => fromEvent(eventSource, eventType).pipe(catchError((err) => { console.error(`Error in "${eventType}" event`, err); return EMPTY; }))), finalize(() => { this.eventSubjects.delete(eventType); })) .subscribe((event) => { this.eventSubjects.get(eventType)?.next(event); }); } return this.eventSubjects.get(eventType).asObservable(); } /** * Creates an observable EventSource instance with optional credentials and automatic reconnection. * @returns An observable that manages the EventSource lifecycle and reconnection strategy. * @author Andreas Nicolaou * @memberof ReactiveEventSource */ createEventSource() { return defer(() => { if (!window.EventSource) { return throwError(() => new EventSourceError('EventSource is not supported in this environment')); } this.lastEventSource = new EventSource(this.url, { withCredentials: this.options.withCredentials }); this.setupEventForwarding(); const open$ = fromEvent(this.lastEventSource, 'open').pipe(switchMap(() => of(this.lastEventSource))); const error$ = fromEvent(this.lastEventSource, 'error').pipe(switchMap(() => { if (this.lastEventSource.readyState === EventSource.CONNECTING) { return throwError(() => new EventSourceError('Initial connection failed')); } return EMPTY; })); const timeout$ = timer(this.options.connectionTimeout ?? 15000).pipe(switchMap(() => throwError(() => new EventSourceError('Connection timeout')))); return merge(open$, error$).pipe(raceWith(timeout$), retry({ count: this.options.maxRetries, delay: (error, attempt) => { if (error instanceof EventSourceError) { if (attempt >= this.options.maxRetries) { return throwError(() => new EventSourceError(error.message)); } const baseDelay = Math.min(this.options.initialDelay * Math.pow(2, attempt), this.options.maxDelay); const retryAfter = Math.random() * baseDelay; console.log(`Retrying connection (attempt ${attempt + 1}) in ${retryAfter}ms`); return timer(retryAfter); } return throwError(() => new EventSourceError('Unrecoverable EventSource error', attempt)); }, }), takeUntil(this.destroy$), finalize(() => { this.lastEventSource?.close(); this.eventSubjects.forEach((subject) => subject.complete()); this.eventSubjects.clear(); })); }).pipe(share({ connector: () => new ReplaySubject(1), resetOnComplete: false, resetOnError: false, resetOnRefCountZero: false, })); } /** * Sets up listeners on the EventSource for core events (open, message, error), * forwarding those events to the corresponding subjects for subscribers. * @author Andreas Nicolaou * @memberof ReactiveEventSource */ setupEventForwarding() { const coreEvents = ['open', 'message', 'error']; coreEvents.forEach((eventType) => { if (!this.eventSubjects.has(eventType)) { this.eventSubjects.set(eventType, new ReplaySubject(1)); } fromEvent(this.lastEventSource, eventType) .pipe(takeUntil(this.destroy$), catchError((err) => { console.error(`Error in "${eventType}" event`, err); return EMPTY; })) .subscribe((event) => { this.eventSubjects.get(eventType)?.next(event); }); }); } }