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; + private eventTarget: EventTarget; + + constructor() { + this.listenersRegistered = new Map(); + this.eventTarget = new EventTarget(); + } + + emit

(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 { + for (const instance of injector.injectables) { + this.registerAvailableEventListeners(instance); + } + } + + onAppClose() { + const emitter = injector.get(EventEmitter); + emitter.unsubscribe(); + } + + // deno-lint-ignore no-explicit-any + private registerAvailableEventListeners(injectableInstance: any) { + const methods = Object.getOwnPropertyNames(injectableInstance.constructor.prototype); + + for (const method of methods) { + const target = injectableInstance[method]; + const eventListenerMedatada = MetadataHelper.getMetadata< + { channel: string } + >( + eventListenerMetadataKey, + target, + ); + if (!eventListenerMedatada) continue; + const { channel } = eventListenerMedatada; + + const emitter = injector.get(EventEmitter); + emitter.subscribe(channel, target); + this.logger.log(`registering method '${method}' to event '${channel}'`); + } + } +} diff --git a/src/injector/injector.ts b/src/injector/injector.ts index 777ed2a1..de58edef 100644 --- a/src/injector/injector.ts +++ b/src/injector/injector.ts @@ -1,7 +1,6 @@ import { Logger } from '../logger.ts'; import { MetadataHelper } from '../metadata/helper.ts'; -import { ModuleConstructor } from '../module/constructor.ts'; -import { ModuleInstance, moduleMetadataKey } from '../module/decorator.ts'; +import { ModuleInstance } from '../module/decorator.ts'; import { ControllerConstructor } from '../router/controller/constructor.ts'; import { Constructor } from '../utils/constructor.ts'; import { getInjectionTokenMetadataKey } from './decorator.ts'; @@ -66,7 +65,9 @@ export class Injector { this.modules.push(module); } - public addAvailableInjectable(injectables: (InjectableConstructor | TokenInjector)[]) { + public addAvailableInjectable( + injectables: (InjectableConstructor | TokenInjector)[], + ) { for (const injectable of injectables) { const actualKey = injectable instanceof TokenInjector ? injectable.token @@ -150,6 +151,7 @@ export class Injector { injectionData, actualType, ); + let canBeSingleton = injectableMetadata?.scope !== SCOPE.REQUEST && injectableMetadata?.scope !== SCOPE.TRANSIENT; if (canBeSingleton) { @@ -286,5 +288,6 @@ export class Injector { export let injector: Injector; // @ts-ignore used before initialization -if (!injector) - injector = new Injector(); \ No newline at end of file +if (!injector) { + injector = new Injector(); +} diff --git a/src/mod.ts b/src/mod.ts index edc8ad0f..794a4e03 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -8,3 +8,4 @@ export * from './module/mod.ts'; export * from './injector/mod.ts'; export * from './guard/mod.ts'; export * from './logger.ts'; +export * from './events/mod.ts';