diff --git a/example/events.ts b/example/events.ts new file mode 100644 index 00000000..b44ceaa9 --- /dev/null +++ b/example/events.ts @@ -0,0 +1,53 @@ +import { + Controller, + DanetApplication, + EventEmitter, + EventEmitterModule, + Module, + OnEvent, + Post, +} from '../mod.ts'; + +type User = {}; + +class UserListeners { + @OnEvent('new-user') + notifyUser(user: User) { + console.log('new user created', user); + } + + @OnEvent('new-user') + async sendWelcomeEmail(user: User) { + console.log('send email', user); + } +} + +@Controller('user') +class UserController { + constructor( + private eventEmitter: EventEmitter, + ) {} + + @Post() + create() { + const user: User = {}; + this.eventEmitter.emit('new-user', user); + return JSON.stringify(user); + } +} + +@Module({ + imports: [EventEmitterModule], + controllers: [UserController], + injectables: [UserListeners], +}) +class AppModule {} + +const app = new DanetApplication(); +await app.init(AppModule); + +let port = Number(Deno.env.get('PORT')); +if (isNaN(port)) { + port = 3000; +} +app.listen(port); diff --git a/spec/events.test.ts b/spec/events.test.ts new file mode 100644 index 00000000..f3e7377f --- /dev/null +++ b/spec/events.test.ts @@ -0,0 +1,166 @@ +import { + Controller, + DanetApplication, + EventEmitter, + EventEmitterModule, + Get, + Module, + OnEvent, +} from '../mod.ts'; +import { + assertEquals, + assertSpyCall, + assertThrows, + spy, +} from '../src/deps_test.ts'; + +Deno.test('EventEmitter Service', async (t) => { + await t.step('subscribe multiple listeners for the same topic', async () => { + const emitter = new EventEmitter(); + const fn1 = spy(() => {}); + const fn2 = spy(() => {}); + + emitter.subscribe('test', fn1); + emitter.subscribe('test', fn2); + + emitter.emit('test', 'something'); + + assertSpyCall(fn1, 0, { + args: ['something'], + returned: undefined, + }); + + assertSpyCall(fn2, 0, { + args: ['something'], + returned: undefined, + }); + + emitter.unsubscribe(); + }); + + await t.step('subscribe listeners for the multiple topics', async () => { + const emitter = new EventEmitter(); + const fn1 = spy(() => {}); + const fn2 = spy(() => {}); + + emitter.subscribe('test', fn1); + emitter.subscribe('test-2', fn2); + + emitter.emit('test', 'something'); + + assertSpyCall(fn1, 0, { + args: ['something'], + returned: undefined, + }); + + assertEquals(fn2.calls.length, 0); + + emitter.emit('test-2', 'something'); + + assertSpyCall(fn2, 0, { + args: ['something'], + returned: undefined, + }); + + assertEquals(fn1.calls.length, 1); + + emitter.unsubscribe(); + }); + + await t.step('throw error if emit an event with no listener', async () => { + const emitter = new EventEmitter(); + const fn1 = spy(() => {}); + + assertThrows(() => emitter.emit('test', 'something')); + + emitter.subscribe('test', fn1); + + assertEquals(fn1.calls.length, 0); + + emitter.emit('test', 'something'); + + assertSpyCall(fn1, 0, { + args: ['something'], + returned: undefined, + }); + + emitter.unsubscribe(); + }); + + await t.step('throw error if emit to a unsubscribed topic', async () => { + const emitter = new EventEmitter(); + const fn1 = spy(() => {}); + + assertEquals(fn1.calls.length, 0); + + emitter.subscribe('test', fn1); + emitter.emit('test', 'something'); + + assertSpyCall(fn1, 0, { + args: ['something'], + returned: undefined, + }); + + emitter.unsubscribe('test'); + + assertThrows(() => emitter.emit('test', 'something')); + assertEquals(fn1.calls.length, 1); + + emitter.unsubscribe(); + }); +}); + +Deno.test('EventEmitter Module', async (t) => { + const callback = spy((_payload: any) => {}); + const payload = { name: 'test' }; + + class TestListener { + @OnEvent('trigger') + getSomething(payload: any) { + callback(payload); + } + } + + @Controller('trigger') + class TestController { + constructor(private emitter: EventEmitter) {} + + @Get() + getSomething() { + this.emitter.emit('trigger', payload); + return 'OK'; + } + } + + @Module({ + imports: [EventEmitterModule], + controllers: [TestController], + injectables: [TestListener], + }) + class TestModule {} + + const application = new DanetApplication(); + await application.init(TestModule); + const listenerInfo = await application.listen(0); + + await t.step('validate if api call trigger event', async () => { + assertEquals(callback.calls.length, 0); + + let res = await fetch(`http://localhost:${listenerInfo.port}/trigger`); + + assertEquals(res.status, 200); + assertEquals(await res.text(), 'OK'); + assertEquals(callback.calls.length, 1); + assertSpyCall(callback, 0, { + args: [payload], + }); + + res = await fetch(`http://localhost:${listenerInfo.port}/trigger`); + + assertEquals(res.status, 200); + assertEquals(await res.text(), 'OK'); + assertEquals(callback.calls.length, 2); + }); + + await application.close(); +}); diff --git a/spec/method-param-decorator.test.ts b/spec/method-param-decorator.test.ts index 86a1e68e..b0063f72 100644 --- a/spec/method-param-decorator.test.ts +++ b/spec/method-param-decorator.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from '../src/deps_test.ts'; import { DanetApplication } from '../src/app.ts'; import { Session } from '../src/mod.ts'; -import type { MiddlewareHandler} from '../src/deps.ts'; +import type { MiddlewareHandler } from '../src/deps.ts'; import { Module } from '../src/module/decorator.ts'; import { Controller, Get, Post } from '../src/router/controller/decorator.ts'; import { @@ -15,9 +15,9 @@ import { Injectable } from '../src/injector/injectable/decorator.ts'; import { AuthGuard } from '../src/guard/interface.ts'; import { HttpContext } from '../src/router/router.ts'; import { + CookieStore, sessionMiddleware, - CookieStore -} from 'https://deno.land/x/hono_sessions/mod.ts' +} from 'https://deno.land/x/hono_sessions/mod.ts'; @Injectable() class AddThingToSession implements AuthGuard { @@ -372,7 +372,7 @@ Deno.test('@Param decorator', async () => { Deno.test('@Session decorator without params', async () => { const app = new DanetApplication(); - const store = new CookieStore() + const store = new CookieStore(); app.use( sessionMiddleware({ store, @@ -383,7 +383,7 @@ Deno.test('@Session decorator without params', async () => { path: '/', // Required for this library to work properly httpOnly: true, // Recommended to avoid XSS attacks }, - }) as unknown as MiddlewareHandler + }) as unknown as MiddlewareHandler, ); await app.init(MyModule); const listenEvent = await app.listen(0); @@ -401,7 +401,7 @@ Deno.test('@Session decorator without params', async () => { Deno.test('@Session decorator with param', async () => { const app = new DanetApplication(); - const store = new CookieStore() + const store = new CookieStore(); app.use( sessionMiddleware({ store, @@ -412,7 +412,7 @@ Deno.test('@Session decorator with param', async () => { path: '/', // Required for this library to work properly httpOnly: true, // Recommended to avoid XSS attacks }, - }) as unknown as MiddlewareHandler + }) as unknown as MiddlewareHandler, ); await app.init(MyModule); const listenEvent = await app.listen(0); diff --git a/src/deps_test.ts b/src/deps_test.ts index eae9212d..4a04180a 100644 --- a/src/deps_test.ts +++ b/src/deps_test.ts @@ -1,9 +1,15 @@ +export { + assertSpyCall, + assertSpyCalls, + spy, +} from 'https://deno.land/std@0.135.0/testing/mock.ts'; export { assertEquals, assertInstanceOf, assertNotEquals, assertObjectMatch, assertRejects, + assertThrows, } from 'https://deno.land/std@0.135.0/testing/asserts.ts'; export * as path from 'https://deno.land/std@0.135.0/path/mod.ts'; export { diff --git a/src/events/constants.ts b/src/events/constants.ts new file mode 100644 index 00000000..ad0afb9e --- /dev/null +++ b/src/events/constants.ts @@ -0,0 +1 @@ +export const eventListenerMetadataKey = 'event-listener'; diff --git a/src/events/decorator.ts b/src/events/decorator.ts new file mode 100644 index 00000000..3cd9f9ea --- /dev/null +++ b/src/events/decorator.ts @@ -0,0 +1,13 @@ +import { MetadataHelper } from '../metadata/mod.ts'; +import { eventListenerMetadataKey } from './constants.ts'; + +export const OnEvent = (channel: string): MethodDecorator => { + return (_target, _propertyKey, descriptor) => { + MetadataHelper.setMetadata( + eventListenerMetadataKey, + { channel }, + descriptor.value, + ); + return descriptor; + }; +}; diff --git a/src/events/events.ts b/src/events/events.ts new file mode 100644 index 00000000..5a5965ae --- /dev/null +++ b/src/events/events.ts @@ -0,0 +1,68 @@ +import { Logger } from '../mod.ts'; + +// deno-lint-ignore no-explicit-any +type Listener
= (payload: P) => void;
+
+export class EventEmitter {
+ private logger: Logger = new Logger('EventEmitter');
+ private listenersRegistered: Map (channelName: string, payload: P) {
+ const channels = Array.from(this.listenersRegistered.keys());
+ if (!channels.includes(channelName)) {
+ throw new Error(`No listener for '${channelName}' channel`);
+ }
+
+ const event = new CustomEvent(channelName, { detail: payload });
+ this.eventTarget.dispatchEvent(event);
+
+ this.logger.log(
+ `event send to '${channelName}' channel`,
+ );
+ }
+
+ subscribe (channelName: string, listener: Listener ) {
+ const eventListener = (ev: Event) => {
+ const { detail: payload } = ev as CustomEvent;
+ return listener(payload);
+ };
+ this.eventTarget.addEventListener(channelName, eventListener);
+
+ const listeners = this.listenersRegistered.get(channelName) ?? [];
+ this.listenersRegistered.set(channelName, [...listeners, eventListener]);
+
+ this.logger.log(
+ `event listener subscribed to '${channelName}' channel`,
+ );
+ }
+
+ unsubscribe(channelName?: string) {
+ this.logger.log(
+ `cleaning up event listeners for '${channelName ?? 'all'}' channel`,
+ );
+
+ if (channelName) {
+ return this.deleteChannel(channelName);
+ }
+
+ for (const channel of this.listenersRegistered.keys()) {
+ this.deleteChannel(channel);
+ }
+ }
+
+ private deleteChannel(channelName: string) {
+ const listeners = this.listenersRegistered.get(channelName) ?? [];
+
+ listeners.map((listener) =>
+ this.eventTarget.removeEventListener(channelName, listener)
+ );
+
+ this.listenersRegistered.delete(channelName);
+ }
+}
diff --git a/src/events/mod.ts b/src/events/mod.ts
new file mode 100644
index 00000000..cd7175ec
--- /dev/null
+++ b/src/events/mod.ts
@@ -0,0 +1,3 @@
+export * from './decorator.ts';
+export * from './events.ts';
+export * from './module.ts';
diff --git a/src/events/module.ts b/src/events/module.ts
new file mode 100644
index 00000000..415d180a
--- /dev/null
+++ b/src/events/module.ts
@@ -0,0 +1,46 @@
+import { OnAppBootstrap, OnAppClose } from '../hook/interfaces.ts';
+import { MetadataHelper } from '../metadata/helper.ts';
+import { InjectableConstructor, injector, Logger, Module } from '../mod.ts';
+import { eventListenerMetadataKey } from './constants.ts';
+import { EventEmitter } from './events.ts';
+
+@Module({
+ injectables: [EventEmitter],
+})
+export class EventEmitterModule implements OnAppBootstrap, OnAppClose {
+ private logger: Logger = new Logger('EventEmitterModule');
+
+ constructor() {}
+
+ onAppBootstrap(): void | Promise