✨ This package is a modernized and extended version of
pleerock/event-dispatch, refactored for personal use and better extensibility.
In-process application event bus of the server. Domain modules declare subscribers with
decorators, services dispatch events through AbstractService.eventDispatching, and the dispatching
calls the matching subscriber methods, resolved through the Inversify container.
It is not a queue: no persistence, retry, timeout, transaction or cross-process delivery. Deferred or retryable work belongs to the scheduler / workflows. Events only reach listeners of the same Node.js process.
| Export | Kind | Status |
|---|---|---|
event_subscriber() (aliases eventSubscriber, EventSubscriber) |
class decorator | stable |
on(event | events, options?) (alias On) |
method decorator | stable |
EventDispatching: dispatch, asyncDispatch |
service | stable |
EventDispatching: on, once, attach, detach, remove, detachAll, getActiveListeners |
runtime listeners | stable, used in tests |
TYPES.EventDispatching, TYPES.MetadataRegistry |
DI tokens | stable |
MetadataRegistry: init, clear, add*/has*Metadata, getEventListeners |
registry | stable, internal use |
MetadataRegistry: getAllHandlers, findAllEventsHandlersByEventName |
registry | deprecated |
EventMap, EventOptions, EventSubscriberOptions, IEventListenerDescriptor, IEventMetadata, ISubscriberMetadata, IEventSubscriber, IEventsHandler |
types | stable (IEventSubscriber, IEventsHandler: legacy) |
DECORATOR_EVENT_SUBSCRIBER_META_KEY, DECORATOR_ON_META_KEY |
metadata keys | stable |
isEmpty(value) |
utility (defines which dispatch results are kept) | stable, kept for compatibility |
default export bootstrap(app), module |
module entry | stable (loaded through app/modules.json) |
Everything is exported from the package root; there are no deep import paths.
Each module owns its event names and payload types. Event names are plain strings
("<module_snake>.<aggregate>.<event>"); they may be stored or logged, so never rename them.
export class UserEvents {
static readonly USER_CREATED = "app_auth.user.created";
}
export interface UserCreatedEvent {
user: User;
}import { inject, injectable } from "inversify";
import { event_subscriber, on } from "event-dispatching";
import { AbstractService } from "app-common";
@injectable()
@event_subscriber()
export class CMSContentUserEventSubscriber extends AbstractService {
constructor(@inject(TYPES.PageManager) private readonly _pageManager: PageManager) {
super("cms_content.UserEventSubscriber");
}
@on(UserEvents.USER_CREATED)
public async onUserCreated(event: UserCreatedEvent): Promise<void> {
await this._pageManager.createDefaultPages(event.user);
}
// One method, several events
@on([UserEvents.USER_UPDATED, UserEvents.USER_DELETED])
public async onUserChanged(event: UserCreatedEvent): Promise<void> { /* ... */ }
}@injectable()is required: the subscriber is resolved through the container.- Bind it in the module (
options.bind(TYPES.XSubscriber).to(XSubscriber)), as the other modules do. - The file must be imported by the module (it is, through the binding), so that its decorators run before the application initializes the modules.
- Only instance methods declared on the subscriber class are listeners.
@on()on a static method, an accessor, or with an empty / non-string event name throws aTypeErrorwhen the class is defined. Stacked@on()decorators on one method add up. @on()methods of a class without@event_subscriber()are ignored. Inherited@on()methods are not listeners of the subclass: decorate the subclass method too.EventOptions.authRolesis stored but not read by the dispatching.
// In any AbstractService subclass
this.eventDispatching.dispatch(UserEvents.USER_CREATED, { user });
// Await the listeners and read their results
const results = await this.eventDispatching.asyncDispatch(OAuth2Events.OAUTH2_EXPIRED, { oAuth2 });Outside AbstractService: container.get<EventDispatching>(TYPES.EventDispatching).
dispatch() |
asyncDispatch() |
|
|---|---|---|
| Returns | after every listener has been called | a promise, after every listener has settled |
| Async listeners | started, not awaited (fire and forget) | awaited |
| Concurrency | listeners called one after the other | listeners of one event started in order, then awaited together (they run concurrently); events of an array handled one after the other |
| Order | runtime listeners (on/once/attach, attachment order), then subscribers in registration order, then methods in declaration order |
same start order |
| Listener error | reported, other listeners still run | same; the promise never rejects because of a listener |
| Async rejection | reported (never an unhandled rejection) | reported |
| Result | non-empty results (isEmpty), in call order; pending promises count as empty |
non-empty resolved results, in call order |
| No listener | no-op, [] |
[] |
| Throws | only if a subscriber cannot be resolved (message Error instantiating class "X": ..., container error as cause) |
rejects in the same case |
- Errors are reported with
console.error("[event-dispatching] Listener <Class.method> failed for event \"<event>\":", error). The original error object (message, stack, cause) is logged as is. The payload is never logged: it can hold users, tokens or sessions. - Payload: every listener receives the same object reference. It is neither cloned nor frozen; do not mutate it unless the event contract says so.
- Nested dispatch: a listener may dispatch other events. With
dispatch(), the nested event is fully handled before the outer listener continues. - Request context: listeners run in the caller's async context, so
AsyncLocalStorage(request context, sessions) is preserved, also afterawaitin a listener. - Listener changes during a dispatch (attach, detach,
once) apply from the next dispatch. Aoncelistener is detached before it runs: it runs at most once, even if it throws or several dispatches run concurrently.
- Decorators run when a module file is evaluated. They append frozen records to two
process-wide lists stored with
reflect-metadataonReflect(DECORATOR_EVENT_SUBSCRIBER_META_KEY,DECORATOR_ON_META_KEY). bootstrap(app)bindsMetadataRegistry(singleton) andEventDispatching(container default scope, singleton in the application) and initializes the registry;onPreInitinitializes it again to catch modules imported later. Initialization is idempotent.- First dispatch: the registry resolves every registered subscriber with
container.get(SubscriberClass)(the application container usesautobind), in registration order, then indexes the listeners by event. Instances are cached by the registry for its lifetime: a subscriber behaves as a singleton whatever its binding scope, and is a different object fromcontainer.get(TYPES.XSubscriber). A resolution failure is thrown to the dispatching's caller and retried at the next dispatch. - Next dispatches read the listeners of the event from the index (no scan, no reflection). Registering new metadata rebuilds the index on the next dispatch.
Listener methods are looked up on the instance at call time, with the instance as this, so
injected dependencies (this._manager, this.log, ...) are available.
Payloads are any by default. A module can opt in to checked payloads by augmenting EventMap;
other events keep working unchanged:
declare module "event-dispatching" {
interface EventMap {
"app_auth.user.created": UserCreatedEvent;
}
}- Bind a fake under
TYPES.EventDispatchingto record dispatches (seeapp-auth/__test__/helpers.ts). - To test real subscribers, call the default
bootstrap({ container })with anew Container({ defaultScope: "Singleton", autobind: true }), then getTYPES.EventDispatching. - The decorator lists are process-wide: save and restore both metadata keys around tests that
declare decorated classes (see
__tests__/EventDispatching.behavior.test.ts). registry.getEventListeners(event)lists the listeners of an event (frozen descriptors withsubscriberandmethodnames, no payload).
pnpm --filter event-dispatching exec vitest run
pnpm --filter event-dispatching exec tsc --project tsconfig.json --noEmit
pnpm --filter event-dispatching run build # consumers read dist