@andreasnicolaou/reactive-event-source
Version:
A lightweight reactive wrapper around EventSource using RxJS, providing automatic reconnection and buffering.
250 lines (246 loc) • 10.4 kB
JavaScript
import { Subject, BehaviorSubject, ReplaySubject, defer, throwError, fromEvent, of, EMPTY, timer, merge } from 'rxjs';
import { switchMap, raceWith, retry, takeUntil, finalize, share, catchError } from 'rxjs/operators';
class EventSourceError extends Error {
attempt;
constructor(message, attempt = -1) {
const attemptMessage = attempt > -1 ? ` - Attempt: ${attempt}` : '';
super(`EventSource Error: ${message}${attemptMessage}`);
this.name = 'EventSourceError';
this.attempt = attempt;
}
}
class ReactiveEventSource {
destroy$ = new Subject();
eventListenerCleanup = new Map();
eventSource$;
eventSubjects = new Map();
lastEventSource;
options;
readyStateSubject$ = new BehaviorSubject(2); // Track connection state
subscriptions = new Map(); // Track subscriptions for cleanup
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.readyStateSubject$.value;
}
/**
* Observable that emits the current connection state
* @returns Observable<number> Stream of connection state changes
* @author Andreas Nicolaou
* @memberof ReactiveEventSource
*/
get readyState$() {
return this.readyStateSubject$.asObservable();
}
/**
* 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() {
// Clean up subscriptions first
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
this.subscriptions.clear();
// Clean up event listeners
this.eventListenerCleanup.forEach((cleanup) => cleanup());
this.eventListenerCleanup.clear();
// Close the EventSource
this.lastEventSource?.close();
// Update ready state
this.readyStateSubject$.next(2);
// Complete all event subjects
this.eventSubjects.forEach((subject) => {
if (!subject.closed) {
subject.complete();
}
});
this.eventSubjects.clear();
// Complete internal subjects
this.destroy$.next();
this.destroy$.complete();
this.readyStateSubject$.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)) {
// Always use ReplaySubject(1) to buffer the last event
const subject = new ReplaySubject(1);
this.eventSubjects.set(eventType, subject);
// Set up event listener for this specific event type
this.setupEventListener(eventType, subject);
}
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) {
/* istanbul ignore next */
return throwError(() => new EventSourceError('EventSource is not supported in this environment'));
}
// Close previous EventSource if it exists before creating a new one
/* istanbul ignore next */
if (this.lastEventSource) {
this.lastEventSource.close();
}
this.lastEventSource = new EventSource(this.url, { withCredentials: this.options.withCredentials });
// Update ready state immediately
this.readyStateSubject$.next(this.lastEventSource.readyState);
const open$ = fromEvent(this.lastEventSource, 'open').pipe(switchMap(() => {
this.readyStateSubject$.next(1); // OPEN
return of(this.lastEventSource);
}));
const error$ = fromEvent(this.lastEventSource, 'error').pipe(switchMap(() => {
this.readyStateSubject$.next(this.lastEventSource.readyState);
/* istanbul ignore next */
if (this.lastEventSource.readyState === EventSource.CONNECTING) {
return throwError(() => new EventSourceError('Initial connection failed'));
}
return EMPTY;
}));
const timeout$ = timer(this.options.connectionTimeout).pipe(
/* istanbul ignore next */
switchMap(() => {
/* istanbul ignore next */
this.readyStateSubject$.next(2); // CLOSED
/* istanbul ignore next */
return throwError(() => new EventSourceError('Connection timeout'));
}));
return merge(open$, error$).pipe(raceWith(timeout$), retry({
count: this.options.maxRetries,
delay: (error, attempt) => {
/* istanbul ignore next */
if (error instanceof EventSourceError) {
/* istanbul ignore next */
if (attempt >= this.options.maxRetries) {
this.readyStateSubject$.next(2); // CLOSED
return throwError(() => new EventSourceError(error.message));
}
/* istanbul ignore next */
const baseDelay = Math.min(this.options.initialDelay * Math.pow(2, attempt), this.options.maxDelay);
/* istanbul ignore next */
const retryAfter = Math.random() * baseDelay;
/* istanbul ignore next */
console.log(`Retrying connection (attempt ${attempt + 1}) in ${retryAfter}ms`);
/* istanbul ignore next */
this.readyStateSubject$.next(0); // CONNECTING
/* istanbul ignore next */
return timer(retryAfter);
}
/* istanbul ignore next */
return throwError(() => new EventSourceError('Unrecoverable EventSource error', attempt));
},
}), takeUntil(this.destroy$), finalize(() => {
/* istanbul ignore next */
if (this.lastEventSource) {
this.lastEventSource.close();
}
/* istanbul ignore next */
this.readyStateSubject$.next(2); // CLOSED
}));
}).pipe(share({
connector: () => new ReplaySubject(1),
resetOnComplete: false,
resetOnError: false,
resetOnRefCountZero: false,
}));
}
/**
* Sets up an event listener for a specific event type
* @param eventType - The event type to listen for
* @param subject - The subject to emit events to
*/
setupEventListener(eventType, subject) {
const subscription = this.eventSource$
.pipe(takeUntil(this.destroy$), switchMap((eventSource) => {
// Update ready state when we get a new EventSource
this.readyStateSubject$.next(eventSource.readyState);
return fromEvent(eventSource, eventType).pipe(catchError((err) => {
/* istanbul ignore next */
console.error(`Error in "${eventType}" event`, err);
/* istanbul ignore next */
subject.error(err);
/* istanbul ignore next */
return EMPTY;
}));
}))
.subscribe({
next: (event) => subject.next(event),
/* istanbul ignore next */
error: (err) => subject.error(err),
/* istanbul ignore next */
complete: () => subject.complete(),
});
// Store subscription for cleanup
this.subscriptions.set(eventType, subscription);
// Store cleanup function
this.eventListenerCleanup.set(eventType, () => {
// Unsubscribe first
const sub = this.subscriptions.get(eventType);
if (sub) {
sub.unsubscribe();
this.subscriptions.delete(eventType);
}
// Complete subject if not already closed
/* istanbul ignore next */
if (!subject.closed) {
subject.complete();
}
});
}
}
export { EventSourceError, ReactiveEventSource };