Skip to content

Instantly share code, notes, and snippets.

@jednano
Last active October 21, 2022 10:52
Show Gist options
  • Save jednano/6e5b701e24167a438e93fab3716c4a7a to your computer and use it in GitHub Desktop.
Save jednano/6e5b701e24167a438e93fab3716c4a7a to your computer and use it in GitHub Desktop.
TypeScript Polling / Event Emitter
import { noop } from 'lodash';
import EventEmitter from './EventEmitter';
describe('EventEmitter class', () => {
describe('on()', () => {
it('subscribes to foo event with listener', () => {
const e = new EventEmitter();
e.on('foo', noop);
// tslint:disable-next-line:no-any
expect((e as any).registry.has('foo')).toBe(true);
});
describe('result (off) function', () => {
it('returns true, then false if there was only one listener to remove', () => {
const e = new EventEmitter();
const off = e.on('foo', noop);
const off2 = e.on('foo', () => { /* different noop */ });
expect(off()).toBe(true);
expect(off()).toBe(false);
expect(off2()).toBe(true);
expect(off2()).toBe(false);
});
});
});
describe('emit()', () => {
it('returns false if no event name found in registry', () => {
const e = new EventEmitter();
expect(e.emit('foo')).toBe(false);
});
it('returns true if listener was called', async (done) => {
const e = new EventEmitter();
e.on('foo', done);
expect(e.emit('foo')).toBe(true);
});
it('calls foo listener on emit foo', async (done) => {
const e = new EventEmitter();
e.on('foo', done);
e.emit('foo');
});
it('sends additional args to listener', async (done) => {
const e = new EventEmitter();
const args = ['bar', 'baz', 'qux'];
e.on('foo', (...callbackArgs: typeof args) => {
expect(callbackArgs).toEqual(args);
done();
});
e.emit('foo', ...args);
});
});
});
export default class EventEmitter {
// tslint:disable-next-line:no-any
private registry = new Map<string, Set<any>>();
/**
* Synchronously calls each of the listeners registered for the event named
* `eventName`, in the order they were registered, passing the supplied
* arguments to each.
* @returns `true` if the event had listeners, `false` otherwise.
*/
// tslint:disable-next-line:no-any
public emit<T extends any[]>(eventName: string, ...rest: T) {
const listeners = this.registry.get(eventName);
if (!listeners) {
return false;
}
listeners.forEach((listener) => {
listener(...rest);
});
return true;
}
/**
* Adds the `listener` to the `eventName` set.
* @returns a function that removes the `listener` from the `eventName` set.
*/
// tslint:disable-next-line:no-any
public on<T extends any[]>(
eventName: string,
listener: (...args: T) => void,
) {
{
let listeners = this.registry.get(eventName);
/* istanbul ignore else */
if (!listeners) {
this.registry.set(eventName, listeners = new Set());
}
listeners.add(listener);
}
/**
* Removes the `listener` from the `eventName` set.
*/
const off = () => {
const listeners = this.registry.get(eventName);
return (!listeners || !listeners.has(listener))
? false
: (listeners.size === 1)
? this.registry.delete(eventName)
: listeners.delete(listener);
};
return off;
}
}
import Poller from './Poller';
describe('Poller class', () => {
describe('constructor', () => {
it('does not immediately call iteratee', () => {
const iteratee = jest.fn();
const poller = new Poller(iteratee, 0);
expect(iteratee).not.toHaveBeenCalled();
poller.stop();
});
});
describe('emit: tick', () => {
it('emits one tick with a true result', async (done) => {
const poller = new Poller(() => true, 0);
poller.on('error', () => done.fail('should not emit error'));
poller.on('stop', () => done.fail('should not emit stop'));
const ptick = new Promise((resolve) => poller.on('tick', (isDone) => {
expect(isDone).toBe(true);
resolve();
}));
const pdone = new Promise((resolve) => poller.on('done', () => resolve()));
try {
await Promise.all([ptick, pdone]);
done();
} catch (err) {
done.fail('expected emits: tick + done');
}
});
it('emits one tick with a Promise<true> result', async (done) => {
const poller = new Poller(() => Promise.resolve(true), 0);
poller.on('error', () => done.fail('should not emit error'));
poller.on('stop', () => done.fail('should not emit stop'));
const ptick = new Promise((resolve) => poller.on('tick', async (isDone) => {
expect(await isDone).toBe(true);
resolve();
}));
const pdone = new Promise((resolve) => poller.on('done', () => resolve()));
try {
await Promise.all([ptick, pdone]);
done();
} catch (err) {
done.fail('expected emits: tick + done');
}
});
it('ticks false, true, then emits done', async (done) => {
const reverseTicks = [true, false];
const iteratee = jest.fn().mockImplementation(() => reverseTicks.pop() as boolean);
const poller = new Poller(iteratee, 0);
poller.on('error', () => done.fail('should not emit error'));
poller.on('stop', () => done.fail('should not emit stop'));
const ticks: boolean[] = [];
const ptick = new Promise((resolve) => poller.on('tick', (isDone) => {
ticks.push(isDone);
if (isDone) {
resolve();
}
}));
const pdone = new Promise((resolve) => poller.on('done', () => resolve()));
try {
await Promise.all([ptick, pdone]);
expect(ticks).toEqual([false, true]);
done();
} catch (err) {
done.fail('expected emits: tick + done');
}
});
it('ticks Promise<false>, Promise<true>, then emits done', async (done) => {
const reverseTicks = [Promise.resolve(true), Promise.resolve(false)];
const iteratee = jest.fn().mockImplementation(() => reverseTicks.pop());
const poller = new Poller(iteratee, 0);
poller.on('error', () => done.fail('should not emit error'));
poller.on('stop', () => done.fail('should not emit stop'));
const ticks: Array<Promise<boolean>> = [];
const ptick = new Promise((resolve) => poller.on('tick', async (isDone) => {
ticks.push(isDone);
if (await isDone) {
resolve();
}
}));
const pdone = new Promise((resolve) => poller.on('done', () => resolve()));
try {
await Promise.all([ptick, pdone]);
expect(await Promise.all(ticks)).toEqual([false, true]);
done();
} catch (err) {
done.fail('expected emits: tick + done');
}
});
it('does not emit done if timer stopped on a tick', (done) => {
const iteratee = jest.fn().mockImplementation(() => false);
const poller = new Poller(iteratee, 0);
poller.on('error', () => done.fail('should not emit error'));
poller.on('done', () => done.fail('should not emit done'));
poller.on('tick', () => poller.stop());
poller.on('stop', () => done());
});
});
describe('stop()', () => {
it('emits immediate stop w/o ticking', (done) => {
const iteratee = jest.fn();
const poller = new Poller(iteratee, 0);
poller.on('tick', () => done.fail('should not emit tick'));
poller.on('error', () => done.fail('should not emit error'));
poller.on('done', () => done.fail('should not emit done'));
poller.on('stop', () => {
expect(iteratee).not.toHaveBeenCalled();
done();
});
poller.stop();
});
it('emits tick + stop when stop is delayed', async (done) => {
const iteratee = jest.fn();
const poller = new Poller(iteratee, 0);
poller.on('error', () => done.fail('should not emit error'));
poller.on('done', () => done.fail('should not emit done'));
const ptick = new Promise((resolve) => poller.on('tick', () => resolve()));
const pstop = new Promise((resolve) => poller.on('stop', () => resolve()));
setTimeout(() => poller.stop(), 0);
try {
await Promise.all([ptick, pstop]);
done();
} catch (err) {
done.fail('expected emits: tick + stop');
}
});
});
it('emits same error that iteratee throws', (done) => {
const err = new Error();
const iteratee = jest.fn().mockImplementation(() => {
throw err;
});
const poller = new Poller(iteratee, 0);
poller.on('tick', done.fail);
poller.on('stop', done.fail);
poller.on('done', done.fail);
poller.on('error', (thrownError) => {
expect(thrownError).toBe(err);
done();
});
});
});
import EventEmitter from './EventEmitter';
export default class Poller<I extends () => boolean | Promise<boolean>> {
private timer: number | null = window.setTimeout(() => this.start(), 0);
private emitter = new EventEmitter();
public get running() {
return this.timer !== null;
}
constructor(
/**
* This function will keep getting called until it returns or resolves
* `true`, which cancels/stops the polling.
*/
private iteratee: I,
/**
* The delay in milliseconds between each function call, plus any time
* it takes the `iteratee` to complete, async or not. This also prevents
* overlapping timers.
*/
private delay: number,
) {}
/**
* @emits Poller#on('done')
* @emits Poller#on('error')
*/
public start() {
this.tick();
}
/**
* Clears timer and prevents recursive starts from creating new ones.
* @emits Poller#on('stop')
*/
public stop() {
clearTimeout(this.timer as number);
this.timer = null;
this.emitter.emit('stop');
}
/**
* Adds the `listener` to the `eventName` set.
* @returns a function that removes the `listener` from the `eventName` set.
*/
// tslint:disable-next-line:no-any
public on<T extends any[]>(
eventName: 'tick' | 'stop' | 'done' | 'error',
listener: (...args: T) => void,
) {
this.emitter.on(eventName, listener);
}
private async tick() {
try {
const iterateeResult = this.iteratee();
this.emitter.emit('tick', iterateeResult);
const done = await iterateeResult;
if (!this.running) {
return;
}
if (done === true) {
this.emitter.emit('done');
return;
}
} catch (err) {
this.emitter.emit('error', err);
return;
}
this.timer = window.setTimeout(() => this.tick(), this.delay);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment