diff --git a/packages/utils/src/typedEventEmitter.ts b/packages/utils/src/typedEventEmitter.ts new file mode 100644 index 0000000000..baf0677372 --- /dev/null +++ b/packages/utils/src/typedEventEmitter.ts @@ -0,0 +1,59 @@ +/* + +This file should not be exported from index to prevent missing dependency/polyfill error. +EventEmitter is accessible in nodejs but requires polyfills in web builds. + +use: +import { TypedEmitter } from '@trezor/utils/lib/typedEventEmitter'; + +example: +type EventMap = { + obj: { id: string }; + primitive: boolean | number | string | symbol; + noArgs: undefined; + multipleArgs: (a: number, b: string, c: boolean) => void; + [type: `dynamic/${string}`]: boolean; +}; + +*/ + +import { EventEmitter } from 'events'; + +type EventMap = Record; +type EventKey = string & keyof T; + +type IsUnion = T extends unknown ? ([U] extends [T] ? 0 : 1) : 2; + +// NOTE: case 1. looks like case 4. but works differently. the order matters +type EventReceiver = IsUnion extends 1 + ? (event: T) => void // 1. use union payload + : T extends (...args: any[]) => any + ? T // 2. use custom callback + : T extends undefined + ? () => void // 3. enforce empty params + : (event: T) => void; // 4. default + +export interface TypedEmitter { + on>(eventName: K, fn: EventReceiver): this; + once>(eventName: K, fn: EventReceiver): this; + addListener>(eventName: K, fn: EventReceiver): this; + + prependListener>(eventName: K, fn: EventReceiver): this; + prependOnceListener>(eventName: K, fn: EventReceiver): this; + + off>(eventName: K, fn: EventReceiver): this; + removeListener>(eventName: K, fn: EventReceiver): this; + removeAllListeners>(event?: K): this; + + emit>(eventName: K, ...params: Parameters>): boolean; + + listeners>(eventName: K): EventReceiver[]; + rawListeners>(eventName: K): EventReceiver[]; +} + +export class TypedEmitter extends EventEmitter { + // implement at least one function + listenerCount>(eventName: K) { + return super.listenerCount(eventName); + } +} diff --git a/packages/utils/tests/typedEventEmitter.test.ts b/packages/utils/tests/typedEventEmitter.test.ts new file mode 100644 index 0000000000..dd18aa7a8a --- /dev/null +++ b/packages/utils/tests/typedEventEmitter.test.ts @@ -0,0 +1,120 @@ +import { TypedEmitter } from '../src/typedEventEmitter'; + +type PayloadUnion = { foo: number } | { bar: string }; + +type Events = { + obj: { id: string }; + bool: boolean; + nr: number; + str: string; + union: PayloadUnion; + unionMultiple: (a: PayloadUnion, b: PayloadUnion) => Promise; + noArgs: undefined; + multipleArgs: (a: number, b: (success: boolean, err?: Error) => void) => void; + [type: `dynamic/${string}`]: [(str: string) => boolean]; +}; + +describe('typedEventEmitter', () => { + const emitter = new TypedEmitter(); + + beforeEach(() => { + emitter.removeAllListeners(); + }); + + it('object payload', () => { + emitter.once('obj', obj => { + expect(typeof obj.id).toBe('string'); + }); + emitter.emit('obj', { id: 'id' }); + emitter.off('obj', obj => { + expect(typeof obj.id).toBe('string'); + }); + emitter.removeAllListeners('obj'); + + // @ts-expect-error + emitter.emit('obj'); + // @ts-expect-error + emitter.emit('obj', 1); + }); + + it('no payload', () => { + // @ts-expect-error + emitter.on('noArgs', arg => { + expect(typeof arg).toBe('undefined'); + }); + emitter.emit('noArgs'); + emitter.removeAllListeners('noArgs'); + }); + + it('boolean payload', () => { + emitter.once('bool', bool => { + expect(typeof bool).toBe('boolean'); + }); + emitter.emit('bool', true); + + // @ts-expect-error + emitter.emit('bool'); + // @ts-expect-error + emitter.emit('bool', 1); + + emitter.on('nr', nr => expect(nr).toBe(1)); + + const asyncCb = (nr: number) => Promise.resolve(expect(nr).toBe(1)); + emitter.on('nr', asyncCb); + }); + + it('union payload', () => { + emitter.on('union', m => { + if (!m) return; + + if ('foo' in m) { + expect(m.foo).toBe(1); + } + + if ('bar' in m) { + expect(m.bar).toBe('bar'); + } + }); + + const p = emitter.listenerCount('union') > 0 ? { foo: 1 } : { bar: 'bar' }; + + emitter.emit('union', p); + emitter.emit('unionMultiple', p, p); + emitter.emit('union', { foo: 1 }); + emitter.emit('unionMultiple', { foo: 1 }, { bar: 'bar' }); + emitter.emit('union', { bar: 'bar' }); + + // @ts-expect-error + emitter.emit('union'); + // @ts-expect-error + emitter.emit('union', { foo: 1, err: 'err' }); + }); + + it('multiple arguments', () => { + // @ts-expect-error + emitter.emit('multipleArgs'); + // @ts-expect-error + emitter.emit('multipleArgs', true); + // @ts-expect-error + emitter.emit('multipleArgs', [1, () => {}]); + + emitter.on('multipleArgs', (nr, callback) => { + expect(typeof nr).toBe('number'); + expect(typeof callback).toBe('function'); + + callback(false, new Error('a')); + }); + emitter.emit('multipleArgs', 1, (_a, _b) => {}); + }); + + it('dynamic event name', () => { + emitter.on('dynamic/abc', ([cb]) => { + const resp = cb('foo'); + expect(typeof resp).toBe('boolean'); + }); + + emitter.emit('dynamic/abc', [(str: string) => typeof str === 'string']); + // @ts-expect-error + emitter.emit('dynamic', (str: string) => typeof str === 'string'); + }); +});