Skip to content

SNSRouter ​

SNSRouter routes Amazon SNS notifications to handlers, one record at a time.

The router parses each record's message, converts its message attributes, then works out which of your routes should handle it. You can route on the topic, the subject or any message attribute.

Install ​

bash
npm install @lambda-event-router/base @lambda-event-router/sns

@lambda-event-router/base is a peer dependency, so install it yourself. It exports LambdaRouter, which every router plugs into.

Create the router ​

ts
import { createSNSRouter } from '@lambda-event-router/sns'
import { logInvocation } from './middleware/logInvocation'

const snsRouter = createSNSRouter({
  middleware: [logInvocation],  // Optional
})

The option can be left out. createSNSRouter() on its own is what you want most of the time.

Options ​

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

Register routes ​

ts
snsRouter.route({
  filters: {
    topicArn: ORDER_TOPIC_ARN,
    subject: 'Order created',
  },
  bodySchema: OrderSchema,  // Optional
  messageAttributesSchema: OrderAttributesSchema,  // Optional
  middleware: [withOrderContext],  // Optional
  handler: processOrder,
})

filters and handler are the only required keys.

route() returns the router, so you can chain registrations.

ts
snsRouter.route(createOrderRoute).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 record that matches no route throws. That fails the invocation, which is usually what you want, since it lets Lambda retry the notification. Nothing matched covers what the other routers do.

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 records you want and leave the rest off.

ts
snsRouter.route({
  filters: {
    topicArn: [ORDER_TOPIC_ARN, ORDER_DL_TOPIC_ARN],
    subject: 'Order created', // Or a pattern: /^Order /
    messageAttributes: {
      Type: ['OrderCreated', 'OrderRefunded'],
      Region: 'eu-west-2', // Or a pattern: /^eu-/
      Attempt: '1', // Always a string, whatever it was published as
    },
    custom: ({ body }) => {
      // Only a custom reaches the parsed body
      if (!isObject(body) || typeof body.total !== 'number') return false

      return body.total >= HIGH_VALUE_THRESHOLD
    },
  },
  handler: processOrder,
})
FilterTypeDescription
topicArnFilterStringMatcherMatches against the record's topic ARN
subjectFilterStringMatcherMatches against the notification subject
messageAttributesRecord<string, FilterStringMatcher>Every key listed must be present on the message and match. A missing attribute means no match
custom(input: SNSFilterInput) => boolean | Promise<boolean>Anything the other filters cannot express. Can be async

FilterStringMatcher is string | RegExp | Array<string | RegExp>. See filters for how each form matches, including the * wildcard.

A Binary attribute never matches a messageAttributes filter. It arrives as a Buffer, and no matcher describes one. Pick those out with a custom filter instead.

Every other attribute is a string when it reaches a filter, so match '1' rather than 1. See Message attribute types for why.

A subject filter never matches a notification published without one. Subject is optional in SNS, and plenty of publishers leave it unset. If some of your messages have no subject, route those on topicArn or a message attribute instead.

custom sees the body before any schema has run, so narrow it with isObject from @lambda-event-router/base rather than reading straight into it. 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 { SNSRequest, SNSResponse } from '@lambda-event-router/sns'

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

Request object ​

FieldTypeDescription
bodyTBodyThe notification message. JSON is parsed for you. If the message is not valid JSON you get the raw string
messageAttributesTMessageAttributesMessage attributes converted to real values. See Message attribute types
recordSNSEventRecordThe untouched record from AWS, for Sns.MessageId, Sns.Timestamp and anything else you need
contextContextThe Lambda context

SNSEventRecord and Context come from aws-lambda, not from this package.

Message attribute types ​

Publish takes four attribute types, but a Lambda subscription receives two. SNS flattens Number and String.Array to String on the way.

You publishYour handler gets
String'OrderPlaced'
Number'2', a string
String.Array'["london","manchester"]', a string holding the JSON
Binarya Buffer, decoded from base64

A custom label on a published type, such as Number.float or Binary.gif, does not reach your handler: SNS drops it and delivers the base type. SQSRouter behaves the other way round, so a route moved from one to the other cannot filter on a label.

Only Binary is converted for you. Handle the other two in your schema:

ts
priority: z.coerce.number(), // '2' becomes 2
warehouses: z.string().transform((value) => JSON.parse(value)),

Response type ​

SNSResponse is undefined. There is nothing useful to return from a notification, so handlers return Promise<SNSResponse>.

Throwing is how you signal failure. See Failures and retries for what that does to the invocation.

Inferred handlers ​

Nothing to look up and nothing to keep in sync. defineRoute reads the schemas and hands your handler a fully typed body and messageAttributes, defaults and coercion included, so dryRun below is a boolean without you declaring that anywhere.

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

const OrderSchema = z.object({ orderId: z.string(), total: z.number() })
const OrderAttributesSchema = z.object({ dryRun: z.coerce.boolean().default(false) })

export const processOrderRoute = defineRoute({
  filters: { topicArn: ORDER_TOPIC_ARN },
  bodySchema: OrderSchema,
  messageAttributesSchema: OrderAttributesSchema,
}).handle(async ({ body, messageAttributes }) => {
  if (messageAttributes.dryRun) return
  logger.info(`Processing order ${body.orderId} for ${body.total}`)
})

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

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

const snsRouter = createSNSRouter()

snsRouter.route({
  filters: { topicArn: ORDER_TOPIC_ARN },
  bodySchema: OrderSchema,
  handler: processOrder,
})

Derive the type from the schema with z.infer rather than hand-writing an interface that mirrors it. Typing the message attributes as well means intersecting the schema output with SNSMessageAttributes, which annotated handlers shows for SQS.

Schema validation ​

Two keys take a schema, and both are optional.

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

const OrderAttributesSchema = z.object({
  Type: z.literal('OrderCreated'),
  dryRun: z.coerce.boolean().default(false),
})

snsRouter.route({
  filters: { topicArn: ORDER_TOPIC_ARN },
  bodySchema: OrderSchema,
  messageAttributesSchema: OrderAttributesSchema,
  handler: processOrder,
})
KeyValidates
bodySchemaThe parsed notification message
messageAttributesSchemaThe converted message attributes

Any Standard Schema library works. Validation runs after a route has matched, so a record failing its schema throws rather than falling through to the next route. See schema validation for what your handler receives after coercion.

Failures and retries ​

The router lets the first error through, so the invocation fails. A handler that throws, a record matching no route and a record failing schema validation all fail it the same way.

There is no partial batch response for an SNS subscription, so the router has nowhere to report one record's failure. Failing the invocation is what lets Lambda retry it and then reach the function's on-failure destination, so there is no option to turn that off.

If you want per-record retries across a batch, subscribe an SQS queue to the topic and use SQSRouter, which does report individual failures.

Middleware ​

Router and route middleware are both typed SNSMiddleware, and the chain runs once per record alongside your handler rather than once per invocation.

ts
import { logger } from '@lambda-event-router/base'
import type { SNSMiddleware } from '@lambda-event-router/sns'

export const logInvocation: SNSMiddleware = async (request, next) => {
  logger.info(`Handling notification ${request.record.Sns.MessageId}`)
  return next(request)
}
ts
const snsRouter = createSNSRouter({ middleware: [logInvocation] })

snsRouter.route({
  filters: { topicArn: ORDER_TOPIC_ARN },
  middleware: [withOrderContext],
  handler: processOrder,
})

A route middleware can name the route's own types, so SNSMiddleware<Order, OrderAttributes> reads request.body as an Order. Name both parameters even if you only touch the body, because next makes the type invariant.

Where an event does carry several records they run in parallel, so pass per-record values on each log call rather than reaching for appendKeys. See middleware for the execution order and the three levels it attaches at.

Types ​

All exported from @lambda-event-router/sns.

TypeDescription
SNSRequest<TBody, TMessageAttributes>The handler argument
SNSResponseHandler return type, undefined
SNSFiltersThe filters object
SNSFilterInputWhat custom receives
SNSRecordHandler<TBody, TMessageAttributes>The handler function
SNSRouteDefinition<TBody, TMessageAttributes>A full route passed to route()
SNSRouterOptionsOptions for createSNSRouter
SNSMiddleware<TBody, TMessageAttributes>Router and route middleware
SNSMessageAttributesRecord<string, SNSMessageAttributeValue>
SNSMessageAttributeValuestring | Buffer
SNSRawMessageAttributesThe attributes as AWS sends them, before conversion

The SNSRouter class and the createSNSRouter and defineRoute functions come from the same place.

Generic parameters ​

The three types above that take parameters take the same two, in the same order.

ParameterTypesDefault
TBodyrequest.bodyunknown
TMessageAttributesrequest.messageAttributesSNSMessageAttributes

Pass just the first and the second falls back to its default, so SNSRequest<Order> types the body and leaves message attributes loose. TMessageAttributes has to extend SNSMessageAttributes.

You only need these for annotated handlers. Inference covers both.

Code example ​

One topic carrying order events, with created and refunded notifications going to their own handlers.

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

File viewer
TSsns.ts

Each route matches a different subject, so no notification can match both and the order you register them in makes no difference. Both routes need the publisher to set a subject, so if that is not guaranteed, filter on a message attribute instead.

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.