ProjectsmahiEvents

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Events

An event is a typed payload class. A listener is a class with handle(). The dispatcher walks its registrations in order and awaits each match.

import { AbstractEvent } from "@mahiframework/events";

export class PostCreated extends AbstractEvent {
  constructor(public readonly post: PostTable) {
    super();
  }
}
import type { Listener } from "@mahiframework/events";

export class LogPostCreated implements Listener<PostCreated> {
  constructor(private app: Application) {}

  handle(event: PostCreated): void {
    this.app.logger.info("Post created", { id: event.post.id, userId: event.post.user_id });
  }
}
await Events.dispatch(new PostCreated(post));

No decorators, no marker interfaces, no reflection. Wiring is an explicit [EventClass, ListenerClass] pair returned from a provider hook.

AbstractEvent

export abstract class AbstractEvent {
  get eventName(): string { return this.constructor.name; }
  static suppress<T>(callback: () => T | Promise<T>, patterns?: string[]): Promise<T>;
  static isSuppressed(name?: string): boolean;
}

The base class carries almost nothing. Subclass it, add constructor fields, done. There is no dispatch() method on the event itself and no broadcastAs(). Broadcasting is opted into by implementing ShouldBroadcast from @mahiframework/broadcasting, which the events package knows nothing about.

eventName

An instance getter, defaulting to the constructor's name. It is used for two things: wildcard listener patterns, and suppression patterns. Neither uses the class identity, instanceof handles that separately.

Override it to participate in a namespace:

export class PostCreated extends AbstractEvent {
  get eventName(): string { return "model.posts.created"; }
}

@mahiframework/database's ModelLifecycleEvent does exactly this, returning "model.{table}.{event}", which is what lets Post.withoutEvents() suppress just that model's events via "model.posts.*" rather than silencing the whole app. See Models.

Because it's derived from constructor.name by default, a minifier that mangles class names changes your event names. If you rely on wildcard patterns in a bundled build, override eventName with a literal.

Listener

export interface Listener<E extends AbstractEvent = AbstractEvent> {
  handle(event: E): void | Promise<void>;
}

export type ListenerClass<E extends AbstractEvent = AbstractEvent> =
  new (app: Application) => Listener<E>;

A listener class is constructed with the Application and nothing else. That's the whole dependency-injection story, pull what you need out of the container in the constructor, or don't declare one at all:

export class NotifyOnLike implements Listener<PostLiked> {
  async handle(event: PostLiked): Promise<void> {
    const post = await Post.find(event.like.post_id);
    if (post === undefined || post.user_id === event.like.user_id) return;
    await notify(new UserNotifiable(post.user_id), new LikeNotification(/* ... */));
  }
}

A fresh instance is constructed for every dispatch.

const listener: Listener = new registration.listenerClass(this.app);
await listener.handle(event);

Listeners are stateless by construction. There is no instance to accumulate state on between dispatches, and no shared object two concurrent dispatches could race on. Constructor work runs on every dispatch, so keep it cheap: resolve tokens, don't do I/O.

Registering listeners

Via a provider's listeners() hook, the default, and the one to use first:

export class PostsServiceProvider extends ServiceProvider {
  listeners(): ReadonlyArray<ListenerRegistration> {
    return [
      [PostCreated, LogPostCreated],
      [PostCreated, NotifyOnReply],
      ["model.posts.*", AuditModelWrites],
    ];
  }
}

One event, several listeners, each with one reason to change.

EventsServiceProvider.boot() walks every provider's listeners() hook and calls dispatcher.listen() for each pair. All providers are already instantiated by then, so hook collection is order-independent, but boot() itself runs sequentially in registration order, so list EventsServiceProvider before any provider whose own boot() dispatches an event and expects listeners to be wired.

A pair is either [EventClass, ListenerClass | ListenerFn] or [pattern, ListenerClass | WildcardListener]:

type ListenerRegistration =
  | readonly [EventClass, ListenerClass | ListenerFn]
  | readonly [string, ListenerClass | WildcardListener];

The type is not generic over the event. A hook returns a heterogeneous array covering many unrelated events, so there is no single E to infer. A per-pair generic would need existential types TypeScript doesn't have. A closure in a pair therefore receives AbstractEvent, not the narrowed event. Use a ListenerClass (which declares its own Listener<E>) when you want the typed payload from the hook, or register the closure through listen(), where inference does work.

listenQueued() is the one thing the hook can't express, call the dispatcher directly in your own boot():

boot(): void {
  const dispatcher = this.app.make<EventDispatcher>(EVENTS_TOKEN);
  dispatcher.listenQueued(PostCreated, GenerateThumbnails);
}

See Providers.

Registering from outside a provider

Events.listen() registers against the current app()'s dispatcher, for wiring done where there's no app in hand, a bootstrap script, a test setup:

Events.listen(PostCreated, LogPostCreated);
Events.listen("model.posts.*", AuditModelWrites);

Registrations are append-only for the life of the dispatcher, and there is no forget(). A listen() call in a module that gets imported more than once, or on a request path, silently registers a duplicate listener that runs on every subsequent dispatch. The listeners() hook runs exactly once, at boot, which is why it stays the default. Use the facade for one-off wiring, not as the normal way to register.

EventDispatcher

MethodPurpose
listen(eventClass, listenerClass | closure)Register a listener against an event class.
listen(pattern, listenerClass | callback)Register a listener against an event-name pattern.
listenQueued(eventClass, listenerClass)Register a listener that is enqueued, not run inline.
afterDispatch(callback)Run a callback after every dispatched event.
dispatch(event)Dispatch.
useQueuedListenerHandler(handler)Install the enqueue function. Bound by QueueServiceProvider.
enqueueQueuedListener(event, listenerClass)Build the payload and hand it to the handler.
runQueuedListener(payload)Rehydrate and run. Called by the queue job.

There is no until(). No listener can halt propagation by returning a value, and no dispatch returns a listener's result. Events are notifications, not a request/response channel. If you need a decision, use a Gate or a plain function call.

There is no subscribe(). No subscriber-class convention that registers many handlers from one object. Return more pairs from listeners().

There is no forget() / forgetPushed() / flush(). Registrations are append-only for the life of the dispatcher. For tests, build a fresh dispatcher or use RecordingEventDispatcher.

There is no dispatchIf() / dispatchUnless(). Write the if.

listen(): class vs. pattern

listen<E extends AbstractEvent>(eventClass: EventClass<E>, handler: ListenerFn<E>): void;
listen<E extends AbstractEvent>(eventClass: EventClass<E>, listenerClass: ListenerClass<E>): void;
listen(pattern: string, listener: ListenerClass | WildcardListener): void;

The overload is discriminated at runtime by typeof eventClassOrPattern === "string", then by whether the listener has a handle method on its prototype (isListenerClass()).

The two first-argument forms are not interchangeable, and the difference is the whole design.

An event class matches by instanceof and infers E, so the handler's event is fully typed:

listen(PostCreated, (event) => log(event.postId));   // event: PostCreated

A string matches event.eventName through the same wildcard matcher as Event.suppress(), and cannot narrow. A pattern is a runtime string with no type-level link to any event class, so there's nothing to infer from and no way to prove a given listener accepts whatever ends up matching:

listen("model.posts.*", (event) => log(event.eventName));   // event: AbstractEvent

This is why there is no string form for a single concrete event. listen("PostCreated", LogPostCreated) would be strictly worse than listen(PostCreated, LogPostCreated): no type safety, no rename safety, no inference, and it breaks under a minifier that mangles class names (eventName falls back to constructor.name). Use the class. Use a string only when you actually mean a family of events matched by name, which is the one thing the class form can't express.

Both forms accept a listener class or a bare function:

type WildcardListener = (event: AbstractEvent) => void | Promise<void>;

A pattern listener class is constructed fresh per matching dispatch with the Application, exactly like the event-class form, so it can pull its own dependencies from the container. A pattern callback isn't constructed at all. Neither narrows event beyond AbstractEvent, read event.eventName for the matched name and cast if you need the payload.

Wildcard pattern semantics

export function matchesPattern(name: string, pattern: string): boolean {
  const regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`);
  return regex.test(name);
}

The pattern is split on *, each literal chunk is regex-escaped, and the pieces are rejoined with .*. So:

* matches any run of characters, including dots. It is not a single dot-segment wildcard.

PatternMatchesAlso matches
model.posts.*model.posts.createdmodel.posts.a.b.c
model.*model.posts.createdmodel.users.deleted, model.
*everything:
*.createdmodel.posts.createdcreated is not matched (needs the dot)

The match is anchored at both ends (^...$), so posts does not match model.posts.created. And the whole . in the pattern is escaped, so a literal . in a pattern matches only a literal . in the name.

The same function backs Event.suppress() patterns, so the two use identical syntax.

dispatch(): the exact order

async dispatch<E extends AbstractEvent>(event: E): Promise<void> {
  if (AbstractEvent.isSuppressed(event.eventName)) return;

  for (const registration of this.registrations) {
    if (registration.kind === "class") {
      if (event instanceof registration.eventClass) {
        const listener: Listener = new registration.listenerClass(this.app);
        await listener.handle(event);
      }
    } else if (registration.kind === "queued") {
      if (event instanceof registration.eventClass) {
        await this.enqueueQueuedListener(event, registration.listenerClass);
      }
    } else if (registration.kind === "pattern-class") {
      if (matchesPattern(event.eventName, registration.pattern)) {
        const listener: Listener = new registration.listenerClass(this.app);
        await listener.handle(event);
      }
    } else if (matchesPattern(event.eventName, registration.pattern)) {
      await registration.handler(event);
    }
  }

  for (const callback of this.afterCallbacks) {
    await callback(event);
  }
}

Four things to take from that.

1. Suppression is checked first, and it's a hard return. Nothing runs, not listeners, not queued listeners, not afterDispatch callbacks.

2. Registrations run in one flat list, in registration order, sequentially awaited. There is no priority, no parallelism, and no separate ordering between class listeners, queued listeners and wildcard handlers, a wildcard registered before a class listener runs first. If one listener throws, the loop stops and the error propagates to whoever called dispatch(); later listeners never run.

That matters at your dispatch site. This controller:

await Events.dispatch(new PostCreated(post));
return HttpResponse.json(await new PostResource(post).toJson(), 201);

returns a 500 if any PostCreated listener throws, even though the post was already created. If a listener's failure shouldn't fail the request, either catch inside the listener or make it a listenQueued() one.

3. Class matching is instanceof, not identity. A listener registered against a base class receives every subclass:

class ModelEvent extends AbstractEvent {}
class PostCreated extends ModelEvent {}
class PostDeleted extends ModelEvent {}

dispatcher.listen(ModelEvent, AuditEverything);   // catches both

That's a feature, it's how you build event hierarchies, and a trap: a listener registered against AbstractEvent receives literally every event in the application, including the queue's JobProcessing/JobProcessed/ JobFailed.

4. afterDispatch callbacks run last, and a throwing one propagates.

for (const callback of this.afterCallbacks) {
  await callback(event);
}

No try. A callback that throws fails the dispatch() call, after every listener has already run, so the listeners' side effects happened and the caller still sees an error. That's a deliberate choice, but it means a callback that shouldn't be able to fail a dispatch must catch its own errors. BroadcastServiceProvider.boot() is the canonical example: it registers an afterDispatch that forwards ShouldBroadcast events to websocket clients and swallows its own failures, so an unreachable socket can't 500 a request.

afterDispatch()

type AfterDispatchCallback = (event: AbstractEvent) => void | Promise<void>;

Runs after every dispatched event, regardless of class and regardless of whether it had any listeners at all.

Deliberately general rather than a hook tailored to one consumer: it's "run this after every dispatch", which is what cross-cutting concerns, auditing, metrics, broadcasting, actually want, and none of them can enumerate every event class up front the way listen() requires. It's also what keeps @mahiframework/events free of any dependency on, or knowledge of, broadcasting.

dispatcher.afterDispatch(async (event) => {
  try {
    await metrics.increment(`events.${event.eventName}`);
  } catch { /* never fail a dispatch */ }
});

After-commit dispatch

An event dispatched inside a DB.transaction() fires immediately by default, so a listener runs even if the transaction later rolls back. Mark the event class static shouldDispatchAfterCommit and its listeners are held until the transaction commits (and dropped entirely on rollback); outside a transaction it dispatches immediately, so nothing at the call site changes:

class OrderPlaced extends AbstractEvent {
  static shouldDispatchAfterCommit = true;
  constructor(public readonly order: Order) { super(); }
}

await DB.transaction(async () => {
  const order = await Order.create({ ... });
  await Events.dispatch(new OrderPlaced(order));   // listeners run after commit
});

Events.dispatchAfterCommit(event) (and dispatcher.dispatchAfterCommit(event)) is the explicit per-call form for an event you don't want to mark. This builds on @mahiframework/database's afterCommit(). See Database → After-commit dispatch. A suppressed event stays suppressed regardless of the marker: the suppress() check runs at dispatch time, so it is never recorded or deferred.

Suppression

await AbstractEvent.suppress(async () => {
  await Events.dispatch(new PostCreated(post));   // no-op, no listeners run
});

await AbstractEvent.suppress(callback, ["model.posts.*"]);

suppress(callback, patterns = ["*"]) runs callback with dispatch suppressed for every event name matching one of patterns. Default is ["*"], everything.

Scoped via AsyncLocalStorage, the same mechanism @mahiframework/database's transaction() uses. So it covers every dispatch() made synchronously or through nested async calls inside the callback, with zero call-site changes. EventDispatcher.dispatch() checks the store itself, so every dispatch is covered, not only the ones a particular caller remembered to guard.

Always returns a Promise, even for a synchronous callback, so callers can await uniformly.

Patterns stack

const current = storage.getStore() ?? [];
return storage.run([...current, ...patterns], callback);

Nested suppress() calls concatenate, they don't replace. So an inner suppress(inner, ["model.comments.*"]) nested inside an outer suppress(outer, ["model.posts.*"]) has both active inside inner, a model-scoped withoutEvents() helper nests correctly inside a broader suppression rather than accidentally narrowing it.

There is no way to un-suppress from inside a suppressed scope. That's intentional.

isSuppressed()

AbstractEvent.isSuppressed()                        // is ANY suppression active?
AbstractEvent.isSuppressed("model.posts.created")   // does THIS name match one?

The no-argument form is hasActiveSuppression(); the named form runs the patterns. Consumers that dispatch outside EventDispatcher entirely, @mahiframework/database's ModelObserver/Model.on() hooks, which are direct calls rather than Event instances, check this themselves at their own dispatch point, passing the equivalent "model.{table}.{event}" name.

hasActiveSuppression, isNameSuppressed and matchesPattern are also exported directly if you're building something similar.

Queued listeners

dispatcher.listenQueued(PostCreated, GenerateThumbnails);

The listener is enqueued instead of run inline. Explicit method rather than a ShouldQueue marker interface plus reflection, so the queue integration stays opt-in and magic-free.

How it works

listenQueued() records the registration under an id and stores it in a lookup map. When a matching event is dispatched:

async enqueueQueuedListener(event: AbstractEvent, listenerClass: ListenerClass): Promise<void> {
  if (!this.queuedHandler) {
    throw new Error("No queued-listener handler is bound. ...");
  }
  const id = queuedListenerId(event.constructor as EventClass, listenerClass);
  await this.queuedHandler({ id, data: { ...event } });
}

The payload is:

interface QueuedListenerPayload {
  id: string;     // `${EventClass.name}:${ListenerClass.name}`
  data: object;   // { ...event } — own enumerable fields
}

QueueServiceProvider.boot() installs the handler, which dispatches the built-in HandleQueuedListener job (registered as events.handle-queued-listener). On the worker, that job calls runQueuedListener(payload), which rebuilds the event and runs the listener:

const event = Object.assign(Object.create(entry.eventClass.prototype), payload.data);
const listener: Listener = new entry.listenerClass(this.app);
await listener.handle(event);

What that implies

The id is "{EventClassName}:{ListenerClassName}". Both are runtime class names, so a minifier that mangles them breaks the mapping between an already-enqueued payload and its registration. It also means renaming either class orphans in-flight payloads:

Queued listener [PostCreated:GenerateThumbnails] is not registered on this dispatcher.

The payload is { ...event }, own enumerable fields only. Anything on the prototype (getters, methods, an overridden eventName) is not serialized; it comes back from the prototype on rebuild, which is generally what you want. Anything not JSON-round-trippable is not preserved: a Date field comes back as a string, a Model field comes back as whatever JSON.stringify made of it. Model references are not specially encoded here the way they are for job fields, a queued listener's event should carry plain data (an id, a row) rather than a live model. See Queues.

The event's constructor never re-runs. Object.create + Object.assign, same as a job rebuild. Compute into fields.

Dispatching a listenQueued() event with no handler bound throws:

No queued-listener handler is bound. Register QueueServiceProvider, or call
EventDispatcher.useQueuedListenerHandler(), before dispatching a listenQueued() event.

Thrown at dispatch time, from inside the registration loop, so it also aborts every listener registered after it. Register QueueServiceProvider, or install a handler yourself:

dispatcher.useQueuedListenerHandler(async (payload) => { recorded.push(payload); });

which is the intended way to assert on payload shape without standing up a queue.

Queued listeners still go through the normal registration loop. The await is the enqueue, not the run, so a durable queue connection means dispatch() resolves once the job is written, and the listener runs later. Under the sync connection it runs immediately, inline, exactly like a normal listener but with a serialize/rebuild round-trip in between.

Testing

RecordingEventDispatcher is a drop-in EventDispatcher subclass that records every dispatch() and then does nothing:

override async dispatch<E extends AbstractEvent>(event: E): Promise<void> {
  if (AbstractEvent.isSuppressed(event.eventName)) return;
  this.recorded.push(event);
}

No listeners run, no listenQueued() jobs are enqueued, no afterDispatch() callbacks fire.

That's the difference from Event.suppress(), which also stops listeners but records nothing. With the recorder a test can prove code tried to dispatch PostCreated while none of its side effects happened.

Note the suppression check is preserved: an event suppressed by an active Event.suppress() is neither recorded nor run, so suppress() keeps meaning "as if never dispatched" even under a fake.

const { events } = await createTestApplication(bootstrap, { fakeEvents: true });

await request("POST", "/posts", { body: "hello" });

events.assertDispatched(PostCreated);
events.assertDispatched(PostCreated, (e) => e.post.body === "hello");
events.assertNotDispatched(PostDeleted);
events.assertDispatchedTimes(PostCreated, 1);
MethodPurpose
dispatched(eventClass?, filter?)Matching events in dispatch order. All of them with no argument.
hasDispatched(eventClass, filter?)Boolean.
assertDispatched(eventClass, filter?)At least once.
assertNotDispatched(eventClass, filter?)Never: with a filter, no matching event.
assertDispatchedTimes(eventClass, times, filter?)Exactly times.
assertNothingDispatched()Nothing at all.
reset()Discard recordings: for a beforeEach().

Matching is instanceof, so dispatched(ModelEvent) returns every subclass instance too.

assertDispatchedTimes is the one assertDispatched() can't make: "fired once, not twice" is exactly the shape of a double-dispatch bug, and assertNotDispatched() only covers zero.

listen()/afterDispatch() still record normally on the inherited methods, so provider boot wiring doesn't throw, the registrations simply never run.

createTestApplication({ fakeEvents: true }) replaces the container singleton, so every fresh make(EVENTS_TOKEN), model lifecycle events, the Events facade, resolves the recorder. Assertions throw plain Errors, not vitest matchers.

The Events facade

export class Events extends Facade<EventDispatcher>(() => EVENTS_TOKEN) {
  static dispatch(event): Promise<void>;
  static dispatchAfterCommit(event): Promise<void>;
  static listen(eventClassOrPattern, listener): void;
  static listenQueued(eventClass, listenerClass): void;
}

Every static re-resolves app() on each call. Nothing is cached on the facade, so a test that swaps in a fresh Application (or RecordingEventDispatcher) is picked up automatically.

afterDispatch() is deliberately not on the facade: it's a framework-integration hook (broadcasting uses it), not an application API, and it only makes sense during boot where you already have app.

Prefer injecting EventDispatcher via EVENTS_TOKEN where practical, and prefer the listeners() hook over Events.listen() for permanent wiring. See Registering from outside a provider for why. Use the facade at call sites where threading app through is genuinely inconvenient, a controller mid-handler, typically:

await Events.dispatch(new PostCreated(post));
await Bus.dispatch(new LogPostCreatedJob(post));

Gotchas

A throwing listener fails the dispatch and skips every later listener. Sequential and awaited, no isolation. Catch inside the listener, or queue it.

A throwing afterDispatch callback fails the dispatch too, after every listener has already run. Callbacks must catch their own errors.

instanceof matching means base-class listeners catch subclasses, including a listener on AbstractEvent, which catches everything in the app.

* in a pattern matches dots. model.* matches model.posts.created, not just model.posts.

A pattern never narrows the event type. Both the class and callback forms of a pattern listener get AbstractEvent. Use an event class when you want a typed payload.

Events.listen() on a re-imported module or a request path duplicates listeners. Registrations are append-only and there's no forget(). Register in a provider's listeners() hook, which runs once.

eventName defaults to constructor.name, so minification changes it. Override with a literal if you depend on patterns in a bundled build.

Suppression patterns stack and can't be undone from inside a scope.

Queued-listener ids are built from class names and are not stable across renames or minification.

A queued listener's payload is { ...event }, no model encoding, no Date preservation.

listenQueued() without a bound handler throws at dispatch, aborting the rest of the registration loop.

Listeners are constructed fresh per dispatch. Don't put expensive work in a constructor.

EventsServiceProvider must be booted before providers that dispatch during their own boot().

  • Providers: the listeners() hook and boot ordering
  • Queues: listenQueued(), and the queue's own lifecycle events
  • Broadcasting: ShouldBroadcast, via afterDispatch()
  • Models: dispatchesEvents, withoutEvents(), model.{table}.{event} names
  • Testing: createTestApplication({ fakeEvents: true })
  • Container: EVENTS_TOKEN