RabbitMQRouter
RabbitMQRouter routes Amazon MQ for RabbitMQ messages to handlers, one message at a time.
An event carries messages grouped by queue, and every message body arrives base64 encoded. The router decodes each one, parses it as JSON, then works out which of your routes should handle it. Your handler deals with one message and never sees the batch.
Install
npm install @lambda-event-router/base @lambda-event-router/mq@lambda-event-router/base is a peer dependency, so install it yourself. It exports LambdaRouter, which every router plugs into.
The package holds ActiveMQRouter as well, so the functions are named per router. You want createRabbitMQRouter and defineRabbitMQRoute rather than the createRouter and defineRoute a single router package gives you.
Create the router
import { createRabbitMQRouter } from '@lambda-event-router/mq'
import { logInvocation } from './middleware/logInvocation'
const rabbitMQRouter = createRabbitMQRouter({
middleware: [logInvocation], // Optional
})The one option can be left out, so createRabbitMQRouter() is what you want most of the time.
Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
middleware | RabbitMQMiddleware[] | No | [] | Runs for every message this router handles, before any route middleware. See Middleware |
Register routes
rabbitMQRouter.route({
filters: {
queue: 'orders',
contentType: 'application/json',
},
bodySchema: OrderSchema, // Optional
middleware: [withOrderContext], // Optional
handler: processOrder,
})filters and handler are the only required keys.
route() returns the router, so you can chain registrations.
rabbitMQRouter.route(processOrderRoute).route(refundOrderRoute)Routes are ranked by how specific they are. Where two overlap, the one matching only a subset of the other is tried first, whatever order you registered them in. Registration order decides the rest, so give each route filters no other route can match. See match order for how ranking works and what it cannot settle.
A message that matches no route throws, which fails the invocation and leaves the messages behind it in the batch unhandled. Register a catch-all route with empty filters if you would rather swallow unknown messages, and see nothing matched for what the other routers do instead.
Filters
Every filter key on one route, showing each form a value can take. All of them are optional, so set the ones that pick out the messages you want and leave the rest off.
rabbitMQRouter.route({
filters: {
eventSourceArn: BROKER_ARN,
queue: ['orders', 'priority-orders'], // Or a pattern: /-retry$/
virtualHost: '/production',
contentType: 'application/json',
custom: ({ record }) => {
// Only a custom reaches the message itself
return (record.basicProperties.priority ?? 0) >= HIGH_PRIORITY
},
},
handler: processOrder,
})| Filter | Type | Description |
|---|---|---|
eventSourceArn | FilterStringMatcher | Matches the ARN of the broker the event came from |
queue | FilterStringMatcher | Matches the queue name, with the virtual host split off. See Queue names |
virtualHost | FilterStringMatcher | Matches the virtual host the queue lives on. See Queue names |
contentType | FilterStringMatcher | Matches basicProperties.contentType, when it is not null |
custom | (input: RabbitMQFilterInput) => boolean | Promise<boolean> | Anything the other keys cannot express, given the queue, the virtualHost, the contentType, the parsed timestamp, the decoded message and the raw record. Can be async |
FilterStringMatcher is string | RegExp | Array<string | RegExp>. See filters for how each form matches, including the * wildcard.
A message with no content type never matches a contentType filter. Content type is an optional AMQP property, so a publisher can leave it off, and Amazon MQ then sends contentType: null. The router skips a contentType filter for that message rather than matching it, the same way an absent virtual host skips a virtualHost filter. So contentType: '*' picks out the messages that have a content type, not every message, and custom sees contentType as null.
custom is the only key that reaches basicProperties, so priority, headers, correlationId and the redelivered flag are filterable through it and nowhere else.
custom gets no parsed body. Schema validation runs after a route matches, so parse message.data yourself to route on the contents. The filter sees the same split as the handler: message has its data decoded to text and record is the untouched AWS message with data still base64. See custom for where it sits in the filter order.
Queue names
An event groups its messages by queue, and RabbitMQ names each group queueName::virtualHost. A group named orders::/production holds the messages from the orders queue on the /production virtual host.
The router splits the two apart, so request.queue is orders and request.virtualHost is /production. The queue filter matches against the name and the virtualHost filter against the host.
Use queue: 'orders', not queue: 'orders::/production'. The queue filter sees the name on its own, so a value with the virtual host in it matches nothing. Pick the host with virtualHost instead.
The same queue name in two virtual hosts routes apart once you add a virtualHost filter, and a handler reads request.virtualHost to tell which host a message came from. A key with no :: carries no virtual host, so request.virtualHost is undefined and a virtualHost filter never matches it.
Handler
Handlers take one argument and return nothing.
import { logger } from '@lambda-event-router/base'
import type { RabbitMQRequest, RabbitMQResponse } from '@lambda-event-router/mq'
export async function processOrder(
request: RabbitMQRequest<Order>,
): Promise<RabbitMQResponse> {
const { orderId, total } = request.body
logger.info(`Processing order ${orderId} for ${total} from ${request.queue}`)
}Request object
| Field | Type | Description |
|---|---|---|
message | RabbitMQMessage | The message with data decoded from base64 to text |
queue | string | The queue name, without the virtual host |
virtualHost | string | undefined | The virtual host the queue lives on. undefined when the queue key carries none |
body | TBody | The decoded data parsed as JSON. Data that is not JSON reaches you as the raw string |
timestamp | Date | null | The AMQP timestamp property as a Date. See Timestamp |
record | RabbitMQMessage | The untouched message from AWS, so data is still base64 |
context | Context | The Lambda context |
RabbitMQMessage and RabbitMQBasicProperties are declared by this package, since aws-lambda carries no RabbitMQ types. Context does come from aws-lambda.
message and record differ by one field. message.data is the decoded text and record.data is the base64 AWS sent, so reading record.data for your message body hands you base64. basicProperties and redelivered are the same on both.
Timestamp
Amazon MQ sends the AMQP timestamp property as text such as Sep 21, 2026, 2:13:20 PM. The text is UTC but carries no time zone, so new Date() on it gives the wrong instant anywhere that is not running in UTC.
The router parses it for you as UTC, so request.timestamp is 2026-09-21T14:13:20.000Z for that text. The original text stays on basicProperties.timestamp.
request.timestamp is null when the publisher set no timestamp, and also when the text is in a format the router does not recognise. The message is still handled either way.
A custom filter gets the same parsed value as timestamp on its input.
Headers
A string header arrives as bytes, not text. Amazon MQ sends orderPriority: 'urgent' as { bytes: [117, 114, 103, 101, 110, 116] }, which is the UTF-8 encoding of the string. That goes for a string inside a header array or a nested table too. Decode one with Buffer.from(value.bytes).toString('utf-8').
Numbers of every AMQP size, booleans and null arrive as they are. A decimal arrives as a plain number, so 12345 with two places reads as 123.45. A byte array arrives as signed bytes, so 255 reads as -1.
A timestamp header is the one plain string, in the same format as the timestamp property. The router does not parse headers, so it stays as text.
RabbitMQHeaderValue types each value, so TypeScript makes you check for bytes before you read a string header.
Response type
RabbitMQResponse is undefined. There is nothing useful to return to a broker, so handlers return Promise<RabbitMQResponse> and the router works out what to hand back to Lambda.
Throwing is how you signal failure. See Failures and retries for what that does to the rest of the batch.
Inferred handlers
Nothing to look up and nothing to keep in sync. defineRabbitMQRoute reads the schema and hands your handler a fully typed body, defaults and coercion included, so total below is a number without you declaring that anywhere.
import { logger } from '@lambda-event-router/base'
import { defineRabbitMQRoute } from '@lambda-event-router/mq'
import { z } from 'zod'
const OrderSchema = z.object({ orderId: z.string(), total: z.coerce.number() })
export const processOrderRoute = defineRabbitMQRoute({
filters: { queue: 'orders', contentType: 'application/json' },
bodySchema: OrderSchema,
}).handle(async ({ body, queue }) => {
logger.info(`Processing order ${body.orderId} for ${body.total} from ${queue}`)
})
rabbitMQRouter.route(processOrderRoute)Inference pays off most in a Lambda taking several event sources, since you never have to know any of their request shapes. See inferred handlers, where the same queue is written both ways to compare.
Annotated handlers
Annotating the request yourself splits route setup from business logic, using RabbitMQRequest and your own types.
// handlers/processOrder.ts
import { logger } from '@lambda-event-router/base'
import type { RabbitMQRequest, RabbitMQResponse } from '@lambda-event-router/mq'
import { z } from 'zod'
export const OrderSchema = z.object({ orderId: z.string(), total: z.number() })
type Order = z.infer<typeof OrderSchema>
export async function processOrder(request: RabbitMQRequest<Order>): Promise<RabbitMQResponse> {
logger.info(`Processing order ${request.body.orderId}`)
}// rabbitmq.ts
import { createRabbitMQRouter } from '@lambda-event-router/mq'
import { OrderSchema, processOrder } from './handlers/processOrder'
const rabbitMQRouter = createRabbitMQRouter()
rabbitMQRouter.route({
filters: { queue: 'orders', contentType: 'application/json' },
bodySchema: OrderSchema,
handler: processOrder,
})Derive the type from the schema with z.infer rather than hand-writing an interface that mirrors it. Both forms hand the handler the same request here, and annotated handlers has the worked version.
Schema validation
One key takes a schema, and it is optional.
const OrderSchema = z.object({
orderId: z.string(),
total: z.number(),
})
rabbitMQRouter.route({
filters: { queue: 'orders', contentType: 'application/json' },
bodySchema: OrderSchema,
handler: processOrder,
})| Key | Validates |
|---|---|
bodySchema | The decoded message data, parsed as JSON |
Any Standard Schema library works. Validation runs after a route has matched, so a message failing its schema throws rather than falling through to the next route. See schema validation for what your handler receives after coercion.
Data that is not JSON reaches the schema as a string. A z.object() rejects that, so every plain text message on the queue throws. Pair a bodySchema with contentType: 'application/json' and give the rest of the traffic its own route.
Failures and retries
A handler that throws fails the invocation. Messages are handled one at a time, queue by queue in the order the event lists them, so a throw stops everything behind it in the batch from running.
The router has no way to report single messages back to Lambda, so a failure applies to all of them. None are acknowledged, the broker redelivers the whole batch, and the messages that already succeeded run a second time. Make handlers safe to run twice and read message.redelivered where a repeat costs something.
The batch size on the event source mapping decides how much gets replayed, so a smaller batch means less repeated work after a failure.
A message matching no route throws the same way, naming the queue and broker ARN it could not place.
Middleware
Router and route middleware are both typed RabbitMQMiddleware, and the chain runs once per message, so a batch of ten messages runs it ten times.
import { logger } from '@lambda-event-router/base'
import type { RabbitMQMiddleware } from '@lambda-event-router/mq'
export const logInvocation: RabbitMQMiddleware = async (request, next) => {
logger.info(`Handling a ${request.message.basicProperties.contentType} message on ${request.queue}`)
return next(request)
}const rabbitMQRouter = createRabbitMQRouter({ middleware: [logInvocation] })
rabbitMQRouter.route({
filters: { queue: 'orders' },
middleware: [withOrderContext],
handler: processOrder,
})Route middleware carries the route's body type. A route with a bodySchema needs RabbitMQMiddleware<Order>. RabbitMQMiddleware on its own does not compile there. Router middleware takes no type argument, because it runs for every route.
See middleware for the execution order and the three levels it attaches at.
Types
All exported from @lambda-event-router/mq.
| Type | Description |
|---|---|
RabbitMQRequest<TBody> | The handler argument |
RabbitMQResponse | Handler return type, undefined |
RabbitMQFilters | The filters object |
RabbitMQFilterInput | What custom receives |
RabbitMQRouteDefinition<TBody> | A full route passed to route() |
RabbitMQRouterOptions | Options for createRabbitMQRouter |
RabbitMQMiddleware<TBody> | Router and route middleware |
RabbitMQMessage | One message, as request.message and request.record |
RabbitMQBasicProperties | The AMQP properties on a message |
RabbitMQHeaderValue | One value in basicProperties.headers. See Headers |
RabbitMQEvent | The whole event, with its messages keyed by queue |
The RabbitMQRouter class and the createRabbitMQRouter and defineRabbitMQRoute functions come from the same place.
Generic parameters
The types above that take a parameter all take the same one.
| Parameter | Types | Default |
|---|---|---|
TBody | request.body | unknown |
Leave it off and the body is unknown, which is what RabbitMQFilterInput sees however the route is typed. You only need it for annotated handlers, since inference covers the rest.
Code example
An orders queue and a refunds queue on one broker, with anything else logged and dropped.
Open a file: index.ts | RabbitMQ router | handlers | schema
The first two routes match a different queue, so no message can match both and the order they are registered in makes no difference to them.
The catch-all is the one route that cares where it sits, since empty filters match anything. Register it last and it takes what the other two turned down, which here is a plain text message on orders as much as anything from a third queue. Without it those throw, and a throw takes the rest of the batch with it.
index.ts hands the router to LambdaRouter, which is what AWS invokes and what every router in the Lambda gets registered on. See routers for how the two levels of matching fit together.