Skip to content

Incoming and outgoing pipeline behaviours (middleware) #263

Closed

Description

@adenhertog

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.
  • beforeDispatch is emitted after handlers.map(dispatchMessageToHandler), so function handlers have already started when it fires.
  • MiddlewareDispatcher and TypedEmitter are exported from the package root, and BusInstance.dispatchMessageToHandler is 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:

  1. Name: "middleware" (matches the existing Middleware type, /guide/middleware and the Koa idiom).
  2. Registration: a single withMiddleware(...middleware: BusMiddleware[]), where BusMiddleware = { incoming?, handler?, outgoing? }.
  3. Emitters: remove all eight and withMessageReadMiddleware, with no shims.
  4. Per-handler stage: ships in this issue.
  5. Native headers: a generic headers bag 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 from transportMessage.raw.

Decided from the principles and the dependent issues:

  1. 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.
  2. Order: registration order within each stage, with the first registered outermost. There are no priorities or named slots.
  3. 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 throws MiddlewareNextCalledTwice (names the stage).
  4. 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 via HandlerDispatchRejected). A throw from an outgoing middleware rejects that send()/publish(), and nothing is buffered.
  5. Incoming is read-only: message and attributes stay frozen. Middleware passes derived data through its own AsyncLocalStorage or sticky attributes.
  6. Per-bus: the chains live in BusConfiguration and are passed to BusInstance. Nothing is module-level. A stateful plugin is a factory called once per bus.

Non-goals

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:

Acceptance criteria

  • withMiddleware() on BusConfiguration (JSDoc, BusAlreadyInitialized guard, returns this) takes BusMiddleware objects with incoming, handler and outgoing stages, run in registration order (first is outermost).
  • Incoming middleware runs for polled and Receiver messages, including messages with no handler. Not calling next() 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 mutate attributes and headers, drop the message by not calling next(), or reject the send by throwing. Flushing doesn't re-run it.
  • A second next() call throws MiddlewareNextCalledTwice.
  • Transport.send/publish take TransportSendOptions. bus-rabbitmq and bus-sqs write headers natively and throw TransportHeaderReserved on 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 and TypedEmitter are 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/middleware rewritten; /guide/lifecycle-hooks deleted and redirected to it, with links fixed; snippets type-check.
  • MIGRATING.md entry, a minor Breaking: changeset and CLAUDE.md updates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestroadmapPart of the project roadmap

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions