├── .gitignore ├── .npmignore ├── README.md ├── jest.config.js ├── package.json ├── src ├── EventDispatcher.ts ├── decorators │ └── index.ts ├── index.ts ├── interfaces.ts ├── metadata │ └── index.ts └── utils │ ├── Container.ts │ ├── Logger.ts │ ├── errors.ts │ └── isPromise.ts ├── tests └── functional │ └── EventDispatcher.test.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | .idea 3 | 4 | # Below from: https://github.com/github/gitignore/blob/master/Node.gitignore 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | .env.test 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # next.js build output 78 | .next 79 | 80 | # nuxt.js build output 81 | .nuxt 82 | 83 | # gatsby files 84 | .cache/ 85 | public 86 | 87 | # vuepress build output 88 | .vuepress/dist 89 | 90 | # Serverless directories 91 | .serverless/ 92 | 93 | # FuseBox cache 94 | .fusebox/ 95 | 96 | # DynamoDB Local files 97 | .dynamodb/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | coverage 3 | tests 4 | README.md 5 | tsconfig.json 6 | tslint.json 7 | *.spec.ts 8 | *.test.ts 9 | .prettierrc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

type-events

2 |

3 | A simple @decorator based event dispatcher. 4 |

5 | 6 |

7 | NPM Version 8 | Package License 9 | NPM Downloads 10 |

