Skip to content

Commit

Permalink
Feature: Events (#76)
Browse files Browse the repository at this point in the history
* feat: add Event Emmiter for Danet

* enh: register events

* fix: remove uneeded BroadcastChannel

* enh: add code example for events

* enh: add EventEmitterModule

* fea: use EventTarget + CustomEvent

* enh: cleanup subscribers OnAppClose

* enh: logging

* enh: update example

* fix: deno fmt changes

* fix: update remaining listeners

* fix: example/events.ts

Co-authored-by: Thomas Cruveilher <[email protected]>

* enh: add Map for handling registered listeners

* fix: typo

* fea: move event listeners registration

- separate module and service file

* enh: cleanup injector dependencies

* fix: expose resolvedTypes from injector

* enh: code cleanup

* enh: add test cases for EventEmitter

* enh: add test cases for EventEmitterModule

* refactor: use injector.injectables

---------

Co-authored-by: Thomas Cruveilher <[email protected]>
  • Loading branch information
marco-souza and Sorikairox authored Feb 19, 2024
1 parent 465c68e commit 28ccfe7
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 12 deletions.
53 changes: 53 additions & 0 deletions example/events.ts
Original file line number Diff line number Diff line change
@@ -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);
166 changes: 166 additions & 0 deletions spec/events.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
14 changes: 7 additions & 7 deletions spec/method-param-decorator.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/deps_test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
export {
assertSpyCall,
assertSpyCalls,
spy,
} from 'https://deno.land/[email protected]/testing/mock.ts';
export {
assertEquals,
assertInstanceOf,
assertNotEquals,
assertObjectMatch,
assertRejects,
assertThrows,
} from 'https://deno.land/[email protected]/testing/asserts.ts';
export * as path from 'https://deno.land/[email protected]/path/mod.ts';
export {
Expand Down
1 change: 1 addition & 0 deletions src/events/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const eventListenerMetadataKey = 'event-listener';
13 changes: 13 additions & 0 deletions src/events/decorator.ts
Original file line number Diff line number Diff line change
@@ -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;
};
};
68 changes: 68 additions & 0 deletions src/events/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Logger } from '../mod.ts';

// deno-lint-ignore no-explicit-any
type Listener<P = any> = (payload: P) => void;

export class EventEmitter {
private logger: Logger = new Logger('EventEmitter');
private listenersRegistered: Map<string, Listener[]>;
private eventTarget: EventTarget;

constructor() {
this.listenersRegistered = new Map();
this.eventTarget = new EventTarget();
}

emit<P>(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<P>(channelName: string, listener: Listener<P>) {
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);
}
}
3 changes: 3 additions & 0 deletions src/events/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './decorator.ts';
export * from './events.ts';
export * from './module.ts';
Loading

0 comments on commit 28ccfe7

Please sign in to comment.