Last active
October 21, 2022 10:52
-
-
Save jednano/6e5b701e24167a438e93fab3716c4a7a to your computer and use it in GitHub Desktop.
TypeScript Polling / Event Emitter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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