@andreasnicolaou/reactive-event-source
Version:
A lightweight reactive wrapper around EventSource using RxJS, providing automatic reconnection and buffering.
225 lines (224 loc) • 8.2 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { lastValueFrom } from 'rxjs';
import { take, toArray } from 'rxjs/operators';
import { ReactiveEventSource } from './index';
// Mock EventSource
class MockEventSource {
static instances = [];
callbacks = {};
closeCalled = false;
readyState = 0;
url;
withCredentials = false;
constructor(url, options) {
this.url = url;
this.withCredentials = options?.withCredentials ?? false;
MockEventSource.instances.push(this);
setTimeout(() => {
if (this.readyState === 0 && !this.closeCalled) {
this.readyState = 1;
this.emit('open');
}
}, 10);
}
static clearInstances() {
MockEventSource.instances = [];
}
addEventListener(type, callback) {
this.callbacks[type] = callback;
}
close() {
this.readyState = 2;
this.closeCalled = true;
}
emit(type, data) {
if (this.callbacks[type]) {
this.callbacks[type]({ type, data });
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
removeEventListener() { }
}
const testUrl = 'http://test.com/events';
const setupAndSubscribeTo = (eventType) => {
const source = new ReactiveEventSource(testUrl);
const subscription = source.on(eventType).subscribe();
const mock = MockEventSource.instances[0];
mock.emit('open');
return { source, mock, subscription };
};
let originalEventSource;
beforeAll(() => {
originalEventSource = window.EventSource;
window.EventSource = MockEventSource;
});
afterAll(() => {
window.EventSource = originalEventSource;
});
beforeEach(() => {
jest.useFakeTimers();
window.EventSource = MockEventSource;
MockEventSource.clearInstances();
jest.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-empty-function
jest.spyOn(console, 'error').mockImplementation(() => { });
});
afterEach(() => {
jest.useRealTimers();
MockEventSource.clearInstances();
});
describe('ReactiveEventSource', () => {
it('should create an EventSource with the correct URL', async () => {
const { source, mock } = setupAndSubscribeTo('error');
const sub = source.on('open').subscribe();
jest.advanceTimersByTime(10);
expect(mock).toBeDefined();
expect(mock.url).toBe(testUrl);
mock.emit('open');
source.close();
sub.unsubscribe();
});
it('should set withCredentials when configured', () => {
const source = new ReactiveEventSource(testUrl, { withCredentials: true });
expect(source.withCredentials).toBe(true);
});
it('should emit message events', (done) => {
const { source, mock } = setupAndSubscribeTo('message');
source
.on('message')
.pipe(take(1))
.subscribe((event) => {
expect(event.type).toBe('message');
expect(event?.data).toBe('test data');
done();
});
mock.emit('message', 'test data');
});
it('should emit error events', (done) => {
const { source, mock } = setupAndSubscribeTo('error');
source
.on('error')
.pipe(take(1))
.subscribe((event) => {
expect(event.type).toBe('error');
done();
});
mock.emit('error');
});
it('should complete all observables when closed', async () => {
const { source, mock } = setupAndSubscribeTo('message');
const messages$ = source.on('message').pipe(toArray());
const errors$ = source.on('error').pipe(toArray());
mock.emit('open');
source.close();
const [messages, errors] = await Promise.all([lastValueFrom(messages$), lastValueFrom(errors$)]);
expect(messages.length).toBe(0);
expect(errors.length).toBe(0);
});
it('should connect to EventSource', () => {
const source = new ReactiveEventSource(testUrl);
source.on('message').subscribe();
expect(MockEventSource.instances.length).toBe(1);
});
it('should closes EventSource stream cleanly', () => {
const source = new ReactiveEventSource(testUrl);
source.on('message').subscribe();
source.close();
expect(MockEventSource.instances[0].closeCalled).toBe(true);
});
it('should throw error when EventSource is not supported', () => {
window.EventSource = undefined;
try {
new ReactiveEventSource(testUrl);
}
catch (e) {
expect(e.message).toContain('not supported');
}
finally {
window.EventSource = MockEventSource;
}
});
it('should report correct readyState', () => {
const source = new ReactiveEventSource(testUrl);
source.on('message').subscribe();
jest.advanceTimersByTime(1);
const mock = MockEventSource.instances[0];
mock.readyState = 0;
expect(source.readyState).toBe(0);
mock.readyState = 1;
mock.emit('open');
expect(source.readyState).toBe(1);
mock.readyState = 2;
mock.emit('error');
expect(source.readyState).toBe(2);
});
it('should not timeout if open event received', async () => {
const source = new ReactiveEventSource(testUrl, {
connectionTimeout: 100,
});
const errorSpy = jest.fn();
source.on('error').subscribe(errorSpy);
const mock = MockEventSource.instances[0];
mock.emit('open');
jest.advanceTimersByTime(150);
expect(errorSpy).not.toHaveBeenCalled();
});
it('should prevent multiple close() calls', () => {
const source = new ReactiveEventSource(testUrl);
const subscription = source.on('message').subscribe();
source.close();
source.close(); // Should not throw or cause issues
expect(source.readyState).toBe(2);
subscription.unsubscribe();
});
it('should return EMPTY observable when trying to subscribe after close', () => {
const source = new ReactiveEventSource(testUrl);
source.close();
const observable = source.on('message');
expect(observable).toBeDefined();
let eventReceived = false;
const subscription = observable.subscribe(() => {
eventReceived = true;
});
expect(eventReceived).toBe(false);
subscription.unsubscribe();
});
it('should properly clean up subscriptions on close', () => {
const source = new ReactiveEventSource(testUrl);
const messageSubscription = source.on('message').subscribe();
const errorSubscription = source.on('error').subscribe();
const openSubscription = source.on('open').subscribe();
jest.advanceTimersByTime(10);
expect(messageSubscription.closed).toBe(false);
expect(errorSubscription.closed).toBe(false);
expect(openSubscription.closed).toBe(false);
source.close();
// Verify all internal state is cleaned up
expect(source.readyState).toBe(2);
messageSubscription.unsubscribe();
errorSubscription.unsubscribe();
openSubscription.unsubscribe();
});
it('should handle rapid subscribe/unsubscribe cycles without leaks', () => {
const source = new ReactiveEventSource(testUrl);
for (let i = 0; i < 10; i++) {
const sub = source.on('message').subscribe();
sub.unsubscribe();
}
const finalSub = source.on('message').subscribe();
expect(finalSub).toBeDefined();
source.close();
finalSub.unsubscribe();
});
it('should not create new subjects after close', () => {
const source = new ReactiveEventSource(testUrl);
source.close();
const customEventObs = source.on('custom-event');
let eventReceived = false;
const subscription = customEventObs.subscribe(() => {
eventReceived = true;
});
expect(eventReceived).toBe(false);
subscription.unsubscribe();
});
});