diff --git a/package.json b/package.json index 34c029d..f7e1fc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/event", - "version": "5.0.0", + "version": "5.1.0", "description": "The Athenna events handler with queue store. Based on Emittery syntax.", "license": "MIT", "author": "João Lenon ", @@ -170,11 +170,14 @@ }, "athenna": { "bootLogs": false, - "listeners": [ - "#tests/fixtures/listeners/AnnotatedListener", - "#tests/fixtures/listeners/HelloListener", - "#tests/fixtures/listeners/ProductListener" - ], + "events": { + "path": "#tests/fixtures/events/index", + "listeners": [ + "#tests/fixtures/listeners/AnnotatedListener", + "#tests/fixtures/listeners/HelloListener", + "#tests/fixtures/listeners/ProductListener" + ] + }, "templates": { "listener": "./templates/listener.edge" } diff --git a/src/commands/MakeListenerCommand.ts b/src/commands/MakeListenerCommand.ts index 4afa81d..ae26d1d 100644 --- a/src/commands/MakeListenerCommand.ts +++ b/src/commands/MakeListenerCommand.ts @@ -46,10 +46,10 @@ export class MakeListenerCommand extends BaseCommand { const importPath = this.generator.getImportPath() - await this.rc.pushTo('listeners', importPath).save() + await this.rc.pushTo('events.listeners', importPath).save() this.logger.success( - `Athenna RC updated: ({dim,yellow} [ listeners += "${importPath}" ])` + `Athenna RC updated: ({dim,yellow} [ events.listeners += "${importPath}" ])` ) } } diff --git a/src/events/EventImpl.ts b/src/events/EventImpl.ts index 114231c..bfa0286 100644 --- a/src/events/EventImpl.ts +++ b/src/events/EventImpl.ts @@ -8,7 +8,7 @@ */ import { Config } from '@athenna/config' -import { Macroable } from '@athenna/common' +import { Is, Macroable } from '@athenna/common' import { Listener } from '#src/events/Listener' import { QueueImpl, type ConnectionOptions } from '@athenna/queue' import type { EventClosure, Context } from '#src/types' @@ -89,11 +89,20 @@ export class EventImpl extends Macroable { * * @example * ```ts + * Event.on('user.created', 'UserCreatedListener') + * // or * Event.on('user.created', user => console.log(user)) * ``` */ - public on(event: string, closure: EventClosure) { - const listener = new Listener(event, closure) + public on(event: string, closure: EventClosure | string) { + let listener = null + + if (Is.String(closure)) { + listener = new Listener(event, ctx => ioc.safeUse(closure).handle(ctx)) + } else { + listener = new Listener(event, closure) + } + const set = this.listeners.get(event) ?? new Set() set.add(listener.id) diff --git a/src/providers/EventProvider.ts b/src/providers/EventProvider.ts index 63de5f7..0fe353f 100644 --- a/src/providers/EventProvider.ts +++ b/src/providers/EventProvider.ts @@ -9,10 +9,25 @@ import { ServiceProvider } from '@athenna/ioc' import { EventImpl } from '#src/events/EventImpl' +import { Module, Path, File } from '@athenna/common' +import { sep, isAbsolute, resolve } from 'node:path' export class EventProvider extends ServiceProvider { public async register() { this.container.singleton('Athenna/Core/Event', EventImpl) + + const listeners = Config.get('rc.events.listeners', []) + + await this.container.loadModules(listeners, { + addCamelAlias: true, + parentURL: this.getMeta() + }) + + const eventsPath = Config.get('rc.events.path', null) + + if (eventsPath) { + await this.registerEvents(eventsPath) + } } public async shutdown() { @@ -27,4 +42,36 @@ export class EventProvider extends ServiceProvider { await event.queue.closeAll() } + + /** + * Get the meta URL of the project. + */ + private getMeta() { + return Config.get('rc.parentURL', Path.toHref(Path.pwd() + sep)) + } + + /** + * Register the events file by importing the file. + */ + private async registerEvents(path: string) { + // Bust ESM import cache to guarantee the events file re-runs (tests/providers + // may register/shutdown provider multiple times in same process). + const resolvedPath = `${path}?version=${Math.random()}` + + if (path.startsWith('#')) { + await Module.resolve(resolvedPath, this.getMeta()) + + return + } + + if (!isAbsolute(path)) { + path = resolve(path) + } + + if (!(await File.exists(path))) { + return + } + + await Module.resolve(`${path}?version=${Math.random()}`, this.getMeta()) + } } diff --git a/tests/fixtures/constants/index.ts b/tests/fixtures/constants/index.ts index 4a5a6df..c776377 100644 --- a/tests/fixtures/constants/index.ts +++ b/tests/fixtures/constants/index.ts @@ -13,6 +13,8 @@ export const constants = { helloListener: false, productListener: false, annotatedListener: false, - decoratedListener: false - } + decoratedListener: false, + closureListener: false + }, + LAST_EVENT: null } diff --git a/tests/fixtures/events/index.ts b/tests/fixtures/events/index.ts new file mode 100644 index 0000000..56026a1 --- /dev/null +++ b/tests/fixtures/events/index.ts @@ -0,0 +1,20 @@ +/** + * @athenna/event + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Event } from '#src' +import { constants } from '#tests/fixtures/constants/index' + +Event.on('annotatedListener', 'annotatedListener') +Event.on('helloListener', 'helloListener') +Event.on('productListener', 'productListener') + +Event.on('closureListener', ctx => { + constants.RUN_MAP.closureListener = true + constants.LAST_EVENT = ctx +}) diff --git a/tests/fixtures/events/listeners/AnnotatedListener.ts b/tests/fixtures/events/listeners/AnnotatedListener.ts new file mode 100644 index 0000000..e5d6c1b --- /dev/null +++ b/tests/fixtures/events/listeners/AnnotatedListener.ts @@ -0,0 +1,23 @@ +/** + * @athenna/event + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Listener } from '#src' +import { constants } from '#tests/fixtures/constants/index' + +@Listener({ + type: 'singleton', + alias: 'decoratedListener', + camelAlias: 'annotatedListener' +}) +export class AnnotatedListener { + public async handle() { + constants.RUN_MAP.decoratedListener = true + constants.RUN_MAP.annotatedListener = true + } +} diff --git a/tests/fixtures/events/listeners/HelloListener.ts b/tests/fixtures/events/listeners/HelloListener.ts new file mode 100644 index 0000000..a3b7a48 --- /dev/null +++ b/tests/fixtures/events/listeners/HelloListener.ts @@ -0,0 +1,18 @@ +/** + * @athenna/event + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { constants } from '#tests/fixtures/constants/index' +import { Listener } from '#src' + +@Listener({ connection: 'memoryA' }) +export class HelloListener { + public async handle() { + constants.RUN_MAP.helloListener = true + } +} diff --git a/tests/fixtures/events/listeners/ProductListener.ts b/tests/fixtures/events/listeners/ProductListener.ts new file mode 100644 index 0000000..0d218d8 --- /dev/null +++ b/tests/fixtures/events/listeners/ProductListener.ts @@ -0,0 +1,27 @@ +/** + * @athenna/event + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Listener, type Context } from '#src' +import { constants } from '#tests/fixtures/constants/index' + +@Listener({ connection: 'memory' }) +export class ProductListener { + public async handle(ctx: Context) { + if (ctx.data.failOnAllAttempts) { + throw new Error('testing') + } + + if (ctx.data.failOnFirstAttemptOnly && ctx.attempts >= 1) { + throw new Error('testing') + } + + constants.PRODUCTS.push(ctx.data) + constants.RUN_MAP.productListener = true + } +} diff --git a/tests/fixtures/listeners/HelloListener.ts b/tests/fixtures/listeners/HelloListener.ts index 879ac1b..a7a93d1 100644 --- a/tests/fixtures/listeners/HelloListener.ts +++ b/tests/fixtures/listeners/HelloListener.ts @@ -7,8 +7,10 @@ * file that was distributed with this source code. */ +import { Listener } from '#src' import { constants } from '#tests/fixtures/constants/index' +@Listener({ connection: 'memory' }) export class HelloListener { public async handle() { constants.RUN_MAP.helloListener = true diff --git a/tests/unit/commands/MakeListenerCommandTest.ts b/tests/unit/commands/MakeListenerCommandTest.ts index ff1c511..72e2ebc 100644 --- a/tests/unit/commands/MakeListenerCommandTest.ts +++ b/tests/unit/commands/MakeListenerCommandTest.ts @@ -21,12 +21,14 @@ export default class MakeListenerCommandTest extends BaseCommandTest { output.assertSucceeded() output.assertLogged('[ MAKING LISTENER ]') output.assertLogged('[ success ] Listener "TestListener" successfully created.') - output.assertLogged('[ success ] Athenna RC updated: [ listeners += "#src/events/listeners/TestListener" ]') + output.assertLogged( + '[ success ] Athenna RC updated: [ events.listeners += "#src/events/listeners/TestListener" ]' + ) const { athenna } = await new File(Path.pwd('package.json')).getContentAsJson() assert.isTrue(await File.exists(Path.listeners('TestListener.ts'))) - assert.containSubset(athenna.listeners, ['#src/events/listeners/TestListener']) + assert.containSubset(athenna.events.listeners, ['#src/events/listeners/TestListener']) } @Test() @@ -39,13 +41,13 @@ export default class MakeListenerCommandTest extends BaseCommandTest { output.assertLogged('[ MAKING LISTENER ]') output.assertLogged('[ success ] Listener "TestListener" successfully created.') output.assertLogged( - '[ success ] Athenna RC updated: [ listeners += "#tests/fixtures/storage/listeners/TestListener" ]' + '[ success ] Athenna RC updated: [ events.listeners += "#tests/fixtures/storage/listeners/TestListener" ]' ) const { athenna } = await new File(Path.pwd('package.json')).getContentAsJson() assert.isTrue(await File.exists(Path.fixtures('storage/listeners/TestListener.ts'))) - assert.containSubset(athenna.listeners, ['#tests/fixtures/storage/listeners/TestListener']) + assert.containSubset(athenna.events.listeners, ['#tests/fixtures/storage/listeners/TestListener']) } @Test() diff --git a/tests/unit/events/EventImplTest.ts b/tests/unit/events/EventImplTest.ts index 2a46da2..0987ccf 100644 --- a/tests/unit/events/EventImplTest.ts +++ b/tests/unit/events/EventImplTest.ts @@ -20,18 +20,18 @@ export class EventImplTest { public async beforeEach() { await Config.loadAll(Path.fixtures('config')) - new LoggerProvider().register() - new QueueProvider().register() - new EventProvider().register() + await new LoggerProvider().register() + await new QueueProvider().register() + await new EventProvider().register() } @AfterEach() public async afterEach() { Event.clearListeners() - new QueueProvider().shutdown() - new EventProvider().shutdown() - new LoggerProvider().shutdown() + await new QueueProvider().shutdown() + await new EventProvider().shutdown() + await new LoggerProvider().shutdown() Config.clear() ioc.reconstruct() diff --git a/tests/unit/providers/EventProviderTest.ts b/tests/unit/providers/EventProviderTest.ts index 1ce00ba..7f60c2f 100644 --- a/tests/unit/providers/EventProviderTest.ts +++ b/tests/unit/providers/EventProviderTest.ts @@ -7,9 +7,12 @@ * file that was distributed with this source code. */ -import { Path } from '@athenna/common' import { Config } from '@athenna/config' import { Event } from '#src/facades/Event' +import { Path, Sleep } from '@athenna/common' +import { QueueProvider } from '@athenna/queue' +import { LoggerProvider } from '@athenna/logger' +import { constants } from '#tests/fixtures/constants/index' import { EventProvider } from '#src/providers/EventProvider' import { Test, Mock, BeforeEach, AfterEach, type Context } from '@athenna/test' @@ -17,10 +20,18 @@ export class EventProviderTest { @BeforeEach() public async beforeEach() { await Config.loadAll(Path.fixtures('config')) + + await new LoggerProvider().register() + await new QueueProvider().register() + await new EventProvider().register() } @AfterEach() public async afterEach() { + await new EventProvider().shutdown() + await new QueueProvider().shutdown() + await new LoggerProvider().shutdown() + Mock.restoreAll() ioc.reconstruct() Config.clear() @@ -28,28 +39,28 @@ export class EventProviderTest { @Test() public async shouldBeAbleToRegisterEventImplementationInTheContainer({ assert }: Context) { - new EventProvider().register() + await new EventProvider().register() assert.isTrue(ioc.has('Athenna/Core/Event')) } @Test() public async shouldBeAbleToUseEventImplementationFromFacade({ assert }: Context) { - new EventProvider().register() + await new EventProvider().register() assert.isDefined(Event.queue) } @Test() public async shouldBeAbleToCloseAllConsumersOnShutdown({ assert }: Context) { - new EventProvider().register() + await new EventProvider().register() Event.on('test1', () => {}) Event.on('test2', () => {}) assert.equal(Event.consumerCount(), 1) - new EventProvider().shutdown() + await new EventProvider().shutdown() assert.equal(Event.consumerCount(), 0) } @@ -58,4 +69,46 @@ export class EventProviderTest { public async shouldNotThrowErrorIfProviderIsNotRegisteredWhenShuttingDown({ assert }: Context) { await assert.doesNotReject(() => new EventProvider().shutdown()) } + + @Test() + public async shouldLoadListenersAndRegisterEventsFile({ assert }: Context) { + constants.RUN_MAP.helloListener = false + constants.RUN_MAP.productListener = false + constants.RUN_MAP.annotatedListener = false + constants.RUN_MAP.decoratedListener = false + constants.RUN_MAP.closureListener = false + constants.PRODUCTS.length = 0 + constants.LAST_EVENT = null + + assert.equal(Config.get('rc.events.path'), '#tests/fixtures/events/index') + + await new EventProvider().register() + + assert.isTrue(ioc.has('helloListener')) + assert.isTrue(ioc.has('productListener')) + assert.isTrue(ioc.has('annotatedListener')) + + assert.equal(Event.listenerCount('helloListener'), 1) + assert.equal(Event.listenerCount('productListener'), 1) + assert.equal(Event.listenerCount('annotatedListener'), 1) + assert.equal(Event.listenerCount('closureListener'), 1) + + Event.store('memoryA') + + await Event.emit('helloListener') + await Event.emit('productListener', { id: 1 }) + await Event.emit('annotatedListener') + await Event.emit('closureListener', { ok: true }) + + await Sleep.for(200).milliseconds().wait() + + assert.isTrue(constants.RUN_MAP.helloListener) + assert.isTrue(constants.RUN_MAP.productListener) + assert.isTrue(constants.RUN_MAP.annotatedListener) + assert.isTrue(constants.RUN_MAP.decoratedListener) + assert.isTrue(constants.RUN_MAP.closureListener) + assert.deepEqual(constants.PRODUCTS, [{ id: 1 }]) + assert.isDefined(constants.LAST_EVENT) + assert.deepEqual(constants.LAST_EVENT.data, { ok: true }) + } }