Repository navigation
Incoming and outgoing pipeline behaviours (middleware) #263
Description
Today the only extension points are a read-side middleware chain over TransportMessage (withMessageReadMiddleware) and eight synchronous emitters on BusInstance (beforeSend, beforePublish, afterSend, afterPublish, afterReceive, beforeDispatch, afterDispatch, onError). Nothing wraps an outgoing send or publish, or a single handler. Transports build their native headers only from MessageAttributes: bus-rabbitmq writes attributes/stickyAttributes as JSON headers, and bus-sqs writes attributes.<key>/stickyAttributes.<key>/correlationId SNS attributes. So #202 (raw RabbitMQ headers such as x-delay) can't be done.
Problems found on master:
- A read middleware that doesn't call
next()leaves the message neither deleted nor returned. It stays in flight in memory, unacked on RabbitMQ, and SQS redelivers it after the visibility timeout. beforeDispatchis emitted afterhandlers.map(dispatchMessageToHandler), so function handlers have already started when it fires.MiddlewareDispatcherandTypedEmitterare exported from the package root, andBusInstance.dispatchMessageToHandleris public by accident.
#258, #264 and #151 build on this, and #260/#261 hook into it.
Goal
One small, typed, async (context, next) middleware model with three stages: incoming per message, per handler, and outgoing. It replaces read middleware and the emitters, and lets outgoing middleware set transport-native headers.
Decisions
Answered by the maintainer:
- Name: "middleware" (matches the existing
Middlewaretype,/guide/middlewareand the Koa idiom). - Registration: a single
withMiddleware(...middleware: BusMiddleware[]), whereBusMiddleware = { incoming?, handler?, outgoing? }. - Emitters: remove all eight and
withMessageReadMiddleware, with no shims. - Per-handler stage: ships in this issue.
- Native headers: a generic
headersbag on the outgoing context, set from middleware only; per-call headers wait for Delayed / deferred message delivery #258's send options. Incoming native headers are read fromtransportMessage.raw.
Decided from the principles and the dependent issues:
- Outgoing timing: the outgoing chain runs when
send()/publish()is called, after correlation and sticky attributes are merged and before the outbox. Its terminal step buffers inside a handler and calls the transport otherwise. The outbox flush calls the transport with the finished message and doesn't re-run middleware. This lets a durable outbox (Durable transactional outbox #260) store the final message, lets OTel inject context while the sender's span is active, and lets validation fail the handler that sent. - Order: registration order within each stage, with the first registered outermost. There are no priorities or named slots.
- Short-circuit: not calling
next()means handled. Incoming: handlers are skipped and the message is deleted (fixes the stuck message). Handler: that handler is skipped and counts as succeeded. Outgoing: the message is dropped (debug log).next()can be called at most once; a second call throwsMiddlewareNextCalledTwice(names the stage). - Errors: a throw from an incoming middleware, or an error it lets through from
next(), returns the message for retry as a handler failure does today. Catching without rethrowing marks the message handled. A throw from a handler middleware fails that handler only (its outbox is discarded, siblings unaffected, the message is retried viaHandlerDispatchRejected). A throw from an outgoing middleware rejects thatsend()/publish(), and nothing is buffered. - Incoming is read-only: message and attributes stay frozen. Middleware passes derived data through its own
AsyncLocalStorageor sticky attributes. - Per-bus: the chains live in
BusConfigurationand are passed toBusInstance. Nothing is module-level. A stateful plugin is a factory called once per bus.
Non-goals
- Core
messageId,sentAtandTransport.endpointName: these are in the messageId issue, which runs in parallel with this one. - Normalised incoming native headers (use
transportMessage.raw). - Per-call
headersonsend()/publish()(left to Delayed / deferred message delivery #258's send options). - Middleware filtered by message type, and middleware that replaces the message object.
- Attempt count or failure metadata on the incoming context (Configurable recoverability policy and error queue with failure metadata #262).
- Changing
failMessagesemantics: the outbox still flushes and the delete still runs after it; on RabbitMQ,failMessagefollowed by a throw both dead-letters and retries (Configurable recoverability policy and error queue with failure metadata #262). - Test fakes for middleware contexts (Handler and workflow test helpers #265).
- A bus-test conformance case for native headers (each transport tests its own).
Approach
| Area | Approach |
|---|---|
Types (bus-core/src/middleware/) |
type Next = () => Promise<void>, type Middleware<TContext> = (context: TContext, next: Next) => Promise<void> (a Promise return so a sync next() call that isn't returned doesn't compile). interface IncomingContext extends HandlerContext { readonly message; readonly attributes; readonly transportMessage: TransportMessage<unknown> }. interface HandlerInvocationContext extends IncomingContext { readonly handlerName: string }: the class name, the workflow's $name, or the function name, else 'anonymous'. type OutgoingContext = { kind: 'send'; message: Command; … } | { kind: 'publish'; message: Event; … }, each with mutable attributes: MessageAttributes and headers: TransportHeaders. TransportHeaders = Record<string, string | number | boolean>. interface BusMiddleware { incoming?; handler?; outgoing? }. JSDoc on every member; no any. |
| Incoming stage | Runs inside the bus' messageHandlingContext and messageLifecycleContext, around dispatchMessageToHandlers, for every received message: polled or from a Receiver, including messages with no handler. Core deletes or returns the message after the chain settles, outside it. send/publish from this stage go straight to the transport, as read middleware does today. failMessage/returnMessage work. |
| Handler stage | Wraps each handler invocation inside its outbox (dispatchMessageToHandler, made private). Sends from it are buffered and dropped if the handler fails. For workflows it wraps the registered workflow handler: state load, invoke and save. |
| Outgoing stage | In send()/publish(), after prepareTransportOptions, before addToOutbox. Its terminal step buffers or calls transport.send/publish(message, attributes, { headers }). |
| Transport interface | send/publish take a third options?: TransportSendOptions ({ headers?: TransportHeaders }). #258 adds delivery time here. |
| bus-rabbitmq | Spreads headers into the AMQP headers. attributes, stickyAttributes and failedAttempts are reserved. Retries and the DLQ already copy properties, so headers survive. |
| bus-sqs | Each header becomes an SNS message attribute under its exact name, with types mapped as in toAttributeValue. correlationId and the attributes./stickyAttributes. prefixes are reserved. Document that AWS limits the number of message attributes. |
| InMemoryQueue | Keeps headers on its raw message so tests can assert them. |
| Reserved names | A transport throws TransportHeaderReserved (bus-core error, exported; headerName, transportName, help). |
| Removed | withMessageReadMiddleware, MiddlewareDispatcher, the eight emitters and their payload interfaces (BeforeSend…AfterDispatch), and TypedEmitter with its spec. The old Middleware/Next are replaced by the new types. |
| Migration | withMessageReadMiddleware(fn) → withMiddleware({ incoming: (ctx, next) => fn(ctx.transportMessage, next) }). before* → outgoing middleware before next(). afterSend/afterPublish → after await next(), which inside a handler resolves when the message is buffered, not sent. afterReceive/afterDispatch/onError → incoming middleware before next() / after next() / try/catch (rethrow). beforeDispatch → handler middleware. |
| Docs | Rewrite /guide/middleware covering all three stages, with snippets: timing, log context, stamping an attribute, RabbitMQ x-delay via headers (#202), validation with failMessage. Delete /guide/lifecycle-hooks and add a redirect stub to /guide/middleware; fix the links in retry-strategies, shutting-down and multiple-buses. This page is the Phase 3 "pipeline" page. |
| Repo notes | MIGRATING.md entry; minor changeset with a Breaking: note (bus-core, bus-rabbitmq, bus-sqs). Update CLAUDE.md: message flow, the Outbox line, and replace the TypedEmitter convention. |
How the dependent issues hook in:
- OpenTelemetry tracing and metrics #264:
openTelemetry()returns aBusMiddleware. Incoming opens the process span, extractingtraceparentfromattributes. Handler adds optional per-handler child spans. Outgoing creates the span and injectstraceparent/tracestateintoattributes. Message ids and send times come from the messageId issue. - Add Request/Response pattern #151: outgoing stamps the request id; incoming resolves replies without calling
next(). Correlating on message ids relies on the messageId issue. - Delayed / deferred message delivery #258: delivery options join
TransportSendOptionsand the outgoing context. The persistence scheduler fallback is a core terminal step. - Durable transactional outbox #260: stores the finished outgoing message; the per-handler stage is where the transaction/session scope lives.
- Inbox / idempotent consumer (message deduplication) #261: duplicate skip is an incoming short-circuit; the stable message id it dedups on comes from the messageId issue and is visible to outgoing middleware.
- Configurable recoverability policy and error queue with failure metadata #262: adds attempt and failure metadata to the incoming context.
Acceptance criteria
-
withMiddleware()onBusConfiguration(JSDoc,BusAlreadyInitializedguard, returnsthis) takesBusMiddlewareobjects withincoming,handlerandoutgoingstages, run in registration order (first is outermost). - Incoming middleware runs for polled and
Receivermessages, including messages with no handler. Not callingnext()deletes the message. A throw returns it for retry. - Handler middleware wraps each handler and workflow handler inside its outbox. A throw fails that handler only, and its sends are dropped.
- Outgoing middleware runs at
send()/publish(), before the outbox. It can mutateattributesandheaders, drop the message by not callingnext(), or reject the send by throwing. Flushing doesn't re-run it. - A second
next()call throwsMiddlewareNextCalledTwice. -
Transport.send/publishtakeTransportSendOptions. bus-rabbitmq and bus-sqs writeheadersnatively and throwTransportHeaderReservedon a reserved name. Each has an integration test proving a header arrives (RabbitMQ also through a retry). InMemoryQueue keeps them. -
withMessageReadMiddleware,MiddlewareDispatcher, the eight emitters, their payload types andTypedEmitterare removed. Tests are ported to middleware. - Middleware is unit-testable as a plain function with a plain-object context and
next, with no bus. At least one spec does this. - Two buses in one process don't see each other's middleware or context (isolation test).
- Docs:
/guide/middlewarerewritten;/guide/lifecycle-hooksdeleted and redirected to it, with links fixed; snippets type-check. -
MIGRATING.mdentry, aminorBreaking: changeset and CLAUDE.md updates.
Activity