Skip to content

Commit

Permalink
Merge pull request #544 from thiagomini/feature/request-scoped-event-…
Browse files Browse the repository at this point in the history
…emitters

Feature/request scoped event emitters
  • Loading branch information
kamilmysliwiec committed Jul 12, 2022
2 parents 291aabc + c382818 commit c209f33
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 12 deletions.
3 changes: 3 additions & 0 deletions lib/constants.ts
@@ -1 +1,4 @@
import { REQUEST } from '@nestjs/core';

export const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA';
export const EVENT_PAYLOAD = REQUEST;
113 changes: 107 additions & 6 deletions lib/event-subscribers.loader.ts
Expand Up @@ -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() {
Expand All @@ -31,39 +45,126 @@ 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,
),
);
});
}

private subscribeToEventIfListener(
instance: Record<string, any>,
methodKey: string,
isRequestScoped: boolean,
moduleRef: Module,
) {
const eventListenerMetadata = this.metadataAccessor.getEventHandlerMetadata(
instance[methodKey],
);
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<string, any>;
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);
}
}
1 change: 1 addition & 0 deletions lib/index.ts
@@ -1,3 +1,4 @@
export { EventEmitter2 } from 'eventemitter2';
export * from './decorators';
export * from './event-emitter.module';
export { EVENT_PAYLOAD } from './constants';
41 changes: 37 additions & 4 deletions tests/e2e/module-e2e.spec.ts
Expand Up @@ -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', () => {
Expand All @@ -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();
});

Expand All @@ -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();
});
Expand Down
2 changes: 2 additions & 0 deletions tests/src/app.module.ts
Expand Up @@ -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';

Expand All @@ -18,6 +19,7 @@ import { TestProvider } from './test-provider';
EventsProviderPrependConsumer,
EventsProducer,
TestProvider,
EventsProviderRequestScopedConsumer,
],
})
export class AppModule {}
12 changes: 12 additions & 0 deletions 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';
24 changes: 24 additions & 0 deletions 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() {}
}
9 changes: 8 additions & 1 deletion 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);
}
}
25 changes: 25 additions & 0 deletions 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<string, any>;
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;
}
}
}
2 changes: 1 addition & 1 deletion tsconfig.json
Expand Up @@ -9,7 +9,7 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": false,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./lib",
"skipLibCheck": true
Expand Down

0 comments on commit c209f33

Please sign in to comment.