Skip to content

About

Dispatching and listening for application events in typescript using dependency injection Inversify

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

event-dispatching

✨ 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.

Public API

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.

Usage

1. Events and payloads (in the domain module)

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;
}

2. A subscriber

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 a TypeError when 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.authRoles is stored but not read by the dispatching.

3. 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 guarantees

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 after await in a listener.
  • Listener changes during a dispatch (attach, detach, once) apply from the next dispatch. A once listener is detached before it runs: it runs at most once, even if it throws or several dispatches run concurrently.

Lifecycle and DI

  1. Decorators run when a module file is evaluated. They append frozen records to two process-wide lists stored with reflect-metadata on Reflect (DECORATOR_EVENT_SUBSCRIBER_META_KEY, DECORATOR_ON_META_KEY).
  2. bootstrap(app) binds MetadataRegistry (singleton) and EventDispatching (container default scope, singleton in the application) and initializes the registry; onPreInit initializes it again to catch modules imported later. Initialization is idempotent.
  3. First dispatch: the registry resolves every registered subscriber with container.get(SubscriberClass) (the application container uses autobind), 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 from container.get(TYPES.XSubscriber). A resolution failure is thrown to the dispatching's caller and retried at the next dispatch.
  4. 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.

Typed payloads (optional)

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;
    }
}

Testing

  • Bind a fake under TYPES.EventDispatching to record dispatches (see app-auth/__test__/helpers.ts).
  • To test real subscribers, call the default bootstrap({ container }) with a new Container({ defaultScope: "Singleton", autobind: true }), then get TYPES.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 with subscriber and method names, 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

About

Dispatching and listening for application events in typescript using dependency injection Inversify

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages