Keelv0.86.0
Docs / Events

A tiny event emitter for decoupling — fire an event in one place, handle it in another. The emitter is a container singleton, reachable through the global emit() / listen() helpers.

Listen and emit

import { emit, listen } from "@shaferllc/keel/core";

// register a listener (usually in a provider's boot())
listen("user.registered", (user) => {
  sendWelcomeEmail(user);
});

// fire it from anywhere — emit awaits every listener
await emit("user.registered", user);

listen() returns an unsubscribe function:

const off = listen("tick", () => {});
off(); // stop listening

Typed payloads

Both emit and listen take a payload type parameter, so the value you fire and the value your listener receives line up at compile time:

type OrderPaid = { id: number; total: number };

listen<OrderPaid>("order.paid", (order) => {
  order.total; // number
});

await emit<OrderPaid>("order.paid", { id: 1, total: 4200 });

This is a convenience for the caller: you're passing the same type on both sides by hand, and nothing checks that you did. Get it wrong in one place and the two drift apart silently.

The event registry

Declare an event once in EventsList and the emitter checks both sides of it for you — no type argument to remember, and no way for the emitter and the listener to disagree:

declare module "@shaferllc/keel/core" {
  interface EventsList {
    "order.paid": { id: number; total: number };
    "user.registered": User;
  }
}

Put that in a types/events.ts (anywhere the compiler sees it). From then on:

listen("order.paid", (order) => {
  order.total; // number — inferred from the registry
});

await emit("order.paid", { id: 1, total: 4200 }); // ✅

await emit("order.paid", { id: 1, total: "4200" }); // ❌ total must be a number
await emit("order.paid"); // ❌ this event requires a payload
listen("order.paid", (o: { nope: boolean }) => {}); // ❌ wrong listener payload

Declaring events is opt-in and incremental. An event you haven't declared behaves exactly as it always has — the payload type comes from the listener or an explicit listen<T>, and falls back to unknown — so you can add entries to EventsList one at a time.

Nothing validates the payload at runtime; this is a compile-time contract.

Where to register listeners

Register listeners in a service provider's boot() method, so they're wired up once when the app starts:

export class EventServiceProvider extends ServiceProvider {
  boot(): void {
    listen("order.paid", (order) => this.app.make(Fulfillment).ship(order));
  }
}

boot() runs after every provider has registered, so it's safe to resolve other services there. Don't register listeners in register() — the services they reach may not be bound yet.

The full API

Reach the emitter directly with events() for once, off, and more:

import { events } from "@shaferllc/keel/core";

events().once("boot", () => {});      // fire once, then auto-unsubscribe
events().off("tick", listener);
events().listenerCount("tick");
events().clear("tick");                // or clear() for everything

Ordering and awaiting

Listeners for an event run in registration order, and emit() awaits each one before starting the next — so a slow async listener finishes before the following listener begins, and before emit() resolves:

listen("deploy", async () => { await step1(); });
listen("deploy", async () => { await step2(); }); // runs after step1 settles

await emit("deploy"); // resolves only after both steps finish

emit() snapshots the listener set before iterating, so a listener that subscribes (or unsubscribes) another during the same emit() doesn't change who runs this time around — the change takes effect on the next emission.

Error behavior

A listener that throws does not stop the others. emit() runs every listener, then reports what broke. That's the whole point of an emitter: the analytics listener blowing up shouldn't silently cancel the welcome email.

listen("user.registered", () => sendWelcomeEmail(user)); // runs
listen("user.registered", () => { throw new Error("boom"); }); // throws
listen("user.registered", () => trackSignup(user)); // still runs

try {
  await emit("user.registered", user);
} catch (err) {
  // every listener ran; err is the failure ("boom")
}

Failures are never swallowed. With one failed listener emit() rejects with that error; with several it rejects with an AggregateError whose .errors holds them all.

Handling failures centrally

Register an onError handler and emit() stops rejecting — each failure goes to your handler instead, with the event name and the payload that triggered it. This is how you keep a background listener's bug from taking down the request that happened to fire the event:

events().onError((event, error, payload) => {
  logger().error("listener failed", { event, error, payload });
});

Firing an event that has no listeners is a no-op — emit() resolves immediately.

Observing every event

onAny subscribes to all events — for logging, metrics, and other cross-cutting concerns. It runs before the event's own listeners and returns an unsubscribe function:

const off = events().onAny((event, payload) => {
  logger().debug(`event: ${event}`, { payload });
});

Testing

events().fake() records emissions instead of running listeners, so a test can assert an event fired without triggering its side effects — no welcome email, no queued job. It returns a buffer to assert against; restore() puts the real emitter back.

const buffer = events().fake();

await registerUser({ email: "a@b.com" });

buffer.assertEmitted("user.registered");
buffer.assertEmitted("order.paid", (o) => o.total === 4200); // with a predicate
buffer.assertEmittedCount("user.registered", 1);
buffer.assertNotEmitted("user.deleted");
buffer.assertNoneEmitted(); // nothing at all fired

events().restore();

Pass event names to fake only those — everything else dispatches for real:

const buffer = events().fake("user.registered"); // or ["a", "b"]

buffer.all() returns every recorded { event, payload } in order, and buffer.payloadsFor("order.paid") returns just that event's payloads (typed, if the event is declared in EventsList).

Notes

  • A listener is stored once per event (the backing store is a Set), so registering the exact same function twice for the same event only fires it once. Register a fresh function each time if you want it to run twice.
  • Events are in-process. For cross-process or durable events, have a listener publish to your queue/broker of choice.

API reference

Events

The emitter itself — a container singleton. You rarely construct it; resolve it with events() (or app().make(Events)). Events are keyed by string; each key holds a Set of listeners.

on(event, listener)

on<T = unknown>(event: string, listener: Listener<T>): () => void

Subscribes listener to event and returns an unsubscribe function.

const off = events().on<{ id: number }>("user.deleted", (u) => audit(u.id));
off(); // later: stop listening

Notes: the returned function removes exactly this listener. The same function reference is de-duplicated per event (Set-backed), so subscribing it twice still only registers — and fires — it once. The global listen() helper is a thin wrapper over this.

once(event, listener)

once<T = unknown>(event: string, listener: Listener<T>): () => void

Subscribes for a single emission: the listener runs on the next emit, then auto-unsubscribes. Also returns an unsubscribe function to cancel it beforehand.

events().once("boot", () => console.log("started"));

Notes: it unsubscribes itself before awaiting your callback, so a listener that re-emits the same event won't re-trigger this one. The returned unsubscribe removes the internal wrapper, so it works even if the event never fires.

off(event, listener)

off<T = unknown>(event: string, listener: Listener<T>): void

Removes listener from event. No-op if it wasn't subscribed.

events().off("tick", handler);

Notes: you must pass the same function reference you subscribed with — anonymous inline functions can't be removed this way, so keep a reference (or use the unsubscribe function on/once return). Removing the last listener leaves an empty Set behind under that key; use clear(event) to drop the key entirely.

emit(event, payload?)

emit<T = unknown>(event: string, payload?: T): Promise<void>

Fires event, awaiting every listener in registration order with the given payload. For an event declared in EventsList, the payload is required and type-checked against the registry.

await events().emit("order.paid", { id: 1, total: 4200 });

Notes: listeners run sequentially, each awaited before the next. A listener that throws or rejects does not skip the rest — they all run, and emit rejects afterwards with that error (or an AggregateError if more than one failed), unless an onError handler is registered. No listeners means an immediate resolve. Both listener sets are snapshotted up front, so subscriptions made mid-emit apply only to later emissions.

onAny(listener)

onAny(listener: AnyListener): () => void

Subscribes to every event. The listener receives (event, payload). Returns an unsubscribe function.

const off = events().onAny((event, payload) => log(event, payload));

Notes: any-listeners run before the event's own listeners, so a logger sees the event even if a listener later throws. They're subject to the same error handling as ordinary listeners.

onError(handler)

onError(handler: ErrorHandler): void

Handles listener failures instead of letting emit reject. The handler receives (event, error, payload).