11 | 12 | ### Basics 13 | 14 | `type-events` allows you to create simple ways dispatch and subscribe to events. 15 | 16 | ```typescript 17 | import { EventDispatcher, On } from 'type-events'; 18 | 19 | class Conversion { 20 | constructor(public userAgent: string, public revenue: number) {} 21 | } 22 | 23 | class Impression { 24 | constructor(public userAgent: string) {} 25 | } 26 | 27 | export class TrackingSubscriber { 28 | @On(Conversion) 29 | async onConversion(event: Conversion): Promise { 30 | // do something with conversion events 31 | } 32 | 33 | // The higher the priority, the sooner it's processed. 34 | // Priority is not guaranteed for same-priority values. 35 | @On(Impression, { priority: 255 }) 36 | async onImpression(event: Impression): Promise { 37 | // do something with impression events 38 | } 39 | } 40 | 41 | export class NotifySlack { 42 | // `background: true` makes this subscriber run after all other 43 | // subscribers and doesn't wait for the result to finish 44 | @On([Impression, Conversion], { background: true }) 45 | async notify(event: Impression | Conversion): Promise { 46 | switch (event.constructor.name) { 47 | case 'Impression': 48 | // ... 49 | break; 50 | case 'Conversion': 51 | // ... 52 | break; 53 | } 54 | } 55 | } 56 | 57 | const dispatcher = new EventDispatcher({ 58 | subscribers: [TrackingSubscriber, NotifySlack] 59 | }); 60 | 61 | // then dispatch the events somewhere! 62 | dispatcher.dispatch(new Conversion('Chrome', 13.37)); 63 | ``` 64 | 65 | ### Custom Container (DI) 66 | 67 | Most of the time, you want to use some sort of dependency injection (DI) alongside event dispatching. Don't you worry, you can still do that. 68 | Just pass in an appropriate DI container with a valid `get` method. 69 | 70 | ```typescript 71 | import { Container } from 'inversify'; 72 | 73 | const container = new Container(); 74 | // container bindings go here 75 | 76 | const dispatcher = new EventDispatcher({ 77 | subscribers: [...], 78 | container 79 | }); 80 | ``` 81 | 82 | ## Inheritance 83 | 84 | Events can extend base classes and subscribers can subscribe to those base classes. 85 | 86 | ```typescript 87 | import { EventDispatcher, On } from 'type-events'; 88 | 89 | abstract class BaseEvent { 90 | // ... 91 | } 92 | 93 | class UserCreatedEvent extends BaseEvent { 94 | // ... 95 | } 96 | 97 | class LoggingSubscriber { 98 | @On(BaseEvent) 99 | async all(event: BaseEvent): Promise { 100 | console.log(event); 101 | } 102 | } 103 | 104 | // ... 105 | 106 | dispatcher.dispatch(new UserCreatedEvent()); 107 | ``` 108 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['/tests/**/*.test.ts'], 5 | testPathIgnorePatterns: ['/node_modules/', '/dist/', '/lib/'], 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | globals: { 8 | 'ts-jest': { 9 | tsConfig: 'tsconfig.json' 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "type-events", 3 | "version": "1.0.0-beta.2", 4 | "description": "An event dispatcher", 5 | "keywords": [ 6 | "typescript", 7 | "ts", 8 | "javascript", 9 | "js", 10 | "event", 11 | "dispatcher", 12 | "ddd" 13 | ], 14 | "author": "Jordan Stout ", 15 | "license": "MIT", 16 | "files": [ 17 | "lib/**/*" 18 | ], 19 | "main": "lib/index.js", 20 | "typings": "lib/index.d.ts", 21 | "scripts": { 22 | "build": "tsc -b . --force", 23 | "clean": "rimraf lib", 24 | "watch": "tsc -w", 25 | "prepublishOnly": "yarn clean && yarn build", 26 | "release:next": "npm publish --access public --tag next", 27 | "release": "release-it", 28 | "test": "jest --runInBand --verbose --coverage", 29 | "test:ci": "jest --verbose --coverage --ci --forceExit --detectOpenHandles --runInBand", 30 | "pretty": "prettier '{src,tests}/**/*.ts' --write" 31 | }, 32 | "dependencies": {}, 33 | "devDependencies": { 34 | "@sinonjs/fake-timers": "^7.0.2", 35 | "@types/jest": "^24.0.18", 36 | "@types/node": "^12.12.17", 37 | "@types/sinonjs__fake-timers": "^6.0.2", 38 | "husky": "^3.0.5", 39 | "jest": "^24.9.0", 40 | "prettier": "^1.18.2", 41 | "pretty-quick": "^1.11.1", 42 | "reflect-metadata": "^0.1.13", 43 | "release-it": "^14.2.2", 44 | "ts-jest": "^24.0.2", 45 | "typescript": "^3.6.2" 46 | }, 47 | "peerDependencies": { 48 | "reflect-metadata": "^0.1.13" 49 | }, 50 | "prettier": { 51 | "singleQuote": true 52 | }, 53 | "husky": { 54 | "hooks": { 55 | "pre-commit": "pretty-quick --staged" 56 | } 57 | }, 58 | "release-it": { 59 | "commitMessage": "chore: release v${version}", 60 | "github": { 61 | "release": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/EventDispatcher.ts: -------------------------------------------------------------------------------- 1 | import { Newable, ContainerLike, Handler, Loggable } from './interfaces'; 2 | import { EventDispatcherMetadata, EventMetadata } from './metadata'; 3 | import { Container } from './utils/Container'; 4 | import { Logger } from './utils/Logger'; 5 | import { EventDispatcherError } from './utils/errors'; 6 | 7 | export interface EventDispatcherOptions { 8 | subscribers: Newable[]; 9 | container?: ContainerLike; 10 | logger?: Loggable; 11 | } 12 | 13 | export interface EventListener extends EventMetadata { 14 | handler: Handler; 15 | } 16 | 17 | interface EventListeners { 18 | normal: EventListener[]; 19 | background: EventListener[]; 20 | } 21 | 22 | export class EventDispatcher { 23 | /** 24 | * All of the events that were built on EventDispatcher instantiation 25 | */ 26 | protected readonly registered: Map = new Map(); 27 | 28 | /** 29 | * A map containing all the ordered event handlers. 30 | * 31 | * The array contains prioritized synchronous events in index 0 32 | * and background events in index 1. 33 | */ 34 | protected readonly listeners: Map = new Map(); 35 | 36 | /** 37 | * How event subscribers are created. 38 | */ 39 | protected readonly container: ContainerLike; 40 | 41 | /** 42 | * Custom logger for instances where a subscriber errors in the background. 43 | */ 44 | protected readonly logger: Loggable; 45 | 46 | constructor(options: EventDispatcherOptions) { 47 | this.container = options.container || new Container(); 48 | this.logger = options.logger || new Logger(); 49 | this.buildListeners(options.subscribers); 50 | } 51 | 52 | /** 53 | * Dispatches the event to all the subscribers including all subscribers 54 | * listening on it's parents as well. 55 | */ 56 | async dispatch(event: T): Promise { 57 | const { normal, background } = this.getListeners(event?.constructor); 58 | await this.handleListenersSerially(event, normal); 59 | await this.handleListenersConcurrently(event, background); 60 | 61 | return event; 62 | } 63 | 64 | /** 65 | * Processes non-background event listeners serially. 66 | */ 67 | protected async handleListenersSerially( 68 | event: T, 69 | listeners: EventListener[] 70 | ): Promise { 71 | for (const listener of listeners) { 72 | await listener.handler(event); 73 | } 74 | } 75 | 76 | /** 77 | * Processes background event listeners. 78 | */ 79 | protected async handleListenersConcurrently( 80 | event: T, 81 | listeners: EventListener[] 82 | ): Promise { 83 | if (!listeners.length) { 84 | return; 85 | } 86 | 87 | setImmediate(() => { 88 | Promise.all(listeners.map(listener => listener.handler(event))); 89 | }); 90 | } 91 | 92 | /** 93 | * Registers the event listeners for the subscribers with a 94 | * computed "handler" method. 95 | */ 96 | protected buildListeners(subscribers: Newable[]): void { 97 | subscribers.forEach(EventSubscriber => { 98 | if (!EventDispatcherMetadata.subscribers.has(EventSubscriber)) { 99 | throw new Error( 100 | `"${EventSubscriber.name}" is not a valid EventSubscriber` 101 | ); 102 | } 103 | 104 | EventDispatcherMetadata.subscribers 105 | .get(EventSubscriber) 106 | .forEach(eventMetadata => { 107 | const { Event } = eventMetadata; 108 | 109 | if (!this.registered.has(Event)) { 110 | this.registered.set(Event, []); 111 | } 112 | 113 | this.registered.get(Event).push({ 114 | ...eventMetadata, 115 | handler: this.createHandler(eventMetadata) 116 | }); 117 | }); 118 | }); 119 | } 120 | 121 | /** 122 | * Computes the event's "handler" method. 123 | * 124 | * It sort of optimizes the call by creating different versions of the 125 | * dispatcher based on it's metadata. 126 | */ 127 | protected createHandler(eventMetadata: EventMetadata): Handler { 128 | const { EventSubscriber, method } = eventMetadata; 129 | 130 | return async (event: any): Promise => { 131 | const subscriber = await Promise.resolve( 132 | this.container.get(EventSubscriber) 133 | ); 134 | if (!subscriber) { 135 | throw new EventDispatcherError( 136 | `${EventSubscriber.name} not found in container` 137 | ); 138 | } 139 | 140 | try { 141 | await Promise.resolve(subscriber[method](event)); 142 | } catch (err) { 143 | this.logger.error(err); 144 | } 145 | }; 146 | } 147 | 148 | /** 149 | * Generate the entire listener stack which includes it's 150 | * inherited event handlers. 151 | */ 152 | protected getListeners(Event: any): EventListeners { 153 | if (this.listeners.has(Event)) { 154 | return this.listeners.get(Event); 155 | } 156 | 157 | const listeners: EventListeners = { 158 | normal: [], 159 | background: [] 160 | }; 161 | 162 | // check event and it's parents for any other registered events 163 | let CurrentEvent = Event; 164 | while (CurrentEvent) { 165 | if (this.registered.has(CurrentEvent)) { 166 | this.registered.get(CurrentEvent).forEach(e => { 167 | listeners[e.background ? 'background' : 'normal'].push(e); 168 | }); 169 | } 170 | 171 | CurrentEvent = Object.getPrototypeOf(CurrentEvent); 172 | } 173 | 174 | const sort = a => a.sort((a, b) => b.priority - a.priority); 175 | sort(listeners.normal); 176 | sort(listeners.background); 177 | 178 | this.listeners.set(Event, listeners); 179 | 180 | return listeners; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcherMetadata } from '../metadata'; 2 | import { Newable } from '../interfaces'; 3 | 4 | interface OnOptions { 5 | priority?: number; 6 | background?: boolean; 7 | } 8 | 9 | export function On( 10 | Event: Newable, 11 | options?: OnOptions 12 | ): PropertyDecorator; 13 | export function On( 14 | Events: Newable[], 15 | options?: OnOptions 16 | ): PropertyDecorator; 17 | export function On( 18 | EventOrEvents: Newable | Newable[], 19 | options: OnOptions = {} 20 | ): PropertyDecorator { 21 | return (target: any, method: string) => { 22 | const Events = Array.isArray(EventOrEvents) 23 | ? EventOrEvents 24 | : [EventOrEvents]; 25 | 26 | Events.map(Event => { 27 | EventDispatcherMetadata.addEventMetadata({ 28 | Event, 29 | EventSubscriber: target.constructor, 30 | method, 31 | priority: options.priority || 0, 32 | background: options.background || false 33 | }); 34 | }); 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EventDispatcher'; 2 | export * from './decorators'; 3 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface Newable { 2 | new (...args: any[]): T; 3 | } 4 | 5 | export interface ContainerLike { 6 | get: (service: any) => any; 7 | } 8 | export type Handler = (...args: any[]) => Promise; 9 | 10 | export interface Loggable { 11 | log(...data: any[]); 12 | error(message?: any, ...optionalParams: any[]): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/metadata/index.ts: -------------------------------------------------------------------------------- 1 | import { Newable } from '../interfaces'; 2 | 3 | export interface EventMetadata { 4 | Event: T; 5 | EventSubscriber: Newable; 6 | method: string; 7 | priority: number; 8 | background: boolean; 9 | } 10 | 11 | export class EventDispatcherMetadata { 12 | static events: Map = new Map(); 13 | static subscribers: Map = new Map(); 14 | 15 | static addEventMetadata(meta: EventMetadata): void { 16 | // register event and it's subscriber 17 | if (!this.events.has(meta.Event)) { 18 | this.events.set(meta.Event, []); 19 | } 20 | 21 | // ignore duplicate event methods on subscribers 22 | if ( 23 | !!this.events 24 | .get(meta.Event) 25 | .find( 26 | m => 27 | m.EventSubscriber === meta.EventSubscriber && 28 | m.method === meta.method 29 | ) 30 | ) { 31 | return; 32 | } 33 | 34 | this.events.get(meta.Event).push(meta); 35 | 36 | // register subscriber and events it's subscribed to 37 | if (!this.subscribers.has(meta.EventSubscriber)) { 38 | this.subscribers.set(meta.EventSubscriber, []); 39 | } 40 | this.subscribers.get(meta.EventSubscriber).push(meta); 41 | } 42 | 43 | static assertEventExists(Event: Newable): void { 44 | if (!this.events.has(Event)) { 45 | throw new Error(`"${Event.name}" is not a valid decorated "@Event()"`); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/Container.ts: -------------------------------------------------------------------------------- 1 | import { ContainerLike, Newable } from '../interfaces'; 2 | 3 | /** 4 | * The default container for creating event subscribers. 5 | * 6 | * Subscribers, by default, are cached after initialization. 7 | */ 8 | export class Container implements ContainerLike { 9 | public readonly subscribers: Map = new Map(); 10 | 11 | get(EventSubscriber: Newable): T { 12 | if (!this.subscribers.has(EventSubscriber)) { 13 | this.subscribers.set(EventSubscriber, new EventSubscriber()); 14 | } 15 | 16 | return this.subscribers.get(EventSubscriber); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/Logger.ts: -------------------------------------------------------------------------------- 1 | import { Loggable } from '../interfaces'; 2 | 3 | /** 4 | * The default logger when one isn't configured. 5 | */ 6 | export class Logger implements Loggable { 7 | log(...data: any[]) { 8 | console.log(...data); 9 | } 10 | 11 | error(message?: any, ...optionalParams: any[]): void { 12 | console.error(message, ...optionalParams); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export class EventDispatcherError extends Error { 2 | public name: string = 'EventDispatcherError'; 3 | 4 | constructor(message: string) { 5 | super(message); 6 | Error.captureStackTrace(this, this.constructor); 7 | this.name = this.constructor.name; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/isPromise.ts: -------------------------------------------------------------------------------- 1 | const isObject = (value: any) => 2 | value !== null && (typeof value === 'object' || typeof value === 'function'); 3 | 4 | export function isPromise(value: any) { 5 | return ( 6 | value instanceof Promise || 7 | (isObject(value) && 8 | typeof value.then === 'function' && 9 | typeof value.catch === 'function') 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /tests/functional/EventDispatcher.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import FakeTimers from '@sinonjs/fake-timers'; 3 | import { EventDispatcher, On } from '../../src'; 4 | 5 | class BaseEvent { 6 | order: string[] = []; 7 | } 8 | 9 | class ImpressionOrConversionEvent extends BaseEvent {} 10 | 11 | class ImpressionEvent extends ImpressionOrConversionEvent {} 12 | 13 | class ConversionEvent extends ImpressionOrConversionEvent {} 14 | 15 | describe('DocumentManager', () => { 16 | let dispatcher: EventDispatcher; 17 | let clock: any; 18 | 19 | let spies = { 20 | onConversionOrImpression: jest.fn(), 21 | allEventLogger: jest.fn(), 22 | onConversion: jest.fn(), 23 | afterConversion: jest.fn(), 24 | beforeConversion: jest.fn(), 25 | onImpression: jest.fn(), 26 | onImpressionAsync: jest.fn() 27 | }; 28 | 29 | beforeAll(async () => { 30 | clock = FakeTimers.install(); 31 | 32 | class SlackSubscriber { 33 | @On(ImpressionOrConversionEvent, { priority: -99 }) 34 | async onConversionOrImpression(event: ConversionEvent | ImpressionEvent) { 35 | event.order.push('onConversionOrImpression'); 36 | spies.onConversionOrImpression(event); 37 | } 38 | 39 | @On(BaseEvent, { background: true }) 40 | async allEventLogger(event: BaseEvent) { 41 | event.order.push('allEventLogger'); 42 | spies.allEventLogger(event); 43 | } 44 | } 45 | 46 | class ConversionSubscriber { 47 | @On(ConversionEvent, { priority: 2 }) 48 | onConversion(event: ConversionEvent) { 49 | event.order.push('onConversion'); 50 | spies.onConversion(event); 51 | } 52 | 53 | @On(ConversionEvent, { priority: 1 }) 54 | async afterConversion(event: ConversionEvent) { 55 | return new Promise(resolve => { 56 | event.order.push('afterConversion'); 57 | spies.afterConversion(event); 58 | resolve(); 59 | }); 60 | } 61 | 62 | @On(ConversionEvent, { priority: 3 }) 63 | async beforeConversion(event: ConversionEvent) { 64 | return new Promise(resolve => { 65 | event.order.push('beforeConversion'); 66 | spies.beforeConversion(event); 67 | resolve(); 68 | }); 69 | } 70 | } 71 | 72 | class ImpressionSubscriber { 73 | @On(ImpressionEvent) 74 | async onImpression(event: ImpressionEvent) { 75 | return new Promise(resolve => { 76 | event.order.push('onImpression'); 77 | spies.onImpression(event); 78 | resolve(); 79 | }); 80 | } 81 | 82 | // give this event a super high priority to prove it's being executed last even though 83 | // the promise starts first 84 | @On(ImpressionEvent, { background: true, priority: 255 }) 85 | async onImpressionAsync(event: ImpressionEvent) { 86 | event.order.push('onImpressionAsync'); 87 | spies.onImpressionAsync(event); 88 | return new Promise(resolve => { 89 | setTimeout(() => { 90 | event.order.push('onImpressionAsync:DONE'); 91 | resolve(); 92 | }, 100); 93 | }); 94 | } 95 | } 96 | 97 | dispatcher = new EventDispatcher({ 98 | subscribers: [SlackSubscriber, ConversionSubscriber, ImpressionSubscriber] 99 | }); 100 | }); 101 | 102 | afterEach(() => { 103 | clock.reset(); 104 | Object.values(spies).forEach(spy => spy.mockClear()); 105 | }); 106 | 107 | afterAll(() => clock.uninstall()); 108 | 109 | test('emits conversion events', async () => { 110 | const event = new ConversionEvent(); 111 | await dispatcher.dispatch(event); 112 | 113 | expect(spies.onConversion).toBeCalledTimes(1); 114 | expect(spies.onConversion).toBeCalledWith(event); 115 | expect(spies.afterConversion).toBeCalledTimes(1); 116 | expect(spies.afterConversion).toBeCalledWith(event); 117 | expect(spies.onConversionOrImpression).toBeCalledTimes(1); 118 | expect(spies.onConversionOrImpression).toBeCalledWith(event); 119 | expect(spies.allEventLogger).toBeCalledTimes(0); 120 | expect(spies.onImpression).toBeCalledTimes(0); 121 | expect(event.order).toEqual([ 122 | 'beforeConversion', 123 | 'onConversion', 124 | 'afterConversion', 125 | 'onConversionOrImpression' 126 | ]); 127 | 128 | // verify background tasks complete in another event loop 129 | await clock.runAllAsync(); 130 | expect(spies.allEventLogger).toBeCalledTimes(1); 131 | expect(spies.allEventLogger).toBeCalledWith(event); 132 | expect(event.order).toEqual([ 133 | 'beforeConversion', 134 | 'onConversion', 135 | 'afterConversion', 136 | 'onConversionOrImpression', 137 | 'allEventLogger' 138 | ]); 139 | }); 140 | 141 | test('emits impression events', async () => { 142 | const event = new ImpressionEvent(); 143 | await dispatcher.dispatch(event); 144 | 145 | expect(spies.onImpression).toBeCalledTimes(1); 146 | expect(spies.onImpression).toBeCalledWith(event); 147 | expect(spies.onConversionOrImpression).toBeCalledTimes(1); 148 | expect(spies.onConversionOrImpression).toBeCalledWith(event); 149 | expect(spies.allEventLogger).toBeCalledTimes(0); 150 | expect(spies.onConversion).toBeCalledTimes(0); 151 | expect(event.order).toEqual(['onImpression', 'onConversionOrImpression']); 152 | 153 | // verify background tasks complete in another event loop 154 | await clock.runAllAsync(); 155 | expect(spies.onImpressionAsync).toBeCalledTimes(1); 156 | expect(spies.allEventLogger).toBeCalledTimes(1); 157 | expect(spies.allEventLogger).toBeCalledWith(event); 158 | expect(event.order).toEqual([ 159 | 'onImpression', 160 | 'onConversionOrImpression', 161 | 'onImpressionAsync', 162 | 'allEventLogger', 163 | 'onImpressionAsync:DONE' 164 | ]); 165 | }); 166 | 167 | test('with custom container', async () => { 168 | class CustomContainerSubscriber { 169 | @On(ConversionEvent) 170 | async onConversion(event: ConversionEvent) { 171 | spies.onConversion(event); 172 | } 173 | } 174 | 175 | const getFn = jest.fn(); 176 | 177 | const dispatcherWithContainer = new EventDispatcher({ 178 | subscribers: [CustomContainerSubscriber], 179 | container: { 180 | get(Service) { 181 | getFn(); 182 | 183 | return new Service(); 184 | } 185 | } 186 | }); 187 | 188 | const event = new ConversionEvent(); 189 | await dispatcherWithContainer.dispatch(event); 190 | 191 | expect(getFn).toBeCalledTimes(1); 192 | }); 193 | 194 | test('with custom async container', async () => { 195 | class CustomContainerSubscriber { 196 | @On(ConversionEvent) 197 | async onConversion(event: ConversionEvent) { 198 | spies.onConversion(event); 199 | } 200 | } 201 | 202 | const getFn = jest.fn(); 203 | 204 | const dispatcherWithContainer = new EventDispatcher({ 205 | subscribers: [CustomContainerSubscriber], 206 | container: { 207 | async get(Service) { 208 | return new Promise(resolve => { 209 | getFn(); 210 | 211 | resolve(new Service()); 212 | }); 213 | } 214 | } 215 | }); 216 | 217 | const event = new ConversionEvent(); 218 | await dispatcherWithContainer.dispatch(event); 219 | 220 | expect(getFn).toBeCalledTimes(1); 221 | }); 222 | 223 | test('ignores events that do not have any subscribers', async () => { 224 | class Foo {} 225 | await dispatcher.dispatch(new Foo()); 226 | Object.values(spies).forEach(spy => expect(spy).toBeCalledTimes(0)); 227 | }); 228 | 229 | test('throws errors on invalid subscriber', async () => { 230 | class InvalidSubscriber {} 231 | 232 | let e: Error; 233 | 234 | try { 235 | new EventDispatcher({ subscribers: [InvalidSubscriber] }); 236 | } catch (err) { 237 | e = err; 238 | } 239 | 240 | expect(e).toBeInstanceOf(Error); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "sourceMap": true, 6 | "moduleResolution": "node", 7 | "target": "es2016", 8 | "module": "commonjs", 9 | "lib": ["es6", "esnext"], 10 | "skipLibCheck": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "esModuleInterop": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "resolveJsonModule": true, 17 | "baseUrl": ".", 18 | "outDir": "./lib", 19 | "rootDir": "./src" 20 | }, 21 | "include": ["src/**/*"] 22 | } 23 | --------------------------------------------------------------------------------