diff --git a/lib/constants.ts b/lib/constants.ts index 4e79fbf6..b3ee2c99 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1 +1,4 @@ +import { REQUEST } from '@nestjs/core'; + export const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; +export const EVENT_PAYLOAD = REQUEST; diff --git a/lib/event-subscribers.loader.ts b/lib/event-subscribers.loader.ts index 0fe9c70c..c40101fe 100644 --- a/lib/event-subscribers.loader.ts +++ b/lib/event-subscribers.loader.ts @@ -3,20 +3,34 @@ import { OnApplicationBootstrap, OnApplicationShutdown, } from '@nestjs/common'; -import { DiscoveryService, MetadataScanner } from '@nestjs/core'; -import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; +import { + ContextIdFactory, + DiscoveryService, + MetadataScanner, + ModuleRef, +} from '@nestjs/core'; +import { Injector } from '@nestjs/core/injector/injector'; +import { + ContextId, + InstanceWrapper, +} from '@nestjs/core/injector/instance-wrapper'; +import { Module } from '@nestjs/core/injector/module'; import { EventEmitter2 } from 'eventemitter2'; import { EventsMetadataAccessor } from './events-metadata.accessor'; +import { OnEventOptions } from './interfaces'; @Injectable() export class EventSubscribersLoader implements OnApplicationBootstrap, OnApplicationShutdown { + private readonly injector = new Injector(); + constructor( private readonly discoveryService: DiscoveryService, private readonly eventEmitter: EventEmitter2, private readonly metadataAccessor: EventsMetadataAccessor, private readonly metadataScanner: MetadataScanner, + private readonly moduleRef: ModuleRef, ) {} onApplicationBootstrap() { @@ -31,16 +45,21 @@ export class EventSubscribersLoader const providers = this.discoveryService.getProviders(); const controllers = this.discoveryService.getControllers(); [...providers, ...controllers] - .filter(wrapper => wrapper.isDependencyTreeStatic()) .filter(wrapper => wrapper.instance) .forEach((wrapper: InstanceWrapper) => { const { instance } = wrapper; const prototype = Object.getPrototypeOf(instance) || {}; + const isRequestScoped = !wrapper.isDependencyTreeStatic(); this.metadataScanner.scanFromPrototype( instance, prototype, (methodKey: string) => - this.subscribeToEventIfListener(instance, methodKey), + this.subscribeToEventIfListener( + instance, + methodKey, + isRequestScoped, + wrapper.host as Module, + ), ); }); } @@ -48,6 +67,8 @@ export class EventSubscribersLoader private subscribeToEventIfListener( instance: Record, methodKey: string, + isRequestScoped: boolean, + moduleRef: Module, ) { const eventListenerMetadata = this.metadataAccessor.getEventHandlerMetadata( instance[methodKey], @@ -55,15 +76,95 @@ export class EventSubscribersLoader if (!eventListenerMetadata) { return; } + const { event, options } = eventListenerMetadata; - const listenerMethod = !!options?.prependListener + const listenerMethod = this.getRegisterListenerMethodBasedOn(options); + + if (isRequestScoped) { + this.registerRequestScopedListener({ + event, + eventListenerInstance: instance, + listenerMethod, + listenerMethodKey: methodKey, + moduleRef, + options, + }); + } else { + listenerMethod( + event, + (...args: unknown[]) => instance[methodKey].call(instance, ...args), + options, + ); + } + } + + private getRegisterListenerMethodBasedOn(options?: OnEventOptions) { + return Boolean(options?.prependListener) ? this.eventEmitter.prependListener.bind(this.eventEmitter) : this.eventEmitter.on.bind(this.eventEmitter); + } + + private registerRequestScopedListener(eventListenerContext: { + listenerMethod: EventEmitter2['on']; + event: string | symbol | (string | symbol)[]; + eventListenerInstance: Record; + moduleRef: Module; + listenerMethodKey: string; + options?: OnEventOptions; + }) { + const { + listenerMethod, + event, + eventListenerInstance, + moduleRef, + listenerMethodKey, + options, + } = eventListenerContext; listenerMethod( event, - (...args: unknown[]) => instance[methodKey].call(instance, ...args), + async (...args: unknown[]) => { + const contextId = ContextIdFactory.create(); + + this.registerEventPayloadByContextId(args, contextId); + + const contextInstance = await this.injector.loadPerContext( + eventListenerInstance, + moduleRef, + moduleRef.providers, + contextId, + ); + return contextInstance[listenerMethodKey].call( + contextInstance, + ...args, + ); + }, options, ); } + + private registerEventPayloadByContextId( + eventPayload: unknown[], + contextId: ContextId, + ) { + /* + **Required explanation for the ternary below** + + We need the conditional below because an event can be emitted with a variable amount of arguments. + For instance, we can do `this.eventEmitter.emit('event', 'payload1', 'payload2', ..., 'payloadN');` + + All payload arguments are internally stored as an array. So, imagine we emitted an event as follows: + + `this.eventEmitter.emit('event', 'payload'); + + if we registered the original `eventPayload`, when we try to inject it in a listener, it'll be retrieved as [`payload`]. + However, whoever is using this library would certainly expect the event payload to be a single string 'payload', not an array, + since this is what we emitted above. + */ + + const payloadObjectOrArray = + eventPayload.length > 1 ? eventPayload : eventPayload[0]; + + this.moduleRef.registerRequestByContextId(payloadObjectOrArray, contextId); + } } diff --git a/lib/index.ts b/lib/index.ts index aad6a6d8..d46ed19f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ export { EventEmitter2 } from 'eventemitter2'; export * from './decorators'; export * from './event-emitter.module'; +export { EVENT_PAYLOAD } from './constants'; diff --git a/tests/e2e/module-e2e.spec.ts b/tests/e2e/module-e2e.spec.ts index 7b6fc696..26b08c2e 100644 --- a/tests/e2e/module-e2e.spec.ts +++ b/tests/e2e/module-e2e.spec.ts @@ -2,9 +2,15 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { EventEmitter2 } from 'eventemitter2'; import { AppModule } from '../src/app.module'; +import { + TEST_EVENT_MULTIPLE_PAYLOAD, + TEST_EVENT_PAYLOAD, + TEST_EVENT_STRING_PAYLOAD, +} from '../src/constants'; import { EventsControllerConsumer } from '../src/events-controller.consumer'; import { EventsProviderPrependConsumer } from '../src/events-provider-prepend.consumer'; import { EventsProviderConsumer } from '../src/events-provider.consumer'; +import { EventsProviderRequestScopedConsumer } from '../src/events-provider.request-scoped.consumer'; import { TEST_PROVIDER_TOKEN } from '../src/test-provider'; describe('EventEmitterModule - e2e', () => { @@ -22,22 +28,25 @@ describe('EventEmitterModule - e2e', () => { const eventsConsumerRef = app.get(EventsProviderConsumer); await app.init(); - expect(eventsConsumerRef.eventPayload).toEqual({ test: 'event' }); + expect(eventsConsumerRef.eventPayload).toEqual(TEST_EVENT_PAYLOAD); }); it(`should emit a "test-event" event to controllers`, async () => { const eventsConsumerRef = app.get(EventsControllerConsumer); await app.init(); - expect(eventsConsumerRef.eventPayload).toEqual({ test: 'event' }); + expect(eventsConsumerRef.eventPayload).toEqual(TEST_EVENT_PAYLOAD); }); it('should be able to specify a consumer be prepended via OnEvent decorator options', async () => { const eventsConsumerRef = app.get(EventsProviderPrependConsumer); - const prependListenerSpy = jest.spyOn(app.get(EventEmitter2), 'prependListener'); + const prependListenerSpy = jest.spyOn( + app.get(EventEmitter2), + 'prependListener', + ); await app.init(); - expect(eventsConsumerRef.eventPayload).toEqual({ test: 'event' }); + expect(eventsConsumerRef.eventPayload).toEqual(TEST_EVENT_PAYLOAD); expect(prependListenerSpy).toHaveBeenCalled(); }); @@ -58,6 +67,30 @@ describe('EventEmitterModule - e2e', () => { await expect(app.init()).resolves.not.toThrow(); }); + it('should be able to emit a request-scoped event with a single payload', async () => { + await app.init(); + + expect( + EventsProviderRequestScopedConsumer.injectedEventPayload.objectValue, + ).toEqual(TEST_EVENT_PAYLOAD); + }); + + it('should be able to emit a request-scoped event with a string payload', async () => { + await app.init(); + + expect( + EventsProviderRequestScopedConsumer.injectedEventPayload.stringValue, + ).toEqual(TEST_EVENT_STRING_PAYLOAD); + }); + + it('should be able to emit a request-scoped event with multiple payloads', async () => { + await app.init(); + + expect( + EventsProviderRequestScopedConsumer.injectedEventPayload.arrayValue, + ).toEqual(TEST_EVENT_MULTIPLE_PAYLOAD); + }); + afterEach(async () => { await app.close(); }); diff --git a/tests/src/app.module.ts b/tests/src/app.module.ts index 95665615..50b5e41f 100644 --- a/tests/src/app.module.ts +++ b/tests/src/app.module.ts @@ -3,6 +3,7 @@ import { EventEmitterModule } from '../../lib'; import { EventsControllerConsumer } from './events-controller.consumer'; import { EventsProviderPrependConsumer } from './events-provider-prepend.consumer'; import { EventsProviderConsumer } from './events-provider.consumer'; +import { EventsProviderRequestScopedConsumer } from './events-provider.request-scoped.consumer'; import { EventsProducer } from './events.producer'; import { TestProvider } from './test-provider'; @@ -18,6 +19,7 @@ import { TestProvider } from './test-provider'; EventsProviderPrependConsumer, EventsProducer, TestProvider, + EventsProviderRequestScopedConsumer, ], }) export class AppModule {} diff --git a/tests/src/constants.ts b/tests/src/constants.ts new file mode 100644 index 00000000..00d95ced --- /dev/null +++ b/tests/src/constants.ts @@ -0,0 +1,12 @@ +export const TEST_EVENT_PAYLOAD = { + test: 'event', +}; + +export const TEST_EVENT_MULTIPLE_PAYLOAD = [ + TEST_EVENT_PAYLOAD, + { + test2: 'event2', + }, +]; + +export const TEST_EVENT_STRING_PAYLOAD = 'some-string'; diff --git a/tests/src/events-provider.request-scoped.consumer.ts b/tests/src/events-provider.request-scoped.consumer.ts new file mode 100644 index 00000000..cf5233e9 --- /dev/null +++ b/tests/src/events-provider.request-scoped.consumer.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '../../lib'; +import { EVENT_PAYLOAD } from '../../lib'; +import { RequestScopedEventPayload } from './request-scoped-event-payload'; + +@Injectable() +export class EventsProviderRequestScopedConsumer { + constructor(@Inject(EVENT_PAYLOAD) public eventRef: any) { + EventsProviderRequestScopedConsumer.injectedEventPayload.setPayload( + this.eventRef, + ); + } + + public static injectedEventPayload = new RequestScopedEventPayload(); + + @OnEvent('test.*') + onTestEvent() {} + + @OnEvent('multiple.*') + onMultiplePayloadEvent() {} + + @OnEvent('string.*') + onStringPayloadEvent() {} +} diff --git a/tests/src/events.producer.ts b/tests/src/events.producer.ts index e08f423a..98423118 100644 --- a/tests/src/events.producer.ts +++ b/tests/src/events.producer.ts @@ -1,11 +1,18 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { EventEmitter2 } from 'eventemitter2'; +import { + TEST_EVENT_MULTIPLE_PAYLOAD, + TEST_EVENT_PAYLOAD, + TEST_EVENT_STRING_PAYLOAD, +} from './constants'; @Injectable() export class EventsProducer implements OnApplicationBootstrap { constructor(private readonly eventEmitter: EventEmitter2) {} onApplicationBootstrap() { - this.eventEmitter.emit('test.event', { test: 'event' }); + this.eventEmitter.emit('test.event', TEST_EVENT_PAYLOAD); + this.eventEmitter.emit('multiple.event', TEST_EVENT_MULTIPLE_PAYLOAD); + this.eventEmitter.emit('string.event', TEST_EVENT_STRING_PAYLOAD); } } diff --git a/tests/src/request-scoped-event-payload.ts b/tests/src/request-scoped-event-payload.ts new file mode 100644 index 00000000..ef0fad72 --- /dev/null +++ b/tests/src/request-scoped-event-payload.ts @@ -0,0 +1,25 @@ +/** + * Class used to test injected payloads on the RequestScoped listener. + * Each value stored in the instance represents a different type of payload. + */ +export class RequestScopedEventPayload { + public objectValue: Record; + public arrayValue: any[]; + public stringValue: string; + + constructor() { + this.objectValue = {}; + this.arrayValue = []; + this.stringValue = ''; + } + + public setPayload(value: any) { + if (Array.isArray(value)) { + this.arrayValue = value; + } else if (typeof value === 'string') { + this.stringValue = value; + } else { + this.objectValue = value; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 9209f889..b4f3de18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "esModuleInterop": true, "experimentalDecorators": true, "target": "es6", - "sourceMap": false, + "sourceMap": true, "outDir": "./dist", "rootDir": "./lib", "skipLibCheck": true