events().onError((event, error) => logger().error("listener failed", { event, error }));

Notes: only one handler is active — registering again replaces it. Without one, failures surface by rejecting emit; they are never silently dropped.

fake(only?) / restore()

fake(only?: EventName | EventName[]): EventBuffer — record emissions instead of running listeners, and return a buffer to assert against. restore() undoes it.

const buffer = events().fake();
await register(user);
buffer.assertEmitted("user.registered");
events().restore();

Notes: with no argument every event is faked; pass names to fake only those, leaving the rest to dispatch for real. Each fake() returns a fresh buffer.

clearAll()

clearAll(): void

Drops every listener, every onAny listener, and the error handler. clear() only drops ordinary listeners.

listenerCount(event)

listenerCount(event: string): number

Returns how many listeners are currently subscribed to event.

if (events().listenerCount("tick") === 0) startClock();

Notes: returns 0 for an unknown event.

clear(event?)

clear(event?: string): void

Removes all listeners for event, or — with no argument — every listener for every event.

events().clear("tick"); // drop one event's listeners
events().clear();        // wipe everything (handy between tests)

Notes: unlike off, this deletes the event's key outright, so it also cleans up the empty-Set residue off can leave behind.

Global helpers

Convenience functions that resolve the active application's Events singleton — no need to thread the container around.

events()

events(): Events

Returns the application's Events instance.

import { events } from "@shaferllc/keel/core";
events().once("boot", () => {});

Notes: resolves Events from the active application container, so every call returns the same singleton. Throws No Keel application has been bootstrapped… if no Application has been created yet.

emit(event, payload?)

emit<T = unknown>(event: string, payload?: T): Promise<void>

Shorthand for events().emit(event, payload) — fire an event from anywhere.

import { emit } from "@shaferllc/keel/core";
await emit("user.registered", user);

Notes: same awaiting/error semantics as Events.emit. Requires a bootstrapped application.

listen(event, listener)

listen<T = unknown>(event: string, listener: Listener<T>): () => void

Shorthand for events().on(event, listener) — subscribe from anywhere; returns an unsubscribe function.

import { listen } from "@shaferllc/keel/core";
const off = listen<{ id: number }>("user.deleted", (u) => audit(u.id));

Notes: wraps on, not once, so the listener stays subscribed until you call the returned function. Requires a bootstrapped application.

Interfaces & types

Listener

type Listener<T = unknown> = (payload: T) => void | Promise<void>

The shape of an event handler: a function taking the payload, optionally async. Use it to type a handler you store or pass around before subscribing.

import { type Listener } from "@shaferllc/keel/core";

const onPaid: Listener<{ id: number }> = async (order) => {
  await fulfill(order.id);
};
listen("order.paid", onPaid);

Notes: async listeners are fully awaited by emit. A listener that returns a rejected promise is treated exactly like a synchronous throw — the other listeners still run, and the failure is reported afterwards.

EventsList

interface EventsList {}

The registry of declared events, keyed by name. Empty by default; augment it from your app to type an event's payload (see The event registry).

declare module "@shaferllc/keel/core" {
  interface EventsList {
    "order.paid": { id: number; total: number };
  }
}

EventBuffer

What fake() returns. Records every intercepted emission and asserts over them.

Method Signature
assertEmitted (event, predicate?) => void
assertNotEmitted (event) => void
assertEmittedCount (event, count) => void
assertNoneEmitted () => void
all () => RecordedEvent[]
payloadsFor (event) => PayloadOf<E>[]

Failed assertions throw with what actually fired.

AnyListener

type AnyListener = (event: string, payload: unknown) => void | Promise<void>

The shape of an onAny handler.

ErrorHandler

type ErrorHandler = (event: string, error: unknown, payload: unknown) => void | Promise<void>

The shape of an onError handler.

RecordedEvent

interface RecordedEvent { event: string; payload: unknown }

One emission captured by a fake.

EventName / PayloadOf

type EventName = keyof EventsList | (string & {}) — a declared event name, or any other string.

type PayloadOf<E> — the declared payload for an event, or unknown if it isn't in EventsList.