@zudojs/messaging
An in-process message bus: one part of your program sends a named message, other parts handle it and send an answer back, all without the two sides knowing about each other.
OVERVIEW
When one part of an app needs another part to do something, the simplest option is a direct function call. That works until the two parts live in different modules, or until three other parts also want to react. Then every caller has to import every listener, and the code becomes a tangle.
A message bus breaks that tangle. Senders hand a message (a small named object with data) to the bus. The bus looks up the handlers that registered for that message's type, runs them, and hands their return values back to the sender. Sender and handlers only share the message type string, such as "order.placed".
@zudojs/messaging is that bus, kept inside a single Node.js process. It has no network, no queue and no persistence. It is the generic layer; @zudojs/events and @zudojs/cqrs use the same vocabulary for more specific jobs (see Messaging vs Events).
- Modules in one process need to talk without importing each other.
- You want a request/response shape: the handler's return value matters.
- You want to add logging or timing around every message in one place.
- You are building a higher-level bus of your own and need the plumbing.
- A plain function call would do. Two modules that already import each other do not need a bus.
- You are announcing "something happened" to many listeners. Use @zudojs/events.
- Work must survive a restart or run on another machine. Use @zudojs/queue.
INSTALLATION
Install the package. It pulls in @zudojs/errors and @zudojs/constants on its own; you do not need to add them.
Requires Node.js 24 or newer and an ES-module project ("type": "module" in package.json).
QUICK START
This creates a bus, registers one handler for the type "user.created", sends a message of that type, and prints what came back.
What you should see, in order: New user: { id: 'u-1', email: 'ada@example.com' }, then true, then welcome email queued.
id when you call on() if you want to off() the handler later. Without one the bus generates handler:<type>:<n> from a per-bus counter, so generated ids never collide — but they are not stable across runs, so you cannot hard-code one to remove it.
MESSAGES
A message is a frozen object with four required fields: a unique id, a type string, a payload (any data you like) and a timestamp. The type is the address: handlers register for a type, and the bus routes by it. Dotted names like "order.placed" are the convention.
Optional fields describe where the message came from: source (which subsystem made it), correlationId and causationId (explained under Context) and free-form metadata.
This builds a message by hand and prints the fields the factory filled in for you.
Branded ids
MessageId, MessageCorrelationId and MessageCausationId are branded strings: at runtime they are ordinary strings, but TypeScript refuses to accept a plain string where one is expected. This stops you from passing a user id where a message id belongs.
createMessageId() mints a new id. When the id already exists as a string (from a database row, a log line, a network frame), wrap it with toMessageId, toCorrelationId or toCausationId. All three throw a TypeError on an empty or blank string, and createMessage throws the same on an empty type.
correlationId: "req-abc" as a bare string is a compile error. Write correlationId: toCorrelationId("req-abc").
HANDLERS
A handler is a function that receives a message and a MessageContext, does some work, and returns a value (or a promise of one). Registering a handler is the "subscribe" half of publish/subscribe: you tell the bus "call me for this type".
Several handlers may register for the same type. The bus runs them one after another, lowest priority number first (default 100). With one handler, result.value is that handler's return value. With several, it is an array of their return values in run order. That fan-out is the default; create the bus with allowMultipleHandlers: false for a command or query bus, and a second handler claiming a type already taken is refused with a MessageError naming the type and the handler that holds it.
This registers two handlers for one type, sends a message, then removes one handler. The payload type annotation on the parameter is what gives you message.payload.orderId without a cast.
Named handler objects
on() wraps your function in a NamedMessageHandler behind the scenes. Build one yourself with addHandler() when a single handler should answer several types, or when you want to ship it disabled. This fragment assumes the bus from the example above.
DuplicateMessageHandlerError immediately, unless you created the bus with allowDuplicateHandlers: true.
Object-form handlers
The handler field of a NamedMessageHandler accepts either form described by MessageHandlerLike: a plain function, or an object with a handle(message, context) method. The object form lets a class hold the handler's dependencies. this is bound for you, so a class method may use its own fields.
MessageHandlerLike had always advertised the { handle } form, but the dispatcher invoked the registered handler as a function, so every dispatch to an object handler came back as a failed dispatch with handler.handler is not a function in result.error. If you worked around this by wrapping the object yourself — handler: (m, c) => obj.handle(m, c) — that still works and needs no change.
bus.on(type, fn) takes a function only. Register an object-form handler with addHandler(), or normalise it yourself with resolveMessageHandler(handlerLike), which returns a plain bound function.
SENDING AND RESULTS
Dispatching is the "publish" half: the bus takes a message, runs middleware, runs the matching handlers, and returns a DispatchResult. Two methods do it. send(input) builds the message from plain input first. dispatch(message) takes a message you already created, for example one from createDerivedMessage.
A failing handler does not make dispatch throw. Instead the result comes back with success: false and the problem in result.error, wrapped in a MessageHandlerError that names the handler. Dispatching to a type with no handlers also succeeds; value is an empty array.
This shows both outcomes side by side.
What does throw
Only two things reject the promise instead of returning a result: using a bus after dispose() throws MessageBusDisposedError, and passing a signal that is already aborted throws MessageDispatchAbortedError. (send() with an empty type also rejects, with the TypeError from createMessage.) Wrap those calls in try/catch if they can happen in your code.
Timeouts and cancellation
Pass { timeout: 5000 } to one dispatch, or defaultTimeout to createMessageBus, and the bus starts a timer that aborts an AbortSignal. You can also pass your own signal. Handlers that have not started yet are skipped and the result fails with a bare MessageDispatchAbortedError as result.error (not wrapped in a MessageHandlerError).
context.signal.aborted between steps and stop themselves. A timed-out dispatch comes back with result.error instanceof MessageTimeoutError.
signal aborts while the last (or only) handler is running, the dispatch now settles promptly with success: false and a MessageDispatchAbortedError in result.error, even if that handler ignores the signal and later returns normally. As with a timeout, the dispatch does not wait for such a handler, and result.handlerResults lists only the handlers that finished. This applies to the bus and to a dispatcher used directly. Before v1.2.0 that case came back as success: true. Handlers should still honour the signal (for example context.signal.throwIfAborted() after each await) so the work itself stops:
setImmediate. In a browser bundle, v1.2.0 threw ReferenceError: setImmediate is not defined the moment a dispatch was aborted. The abort rejection is now scheduled with setTimeout(…, 0), which keeps the same ordering: a handler that aborts the signal and then returns still has its result recorded first. Tested with setImmediate deleted from the global scope: a handler that called controller.abort() and returned gave success: false, MessageDispatchAbortedError, and one successful entry in handlerResults. Nothing changes in Node.
result.handlerResults is a snapshot taken when the dispatch settles, so it stops changing once you have awaited the dispatch. Previously it was the live array the dispatcher was still writing into: a handler that kept running past a timeout could push a success: true record into the result of a dispatch that had already failed with MessageTimeoutError. Audit records and metrics derived from handlerResults are now stable.
MIDDLEWARE
Middleware is a function that runs around every dispatch. It receives a context and a next function; calling next() continues to the following middleware and finally to the handlers. Whatever next() resolves to becomes result.value, so you can log, time, or even replace the answer in one place.
Register bus-wide middleware with bus.use() or the middleware option of createMessageBus. Middleware for one dispatch only goes in DispatchOptions.middleware and runs after the bus-wide ones. Order is registration order.
This logs when each message starts and how long it took.
What you should see: → ping [msg:…], then ← ping in 0.2ms, then pong. The correlation id defaults to the message id because none was set.
The middleware context (MessageMiddlewareContext) exposes message, the dispatch context, the abort signal, an executionId, and a shared state Map you can use to pass values between middleware. A middleware can also be an object with a handle method.
success: false and result.error is exactly what you threw (not wrapped). Calling next() twice throws MiddlewareNextCalledMultipleTimesError (from @zudojs/errors, a MiddlewareError); the pipeline is compose from @zudojs/middleware. Middleware runs in ascending priority order (default 100), with registration order breaking ties.
CONTEXT, CORRELATION AND CAUSATION
Every handler receives a MessageContext as its second argument. It holds the message, a correlationId, a causationId, an abort signal, a state Map and startedAt.
A correlation id is a label shared by every message that belongs to one bigger operation, such as one HTTP request. A causation id is the id of the single message that directly caused this one. Together they let you trace "why did this happen?" through a chain of messages. If you set neither, both default to the message's own id.
createDerivedMessage builds a follow-up message that inherits the parent's correlation id and records the parent as its cause.
Inside a handler, read the ids from the context. This handler dispatches a follow-up on the same bus, keeping the chain intact.
DispatchOptions.context headers, state and id overrides, plus anything middleware put into context.state.
bus.send(input, { context: { correlationId, causationId } }) now writes those identifiers onto the message it builds, unless the input carries its own. message.correlationId in the handler is therefore the request id, and createDerivedMessage(message, …) continues the chain instead of starting a new one. Before v1.2.0 the context ids applied to that one dispatch only and never reached the message.
MESSAGING VS EVENTS
Both packages route named objects to registered functions inside one process, and neither depends on the other. The difference is intent. A message here is a request that expects an answer: the sender awaits result.value. An event in @zudojs/events is a fact that already happened; the publisher does not want a return value, only that listeners are told.
| Question | @zudojs/messaging | @zudojs/events |
|---|---|---|
| Unit | Message with a type | Event with a type |
| Sending | bus.send() / bus.dispatch() | bus.publish() / bus.emit() |
| Return value | Handler results come back in result.value | Handlers return nothing useful; you get an emit result |
| Listening | on(), addHandler() | on(), once(), onAny() |
| When one listener fails | Dispatch stops; success: false | Configurable: stop, or continue and report |
| Lifecycle | dispose() only | start() / stop(), event registration |
Rule of thumb: "please do X and tell me the outcome" is a message; "X happened" is an event. @zudojs/cqrs goes one step further and splits messages into commands (change something) and queries (read something).
LOWER-LEVEL PIECES
createMessageBus is enough for almost everything. The parts it is built from are exported too, for people writing their own bus on top of this package:
- →
HandlerRegistryStorekeeps the handlers.register(),unregister(),resolve(type)(sorted by priority, disabled handlers left out),get(),has(),getHandlerIds(),getRegisteredTypes(),size,clear(). - →
createDispatcher(registry?)returns aDispatcher(classDefaultDispatcher) that runs middleware and handlers for one message. The bus adds timeouts,on()/off()and the disposed flag on top. The interface isdispatch(),use(),removeMiddleware(),listMiddleware(),getRegistry()anddispose(). - →
runMessagePipeline(middleware, handler, message, options?)runs a middleware chain around any async function and returns{ result, executions, duration }. - →
createMessageContext(message, options?)andresolveMessageHandler(handlerLike)are the small helpers the dispatcher uses internally.
This wires a registry and dispatcher by hand, which is exactly what createMessageBus does for you.
Inspecting and releasing a dispatcher
Three of the dispatcher's methods are about what it holds rather than what it runs.
| Method | What it returns | Notes |
|---|---|---|
listMiddleware() | The ids of every registered global middleware, in the order they run. | Ascending priority (default 100), registration order breaking ties. These are the ids use() returned. |
getRegistry() | The HandlerRegistryStore this dispatcher resolves handlers from. | The registry you passed to createDispatcher, or the one it made for you. |
dispose() | Nothing. | Drops all middleware and makes every later dispatch() reject with MessageBusDisposedError. Handlers stay in the registry. |
dispose(), getRegistry() and listMiddleware() are now declared on the Dispatcher interface. DefaultDispatcher already implemented all three, but because the interface omitted them, createDispatcher().dispose() did not compile without a cast to DefaultDispatcher. Any such cast can now be dropped.
API REFERENCE
Everything below is exported from @zudojs/messaging.
Functions
| Name | What it does | Notes |
|---|---|---|
createMessageBus(options?) | Creates an in-memory MessageBus. | Options: allowDuplicateHandlers, allowMultipleHandlers, middleware, defaultTimeout. |
createMessage(input) | Builds a frozen Message; fills id and timestamp. | Throws TypeError on empty type. |
createDerivedMessage(parent, input) | Builds a follow-up message that inherits correlation and records causation. | Explicit ids in input win. |
createMessageId() | Mints a new random MessageId. | Format msg:<uuid>. |
toMessageId(s), toCorrelationId(s), toCausationId(s) | Brand an existing string as an id. | Throw TypeError on blank input. |
isMessage(value) | Type guard: does this look like a Message? | Checks id, type, timestamp, payload. |
getMessageType(m), getMessagePayload(m), describeMessage(m) | Small accessors. | describeMessage returns "type (id)". |
createMessageContext(message, options?) | Builds a MessageContext. | Ids fall back to the message id. |
createDispatcher(registry?) | Creates a Dispatcher. | Makes its own registry if none given. |
runMessagePipeline(middleware, handler, message, options?) | Runs a middleware chain around a function. | Returns { result, executions, duration }. |
resolveMessageHandler(h) | Turns a function or { handle } object into a function. | |
isMessageError(e), toMessageError(e), createMessageError(), createMessageHandlerError() | Error helpers re-exported from @zudojs/errors. | See that package's page. |
Classes
| Name | What it does | Notes |
|---|---|---|
InMemoryMessageBus | The bus createMessageBus returns. | Methods: send, dispatch, on, addHandler, off, use, hasHandlers, dispose; getters handlerCount, disposed. |
HandlerRegistryStore | In-memory store of named handlers. | See Lower-level pieces. |
DefaultDispatcher | Runs middleware and handlers for one message. | Implements the whole Dispatcher interface: dispatch(), use(), removeMiddleware(), listMiddleware(), getRegistry(), dispose(). |
Types
| Name | What it does | Notes |
|---|---|---|
Message<TPayload>, MessageInput<TPayload> | A message, and the input createMessage accepts. | Input may omit id and timestamp. |
MessageId, MessageCorrelationId, MessageCausationId | Branded id strings. | Build with the to… helpers. |
MessageHandler, NamedMessageHandler, MessageHandlerLike | Handler function; handler with id, name, types, priority; either form. | HandlerResult and MessageHandlerFactory are also exported. |
MessageContext, MessageContextOptions | What handlers receive as their second argument. | |
MessageMiddleware, MessageMiddlewareLike, MessageMiddlewareContext, MessageMiddlewareNext | Middleware function shape and its context. | Pipeline result types: MessageMiddlewarePipelineResult, MessageMiddlewareExecution. |
MessageBus, MessageBusOptions | The bus interface and its options. | |
Dispatcher, DispatchOptions, DispatchResult, HandlerExecutionResult | Dispatch interface, per-call options, and the result shape. | Dispatcher: dispatch, use, removeMiddleware, listMiddleware, getRegistry, dispose. DispatchOptions: context, middleware, timeout, signal. |
HandlerRegistryOptions, RegisteredHandler, HandlerQueryOptions | Registry configuration and lookup types. |
Errors
All error classes live in @zudojs/errors and are re-exported here. Only the first five in the table are raised by this package, plus the base MessageError when a bus created with allowMultipleHandlers: false refuses a second handler. The rest are exported so your own code and other packages can share one hierarchy.
| Name | What it does | Notes |
|---|---|---|
MessageHandlerError | A handler threw. | Appears in result.error; has handlerId, cause. |
DuplicateMessageHandlerError | Handler id already registered. | Thrown by on() / addHandler(). |
MessageBusDisposedError | Bus used after dispose(). | Rejects the dispatch promise. |
MessageDispatchAbortedError | Signal aborted before or between handlers. | Rejects if aborted up front; otherwise returned as result.error, including an abort while the last handler runs (since v1.2.0). |
MessageTimeoutError | A dispatch ran past its timeout or defaultTimeout. | Returned as result.error, even when the last handler finishes later. |
MessageError, MessageDispatchError, InvalidMessageError, MessageTypeNotFoundError, MessageHandlerNotFoundError, MessageMiddlewareError, MessageValidationError | Shared hierarchy for messaging errors. | Not thrown by this package itself, apart from MessageError as described above. |
COMMON MISTAKES
-
Registering handlers without an
idand then trying to remove them → generated ids come from a per-bus counter (handler:<type>:<n>), so they never collide, but they depend on registration order and are not something you can hard-code. → Pass anidin the options ofon()for any handler you intend tooff(). -
Checking for a thrown error instead of
result.success→ a failing handler never throws fromsend(), so yourcatchblock stays silent and the failure is missed. → Readresult.successandresult.errorafter every dispatch. -
Passing a plain string as
correlationId→ TypeScript rejects it because the type is branded. → Wrap it:toCorrelationId("req-abc"). -
Expecting a timeout to stop a running handler → the timer only aborts a signal; the handler keeps going; the dispatch result carries a
MessageTimeoutError. → Checkcontext.signal.abortedinside long handlers, or callcontext.signal.throwIfAborted()after eachawait. -
Expecting
result.valueto always be a single value → with two or more handlers it is an array in priority order, and with none it is[]. → Checkresult.handlerResults.lengthwhen the handler count can vary. -
Using a bus after
dispose()→ everysend/dispatchrejects withMessageBusDisposedErrorand all handlers are gone. → Dispose once, at shutdown, and checkbus.disposedif unsure.
COMPLETE EXPORT INDEX
Every name @zudojs/messaging exports from its package root at v1.1.0 — 69 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 69 exports
DefaultDispatcher DuplicateMessageHandlerError HandlerRegistryStore InMemoryMessageBus InvalidMessageError MessageBusDisposedError MessageDispatchAbortedError MessageDispatchError MessageError MessageHandlerError MessageHandlerNotFoundError MessageMiddlewareError MessageTimeoutError MessageTypeNotFoundError MessageValidationErrorcreateDerivedMessage createDispatcher createMessage createMessageBus createMessageContext createMessageError createMessageHandlerError createMessageId describeMessage getMessagePayload getMessageType isMessage isMessageError resolveMessageHandler runMessagePipeline toCausationId toCorrelationId toMessageError toMessageIdDispatcher DispatchOptions DispatchResult HandlerExecutionResult HandlerQueryOptions HandlerRegistryOptions HandlerResult Message MessageBus MessageBusOptions MessageContext MessageContextOptions MessageInput MessageMiddlewareContext MessageMiddlewareExecution MessageMiddlewareObject MessageMiddlewareOptions MessageMiddlewarePipelineOptions MessageMiddlewarePipelineResult NamedMessageHandler RegisteredHandler RegisteredMessageMiddlewareMessageCausationId MessageCorrelationId MessageHandler MessageHandlerFactory MessageHandlerLike MessageId MessageMiddleware MessageMiddlewareLike MessageMiddlewareNext MessagePayload MessageSource MessageTimestamp MessageType