@zudojs/events
An event bus for Zudojs: publish a message once, and every handler that asked for it gets called, with optional middleware in between.
OVERVIEW
When something happens in your program, other parts of it often need to react. A user signs up, so you send a welcome email, write an audit log line, and update a counter. Without help, the sign-up code has to know about all three, and every new reaction means editing it again.
@zudojs/events separates the two sides. The sign-up code publishes an event, a small object that says "user.created happened, here are the details". Any number of handlers (plain functions) can ask to be called when that kind of event appears. The publisher never learns who is listening.
The object that connects the two is the event bus. Think of it as a notice board: one person pins a notice, and everyone who cares about that topic reads it.
- → Several parts of the app must react to the same thing.
- → You want to add reactions later without editing the code that triggers them.
- → You need logging, timing or validation applied to every event in one place.
- → One function calls one other function. A direct call is simpler.
- → You need a reply value. Events are one-way; use @zudojs/api operations instead.
- → The message must reach another process or server. This bus is in-memory; see @zudojs/messaging.
INSTALLATION
Install the package. It pulls in @zudojs/errors, @zudojs/constants and @zudojs/middleware on its own.
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
The package is ES modules only and needs Node 24 or newer. Every example below is a complete file you can save and run.
QUICK START
This creates a bus, attaches one handler, publishes one event, and prints what happened.
What you should see:
publishEvent() returns a promise, so you await it. It resolves after every handler has finished, and the result tells you how many ran and whether any succeeded.
EVENTS
An event is a frozen object that records one thing that happened. It always has a type (a name like "user.created"), a payload (the details, any value you like), an id, and a timestamp. Frozen means nobody can change it after it is created, so every handler sees the same data.
You build one with createEvent(). Only type and payload are required; the id and timestamp are filled in for you.
Event type names
The type is normalized before use: trimmed, lower-cased, and slashes or colons turned into dots. That is why "Order.Placed" printed as order.placed. Names may contain lowercase letters, digits, underscores, dashes and dots. Anything else makes createEvent() throw InvalidEventError.
Use the dots to build namespaces. user.created, user.deleted and user.profile.updated all live under user, and handlers can subscribe to the whole namespace with a pattern. Three pattern forms exist:
| Pattern | Matches |
|---|---|
| user.created | Exactly that type. |
| user.* | user, user.created, user.profile.updated, anything under the namespace. |
| * | Every event. |
A wildcard anywhere else (user.*.created) is rejected. The functions matchesEventType(type, pattern) and normalizeEventType(type) are exported if you want to test names yourself.
Typed events with defineEvent()
Writing { type: "user.created", payload } objects by hand in many places invites typos. defineEvent() gives you a reusable definition: an object that knows the type name and the payload shape, with a create() method that builds events of that kind.
The first type argument is the event name, the second is the payload type. Pass the definition to bus.register() if you want the bus to know about it (see Event registry).
createEvent() freezes the event object itself. The payload is frozen later, by the bus, just before handlers run — but handlers receive a frozen copy (createFrozenEventSnapshot), so the objects you published are never frozen and you do not need to clone a payload before publishing it. Inside the copy, Map, Set and Date values (including event.timestamp) become read-only variants that throw on mutation; class instances are passed by reference.
HANDLERS
A handler is the function that reacts to an event. The bus calls it with two arguments: the event, and a context object with extras such as an AbortSignal and any metadata the publisher attached. A handler may be synchronous or return a promise; the bus waits for it either way.
You attach a handler with bus.on(pattern, handler, options). It returns a subscription, a small object whose unsubscribe() method detaches the handler again.
What you should see:
The type argument Event<OrderPayload> tells TypeScript what event.payload looks like. Without it the payload is unknown and you must narrow it yourself.
Handler options
| Option | What it does | Default |
|---|---|---|
| id | Name for the handler. Must be unique on the bus; a duplicate throws DuplicateEventHandlerError. | generated |
| priority | Higher numbers run first. Equal priorities keep registration order. | 0 |
| once | Remove the handler before its first run, so it never runs twice. bus.once() sets this for you. | false |
| timeoutMs | If the handler takes longer than this, its run fails with EventTimeoutError and the context.signal that handler received is aborted, with the EventTimeoutError as its reason. Other handlers and the dispatch are not aborted. | no timeout |
| enabled | A disabled handler stays registered but is skipped. | true |
| description | Free text, useful when listing handlers. | none |
bus.onAny(handler) is shorthand for bus.on("*", handler). bus.off(subscription) is the same as subscription.unsubscribe() and returns true if the subscription was still active. To detach several handlers together, put their subscriptions in a createEventSubscriptionGroup() and call unsubscribe() on the group.
Stopping work at the deadline
context.signal is aborted in two cases: the signal you passed to the publish call is aborted, or this handler's timeoutMs runs out. Hand the signal to the slow work the handler starts (fetch, a timer, a database driver) and that work stops at the deadline.
What you should see, after about 100 ms instead of 5 seconds:
Before 1.3.0 a timeout only stopped the bus from waiting: context.signal was never aborted, so the handler's work carried on in the background. A handler that ignores the signal still does that today, because JavaScript cannot stop a function from outside.
Changing event.payload inside a handler. Handlers receive a deeply frozen copy of the event, so an assignment like event.payload.total = 0 throws TypeError: Cannot assign to read only property in ES modules, and Map/Set/Date values (including event.timestamp) throw on mutation. The publisher's own objects are never frozen. Copy the data you need instead.
EVENT BUS
The event bus (EventBus) is the object your application talks to. It holds the handlers, runs middleware, dispatches events, and has a lifecycle so you can pause or shut it down cleanly. You create one with createEventBus(options).
Three ways to publish
- →
bus.publishEvent({ type, payload })builds the event from plain input, then publishes it. - →
bus.publish(event)publishes an event you already built withcreateEvent()or a definition'screate(). - →
bus.emit(eventOrInput)accepts either and picks the right one.
Reading the result
By default the bus keeps going when a handler throws, and reports the failure in the result instead. This example has one good handler and one that fails.
| Result field | Meaning |
|---|---|
| event | The event that was dispatched. |
| handled | true when at least one handler finished without throwing. |
| handlerCount · succeeded · failed | How many handlers ran, and how they ended. |
| results | Return values of the handlers, in run order. |
| errors | One EventHandlerError per failed handler, typed readonly EventHandlerError[] (it was unknown[] before 1.3.0). handlerId says which; cause is what it threw. |
| shortCircuited | true when a middleware stopped the event before any handler ran. |
Error mode and error hook
The collect-and-continue behaviour is EventErrorMode.CONTINUE. If you would rather have the publish call reject on the first failing handler, use EventErrorMode.THROW, either for the whole bus or for one publish call.
Lifecycle
A bus moves through four states: CREATED, ACTIVE, STOPPED, DISPOSED. You rarely have to manage this. A new bus starts itself the first time you call on() or publish. createStartedEventBus() gives you one that is already active.
bus.subscribe(listener) lets you watch the bus itself: the listener receives { type: "started" | "stopped" | "published", event?, timestamp }. A listener that throws used to be swallowed without trace; since 1.2.0 the failure goes to your onError hook if you set one, and otherwise surfaces once per bus through process.emitWarning as a ZudojsEventsWarning with code ZUDOJS_EVENTS_OBSERVER_ERROR. The same applies to registry.subscribe.
Call bus.stop() then bus.dispose() when your process shuts down. Stop refuses new work; dispose cancels every subscription so nothing leaks.
EVENT REGISTRY
The registry (EventRegistry) is the bus's storage. It keeps two lists: the handlers you attached, and any event definitions you registered. Registering a definition is optional. It becomes useful with requireRegistration: true, which makes the bus refuse any event type it has not been told about, catching typos at publish time.
Registering the same type twice throws DuplicateEventDefinitionError unless you pass registry: { allowDuplicateDefinitions: true }. bus.unregister(type) removes a definition; add { removeHandlers: true } to drop the handlers subscribed to exactly that type as well.
Handler limits
The registry counts how many handlers each pattern has collected, because a pattern that keeps growing is almost always a subscribe-without-unsubscribe leak. The ceiling is maxHandlersPerPattern on a registry, spelled maxListeners under emitter on a bus or an emitter. It defaults to 100; 0 turns the check off.
What happens when the ceiling is passed is now yours to choose.
- → Warn (the default). The registration succeeds and one warning is emitted per pattern — the first breach only. It goes to your
onWarninghook if you set one, otherwise throughprocess.emitWarningas aZudojsEventsWarningwith codeZUDOJS_EVENTS_HANDLER_LIMIT. - → Refuse. Set
enforceHandlerLimit: true(defaultfalse, added in 1.2.0) and the registration is rejected instead: the call throwsEventListenerLimitExceededErrorand the handler is rolled back out of the registry, which is left exactly as it was. Unlike the warning it fires on every breach, not only the first, since each one is a separate fault.
The option is accepted in all three places: createEventRegistry({ enforceHandlerLimit }), createEventEmitter({ enforceHandlerLimit }), and createEventBus({ emitter: { enforceHandlerLimit } }).
EventListenerLimitExceededError is defined in @zudojs/errors (code EVENT_LISTENER_LIMIT_EXCEEDED) and re-exported from @zudojs/events, so either import works. Before 1.2.0 nothing in the package ever raised it; a catch branch testing for it was unreachable.
Turning on requireRegistration and forgetting to call bus.register(). Every publish then rejects with EventTypeNotFoundError, including the ones from other packages that publish through your bus.
EVENT EMITTER
The emitter (EventEmitter) is the engine inside the bus. It does one job: given an event, find the matching handlers and run them. The bus adds middleware, the registry, lifecycle states and the onError hook on top. If you need none of those, an emitter alone is lighter.
The emitter also decides how handlers run. EventEmitterMode.SEQUENTIAL (the default) runs them one after another in priority order. EventEmitterMode.PARALLEL starts them all at once and waits for the slowest. The same option is accepted by the bus under emitter: { mode } and per publish call.
What you should see (the two handlers overlap, so the total is about 300 ms, not 400):
emitter.emit() requires a real event object; use emitter.emitEvent({ type, payload }) for plain input. The result is an EventEmitResult: like the bus result, but results holds one { handlerId, ok, result, duration, error? } record per handler instead of bare return values.
The two defaults differ. A bare emitter uses EventErrorMode.THROW, so a failing handler rejects emit(). A bus uses CONTINUE. Pass errorMode explicitly if the difference matters to you.
MIDDLEWARE
Middleware is a function that wraps the dispatch of every published event. It receives a context (holding the event) and a next function. Whatever it does before calling next() happens before the handlers; whatever it does after happens after them. It is the place for logging, timing, validation, and anything else that should apply to all events without repeating it in each handler.
Middleware only exists on the bus, not on a bare emitter. You can add it in three places: bus options, bus.use(), or the options of one publish call. bus.use() accepts everything the middleware option does: a plain function, a { handle } object, or a built-in helper's result.
What you should see:
Middleware runs in descending priority order, so the validator (100) wraps everything else. The timing middleware reports after its inner work finishes, which is why its line comes last.
Built-in helpers
| Function | What it does |
|---|---|
| beforeEvent(fn) | Run fn(context) before the handlers, then continue. |
| afterEvent(fn) | Run the handlers, then fn(context, result). |
| aroundEvent(fn) | Same as writing the middleware by hand; fn(context, next). |
| validateEventMiddleware(check) | If check(event) returns false, reject the publish with EventMiddlewareError. |
| timingEventMiddleware(fn) | Call fn(durationMs, context) when dispatch finishes, even if it failed. |
| stateEventMiddleware(key, factory) | Store factory(context) in context.state, a Map shared by the whole pipeline. |
| createEventMiddleware(fn, options) | Wrap any middleware with an id, priority and enabled flag. |
Every helper returns a registered middleware record (RegisteredEventMiddleware) with its id, priority and enabled flag. Pass it to bus.use() to add it after the bus exists:
Before 1.3.0 bus.use(validateEventMiddleware(check)) threw TypeError: Invalid event middleware.; only the middleware options accepted helper records. Both now accept the same things.
If a middleware returns without calling next(), nothing further runs and the publish result has shortCircuited: true and handled: false. If a middleware throws, the publish rejects with EventMiddlewareError. Handler failures are never relabelled as middleware errors.
Forgetting to return the value of next(). The middleware still runs the handlers, but the bus can no longer see their result and reports the publish as short-circuited.
API REFERENCE
Everything below is importable from "@zudojs/events". Only the exports you call or configure are listed.
Functions
| Name | What it does | Notes |
|---|---|---|
| createEventBus(options?) | Creates a bus in CREATED state. | Starts itself on first use. |
| createStartedEventBus(options?) | Creates a bus and calls start(). | |
| createEventEmitter(options?) | Creates a standalone emitter. | Default errorMode is THROW. |
| createEventRegistry(options?) | Creates a standalone registry. | Rarely needed; the bus owns one. |
| createEvent(input) | Builds a frozen event from { type, payload, ... }. | Throws InvalidEventError on a bad type, id or timestamp. |
| defineEvent<T, P>(type) | Returns a typed definition with create(payload, options?). | Pass to bus.register(). |
| createDerivedEvent(source, input) | Builds a follow-up event that keeps the source's correlationId and sets causationId. | For "this happened because of that" chains. |
| normalizeEventType(type) | Trims, lower-cases and validates a type name. | tryNormalizeEventType returns undefined instead of throwing. |
| matchesEventType(type, pattern) | Tests a type against an exact name, ns.* or *. | Expects normalized input. |
| createEventSubscriptionGroup() | Collects subscriptions to cancel together. | group.add(sub), group.unsubscribe(). |
| beforeEvent · afterEvent · aroundEvent · validateEventMiddleware · timingEventMiddleware · stateEventMiddleware · createEventMiddleware | Middleware builders. | Return a RegisteredEventMiddleware. Accepted by bus.use() and by the middleware option of the bus and of a publish call. See Middleware. |
Classes
| Name | What it does | Notes |
|---|---|---|
| EventBus | Methods: on, once, onAny, off, use, publish, publishEvent, emit, register, unregister, hasEvent, start, stop, dispose, getState, subscribe; properties handlerCount, eventCount. | Prefer the factory functions over new EventBus(). |
| EventEmitter | Methods: on, once, onAny, off, emit, emitEvent, removeAllListeners, dispose; property listenerCount. | No middleware, no lifecycle. |
| EventRegistry | Methods: register, get, has, registerHandler, getHandlers, getHandlersForEvent, clear, dispose. | Reach it with bus.getRegistry(). |
Types
| Name | What it does | Notes |
|---|---|---|
| Event<TPayload> | id, type, payload, timestamp, plus optional source, correlationId, causationId, metadata. | All fields read-only. |
| EventInput<TPayload> | What createEvent / publishEvent accept. Only type and payload are required. | timestamp may be a Date or a number. |
| EventTypePattern | A type name, ns.*, or *. | First argument of on(). |
| EventHandler<TEvent> | (event, context) => unknown | Promise<unknown>. | An object with a handle() method also works. |
| EventHandlerContext | event, type, eventId, correlationId?, causationId?, signal, metadata. | Second argument of every handler. |
| EventHandlerOptions | id, priority, once, timeoutMs, enabled, description. | Third argument of on(). |
| EventSubscription | id, active, state, unsubscribe(). | Returned by on(). |
| EventBusOptions | emitter: { mode, errorMode, freezeEvents, maxListeners, enforceHandlerLimit }, registry: { allowDuplicateDefinitions, onDuplicateHandlerId }, requireRegistration, middleware, onWarning, onError. | maxListeners defaults to 100 per pattern; more emits a one-shot leak warning (process warning or onWarning). enforceHandlerLimit defaults to false; set it to refuse the registration with EventListenerLimitExceededError instead. See Handler limits. |
| PublishOptions | mode, errorMode, signal, metadata, middleware. | Second argument of publish / publishEvent / emit. |
| EventPublishResult | See Reading the result. | |
| EventMiddleware | (context, next) => Promise<unknown>. | context.event, context.signal, context.metadata, context.state. |
Enums and constants
| Name | What it does | Notes |
|---|---|---|
| EventEmitterMode | SEQUENTIAL (default) or PARALLEL. | |
| EventErrorMode | THROW or CONTINUE. | Bus default CONTINUE; emitter default THROW. |
| EventBusState | CREATED, ACTIVE, STOPPED, DISPOSED. | Returned by bus.getState(). |
Errors
All extend EventError from @zudojs/errors and carry eventType and eventId where known.
| Name | When you see it | Notes |
|---|---|---|
| InvalidEventError | Bad type name, id or timestamp; or a non-event passed to publish(). | |
| EventHandlerError | A handler threw. | handlerId, cause. |
| EventTimeoutError | A handler exceeded its timeoutMs. | Arrives wrapped in an EventHandlerError. Also the reason of the aborted context.signal that handler received. |
| EventMiddlewareError | A middleware threw, or validation failed. | middlewareId. |
| EventDispatchAbortedError | The signal you passed was aborted. | results and errors gathered so far. Sequential mode: raised when the signal is aborted before a handler starts or while any handler runs, including the last or only one (since 1.3.0; it used to resolve normally). Parallel mode: raised only when the signal was aborted before dispatch began. |
| EventTypeNotFoundError | Publishing an unregistered type with requireRegistration: true. | |
| DuplicateEventHandlerError · DuplicateEventDefinitionError | Reusing a handler id or registering a type twice. | |
| EventListenerLimitExceededError | Registering past maxHandlersPerPattern / maxListeners with enforceHandlerLimit: true. | pattern, count, limit. Never raised under the default warn-only behaviour. |
| EventBusStoppedError · EventBusDisposedError · EventEmitterDisposedError | Using a bus or emitter after stop() / dispose(). | Since 1.3.0 the two bus errors are defined in @zudojs/errors and re-exported, so instanceof works whichever package you import them from. EventBusDisposedError's code is ERR_EVENT_BUS_DISPOSED (was ERR_LIFECYCLE_DISPOSED). |
COMMON MISTAKES
-
Not awaiting
publish()Handlers run asynchronously, so the line after
bus.publishEvent(...)executes before they do, and a rejection becomes an unhandled promise. Alwaysawaitthe call, or pass anonErroroption and add.catch()if you truly want fire-and-forget. -
Assuming a failed handler throws
On a bus the default is
CONTINUE: the publish resolves normally and the failure sits inresult.errors. Checkresult.handledorresult.failed, or switch toEventErrorMode.THROW. -
Subscribing with a pattern that does not match
bus.on("user", ...)matches only the typeuser, notuser.created. The result showshandlerCount: 0and nothing runs. Use"user.*"for the namespace. -
Creating a new bus in every module
Two buses do not share handlers, so an event published on one never reaches handlers on the other. Create the bus once and pass it around, or register it in your container.
-
Adding handlers in a loop without removing them
Each
on()call adds another handler. After 100 on the same pattern the bus emits one leak warning throughprocess.emitWarning(typeZudojsEventsWarning), or passes it toonWarningif you set one; the handler is still registered. Keep the subscription and callunsubscribe()when the owner goes away. If you would rather find out loudly, passemitter: { enforceHandlerLimit: true }and the 101ston()throwsEventListenerLimitExceededErrorinstead of registering.
COMPLETE EXPORT INDEX
Every name @zudojs/events exports from its package root at v1.2.0 — 207 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 207 exports
DuplicateEventDefinitionError DuplicateEventHandlerError EventBus EventBusDisposedError EventBusStoppedError EventDefinitionNotFoundError EventDeserializationError EventDispatchAbortedError EventEmitter EventEmitterDisposedError EventError EventHandlerError EventHandlerNotFoundError EventListenerLimitExceededError EventMiddlewareError EventPublishError EventRegistry EventRegistryDisposedError EventSerializationError EventSubscriptionClosedError EventSubscriptionGroup EventSubscriptionHandle EventTimeoutError EventTypeNotFoundError FrozenEventDate FrozenEventMap FrozenEventSet InvalidEventErrorabortableEventMiddleware afterEvent aroundEvent assertEventType beforeEvent cloneEventPayload createAbortError createDerivedEvent createEvent createEventBus createEventEmitter createEventError createEventHandler createEventHandlerContext createEventHandlerError createEventHandlerId createEventId createEventMiddleware createEventMiddlewareContext createEventMiddlewareId createEventPayload createEventRegistry createEventSubscription createEventSubscriptionGroup createEventSubscriptionId createEventType createEventTypePattern createFrozenEventSnapshot createJsonEventPayload createObjectEventPayload createStartedEventBus deepFreeze defineEvent defineEventType defineEventTypes definePayloadFactory describeEvent describeEventPayload disableEventHandler disableEventMiddleware emitParallel emitSequential enableEventHandler enableEventMiddleware eventMatchesType executeEventHandler executeEventMiddleware executeEventMiddlewarePipeline executeRegisteredEventHandler filterEventsByType fireAndForgetHandler getAllDefinitions getAllHandlers getEventAction getEventNamespace getEventPayload getEventType getEventTypeSegments getHandlersForEvent getHandlersForType getMatchingEventHandlers handlerMatchesEvent isAbortError isChildEventType isEvent isEventEmitResult isEventError isEventHandler isEventMiddleware isEventSubscription isFunctionEventHandler isFunctionEventMiddleware isJsonEventPayload isObjectEventHandler isObjectEventMiddleware isObjectEventPayload isPrimitiveEventPayload isRegisteredEventMiddleware isSameEventNamespace isValidEventType isValidEventTypePattern matchesEventType mergeEventPayloads normalizeEventType normalizeEventTypePattern normalizeRegistryEventType onceEventHandler prioritizedEventHandler registryClear registryDispose registryNotify registryRegister registryRegisterHandler registryUnregister registryUnregisterHandler setEventHandlerPriority sortEventHandlers sortEventMiddleware stateEventMiddleware staticPayload stripUndefinedValues timingEventMiddleware toEventError tryNormalizeEventType typedEventHandler validateEventMiddleware validateEventPayload withEventMetadataDispatchHooks EmitOptions EmitterListener Event EventBusErrorContext EventBusEvent EventBusOptions EventDefinition EventDispatchAbortedErrorOptions EventEmitResult EventEmitterOptions EventHandlerContext EventHandlerEntry EventHandlerExecutionResult EventHandlerObject EventHandlerOptions EventHandlerStore EventInput EventMiddlewareContext EventMiddlewareExecution EventMiddlewareObject EventMiddlewareOptions EventMiddlewarePipelineOptions EventMiddlewarePipelineResult EventPayloadOptions EventPublishResult EventRegistryChange EventRegistryErrorContext EventRegistryOptions EventRegistryWarning EventSubscription EventSubscriptionOptions PublishOptions RegisteredEventDefinition RegisteredEventHandler RegisteredEventMiddlewareDuplicateHandlerIdPolicy EventBusListener EventBusMiddlewareItem EventCausationId EventCorrelationId EventHandler EventHandlerLike EventHandlerResult EventId EventMiddleware EventMiddlewareLike EventMiddlewareNext EventPayload EventPayloadFactory EventPayloadMap EventRegistryListener EventSource EventSubscriptionId EventTimestamp EventType EventTypeList EventTypeOf EventTypePattern EventUnion JsonEventPayload ObjectEventPayload PayloadMap PayloadOf PrimitiveEventPayloadDEFAULT_MAX_HANDLERS_PER_PATTERNEventBusState EventEmitterMode EventErrorMode EventRegistryChangeType EventSubscriptionState