Skip to content

ActiveMQRouter ​

ActiveMQRouter routes Amazon MQ for ActiveMQ messages to handlers, one message at a time.

An event carries a list of messages from one broker, and every message body arrives base64 encoded. A text message is decoded to text and parsed as JSON; a bytes message is decoded to a Buffer of the raw bytes. The router works out which of your routes should handle each one, and your handler deals with a single message and never sees the batch.

Every message is either a text message or a bytes message, and a route can take one type or both.

Install ​

bash
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 RabbitMQRouter as well, so the functions are named per router. You want createActiveMQRouter and defineActiveMQRoute rather than the createRouter and defineRoute a single router package gives you.

Create the router ​

ts
import { createActiveMQRouter } from '@lambda-event-router/mq'
import { logInvocation } from './middleware/logInvocation'

const activeMQRouter = createActiveMQRouter({
  middleware: [logInvocation],  // Optional
})

The one option can be left out, so createActiveMQRouter() is what you want most of the time.

Options ​

OptionTypeRequiredDefaultDescription
middlewareActiveMQMiddleware[]No[]Runs for every message this router handles, before any route middleware. See Middleware

Register routes ​

ts
activeMQRouter.route({
  filters: {
    destination: 'orders',
    messageType: 'jms/text-message',
  },
  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.

ts
activeMQRouter.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.

Convenience methods ​

textMessage() and bytesMessage() are route() with the messageType filter already set.

ts
activeMQRouter.textMessage({
  filters: { destination: 'orders' },
  handler: processOrder,
})

// The same route written out
activeMQRouter.route({
  filters: { destination: 'orders', messageType: 'jms/text-message' },
  handler: processOrder,
})
MethodSetsHandler is given
textMessage()messageType: 'jms/text-message'ActiveMQTextMessageRequest
bytesMessage()messageType: 'jms/bytes-message'ActiveMQBytesMessageRequest

Both take middleware and return the router so they chain, and textMessage() takes bodySchema too. A bytes body is a Buffer with no JSON to validate, so bytesMessage() has no bodySchema. What they both drop is messageType itself: it is gone from the filters they accept, so setting it is a type error rather than a value that silently loses to the one the method sets.

See convenience methods for how the other routers use them.

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.

ts
activeMQRouter.route({
  filters: {
    eventSourceArn: BROKER_ARN,
    messageType: 'jms/text-message',
    destination: ['orders', 'priority-orders'], // Or a pattern: /^priority-/
    custom: ({ record }) => {
      // Only a custom reaches the message itself
      return record.priority >= HIGH_PRIORITY
    },
  },
  handler: processOrder,
})
FilterTypeDescription
eventSourceArnFilterStringMatcherMatches the ARN of the broker the event came from
messageTypeActiveMQMessageType | ActiveMQMessageType[]Matches the type of the message. See Message types
destinationFilterStringMatcherMatches destination.physicalName, which is the queue or topic name on the broker
custom(input: ActiveMQFilterInput) => boolean | Promise<boolean>Anything the other keys cannot express, given the messageType, the destination, 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.

messageType is compared exactly. It is the one filter that takes no wildcard and no RegExp, and it accepts only the two ActiveMQMessageType values, so a misspelled one is a compile error rather than a filter that quietly matches nothing.

custom is the only key that reaches the message itself, so the JMS properties in message.properties, the priority and redelivered are all filterable through it and nowhere else.

Amazon MQ omits properties when a message carries none. message.properties is filled in with an empty object so a filter can read it either way, and record.properties is left as Amazon MQ sent it.

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: on a text message message.data is decoded text and record.data is the base64 AWS sent, and on a bytes message both stay base64. See custom for where it sits in the filter order.

Handler ​

Handlers take one argument and return nothing.

ts
import { logger } from '@lambda-event-router/base'
import type { ActiveMQResponse, ActiveMQTextMessageRequest } from '@lambda-event-router/mq'

export async function processOrder(
  request: ActiveMQTextMessageRequest<Order>,
): Promise<ActiveMQResponse> {
  const { orderId, total } = request.body
  logger.info(`Processing order ${orderId} for ${total} from ${request.destination}`)
}

Request object ​

FieldTypeDescription
messageActiveMQMessageThe message. On a text message data is decoded to text; on a bytes message data is the base64 AWS sent
destinationstringThe queue or topic the message came from, taken from destination.physicalName
bodyTBody on text, Buffer on bytesA text body is the decoded data parsed as JSON, or the raw string if it is not JSON. A bytes body is a Buffer of the raw bytes
messageTypeActiveMQMessageTypeWhich of the two types this message is. See Message types
recordActiveMQMessageThe untouched message from AWS, so data is still base64
contextContextThe Lambda context

ActiveMQMessage and ActiveMQDestination are declared by this package, since aws-lambda carries no ActiveMQ types. Context does come from aws-lambda.

On a text message, message and record differ by one field. message.data is the decoded text and record.data is the base64 AWS sent. On a bytes message the two are identical, since the router leaves data base64 and hands the decoded bytes to body instead. messageID, properties and the rest are the same on both.

Response type ​

ActiveMQResponse is undefined. There is nothing useful to return to a broker, so handlers return Promise<ActiveMQResponse> 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. defineActiveMQRoute 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.

ts
import { logger } from '@lambda-event-router/base'
import { defineActiveMQRoute } from '@lambda-event-router/mq'
import { z } from 'zod'

const OrderSchema = z.object({ orderId: z.string(), total: z.coerce.number() })

export const processOrderRoute = defineActiveMQRoute({
  filters: { destination: 'orders', messageType: 'jms/text-message' },
  bodySchema: OrderSchema,
}).handle(async ({ body, destination }) => {
  logger.info(`Processing order ${body.orderId} for ${body.total} from ${destination}`)
})

activeMQRouter.route(processOrderRoute)

It reads the messageType filter as well, so the request above is the text message one rather than the union. Message types covers when that narrowing happens.

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 ActiveMQTextMessageRequest and your own types.

ts
// handlers/processOrder.ts
import { logger } from '@lambda-event-router/base'
import type { ActiveMQResponse, ActiveMQTextMessageRequest } 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: ActiveMQTextMessageRequest<Order>,
): Promise<ActiveMQResponse> {
  logger.info(`Processing order ${request.body.orderId}`)
}
ts
// activemq.ts
import { createActiveMQRouter } from '@lambda-event-router/mq'
import { OrderSchema, processOrder } from './handlers/processOrder'

const activeMQRouter = createActiveMQRouter()

activeMQRouter.textMessage({
  filters: { destination: 'orders' },
  bodySchema: OrderSchema,
  handler: processOrder,
})

Derive the type from the schema with z.infer rather than hand-writing an interface that mirrors it. The textMessage() call is doing real work here, since naming one of the two request types in the handler does not filter for it, and annotated handlers has the worked version.

Message types ​

Amazon MQ tags every message as one of two types, and request.messageType carries which.

TypeWhat it is
jms/text-messageA JMS TextMessage, so data is text the sender wrote
jms/bytes-messageA JMS BytesMessage, so data is whatever bytes the sender wrote

ActiveMQRequest is the union of ActiveMQTextMessageRequest and ActiveMQBytesMessageRequest, and messageType is what tells them apart. Pin one type on the route and the request narrows to it.

ts
activeMQRouter.textMessage({
  filters: { destination: 'orders' },
  handler: async (request) => {
    // request is ActiveMQTextMessageRequest
  },
})

const scanRoute = defineActiveMQRoute({
  filters: { messageType: 'jms/bytes-message' },
}).handle(async (request) => {
  // request is ActiveMQBytesMessageRequest
})

A route naming both types, or leaving messageType off, gets the union and has to check request.messageType before doing anything specific to one of them.

A narrowed handler needs a matching messageType filter. route() types the handler from the messageType filter, so a handler annotated ActiveMQTextMessageRequest compiles only on a route that filters for jms/text-message, whether through the filter directly or textMessage(). A route with no messageType filter types the handler as the union, so it cannot be annotated to one type and then handed the other at runtime.

A bytes message body is a Buffer. The router decodes base64 to text and parses JSON only for a text message. A bytes message is decoded straight to a Buffer of the raw bytes on request.body, so binary that is not UTF-8 arrives intact rather than mangled. message.data and record.data stay the base64 AWS sent.

Schema validation ​

One key takes a schema, and it is optional.

ts
const OrderSchema = z.object({
  orderId: z.string(),
  total: z.number(),
})

activeMQRouter.textMessage({
  filters: { destination: 'orders' },
  bodySchema: OrderSchema,
  handler: processOrder,
})
KeyValidates
bodySchemaThe 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.

bodySchema validates a text body only. A bytes body is a Buffer, so it skips JSON parsing and schema validation, and bytesMessage() and a jms/bytes-message route built with defineActiveMQRoute both reject a bodySchema at compile time. On a text route, data that is not JSON reaches the schema as a string and a z.object() rejects it, so keep bodySchema on the text routes carrying JSON.

Failures and retries ​

A handler that throws fails the invocation. Messages are handled one at a time, 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 messageID it could not place.

Middleware ​

Router and route middleware are both typed ActiveMQMiddleware, and the chain runs once per message, so a batch of ten messages runs it ten times.

ts
import { logger } from '@lambda-event-router/base'
import type { ActiveMQMiddleware } from '@lambda-event-router/mq'

export const logInvocation: ActiveMQMiddleware = async (request, next) => {
  logger.info(`Handling ${request.message.messageID} from ${request.destination}`)
  return next(request)
}
ts
const activeMQRouter = createActiveMQRouter({ middleware: [logInvocation] })

activeMQRouter.textMessage({
  filters: { destination: 'orders' },
  middleware: [withOrderContext],
  handler: processOrder,
})

Route middleware carries the route's body type and message type. A textMessage() route with a bodySchema needs ActiveMQMiddleware<Order, 'jms/text-message'>. That gives request.body the schema output. Leave the second parameter off and request is the union of both message types. 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.

TypeDescription
ActiveMQRequest<TBody>The handler argument, the union of the two below
ActiveMQTextMessageRequest<TBody>The handler argument on a text message route
ActiveMQBytesMessageRequestThe handler argument on a bytes message route, its body a Buffer
ActiveMQResponseHandler return type, undefined
ActiveMQMessageType'jms/text-message' | 'jms/bytes-message'
ActiveMQFiltersThe filters object
ActiveMQFilterInputWhat custom receives
ActiveMQRouteDefinition<TBody>A full route passed to route()
ActiveMQTextMessageRouteDefinition<TBody>A route passed to textMessage(), with no messageType filter
ActiveMQBytesMessageRouteDefinitionA route passed to bytesMessage(), with no messageType filter and no bodySchema
ActiveMQRouterOptionsOptions for createActiveMQRouter
ActiveMQMiddleware<TBody, TMessageType>Router and route middleware
ActiveMQMessageOne message, as request.message and request.record
ActiveMQDestinationThe destination on a message, holding physicalName
ActiveMQEventThe whole event, with its messages in one array

The ActiveMQRouter class and the createActiveMQRouter and defineActiveMQRoute functions come from the same place.

Generic parameters ​

The types above take one or both of these.

ParameterTypesDefault
TBodyrequest.bodyunknown
TMessageTypeWhich request shape a middleware takesundefined, meaning the union

Only ActiveMQMiddleware takes the second parameter. Leave TBody off and the body is unknown, which is what ActiveMQFilterInput sees however the route is typed. You only need it for annotated handlers, since inference covers the rest.

Code example ​

Orders arriving as text and scanned documents arriving as bytes, on one broker, with anything else logged and dropped.

Open a file: index.ts | ActiveMQ router | handlers | schema

File viewer
TSactivemq.ts

The first two routes differ by destination and by message type, 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 bytes message on orders as much as anything from a third destination. Without it those throw, and a throw takes the rest of the batch with it.

storeScan reads request.body, which the router hands over as a Buffer of the raw bytes. A scan is not text, so it never goes through the UTF-8 decode the text messages take. See message types for how a bytes message differs from a text one.

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.