Skip to content

AppSyncEventsRouter ​

AppSyncEventsRouter routes AWS AppSync Events requests to handlers, one publish or subscribe per invocation.

An Event API is a WebSocket broker: clients subscribe to a channel and publish to it, and AppSync fans each message out to whoever is listening. Attach a Lambda to a channel namespace and it gets asked about both, so your handler either transforms the messages going through or decides who may listen.

Install ​

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

@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 { createAppSyncEventsRouter } from '@lambda-event-router/appsync'
import { withRequestContext } from './middleware/withRequestContext'

const eventsRouter = createAppSyncEventsRouter({
  middleware: [withRequestContext],  // Optional
})

middleware is the only option and it can be left out. createAppSyncEventsRouter() on its own gives you a router with no shared middleware, see Middleware.

Register routes ​

ts
eventsRouter.route({
  filters: {
    operation: 'PUBLISH',
    channelPath: '/orders/*',
  },
  middleware: [withOrderContext],   // Optional
  handler: onOrderPublish,
})

filters and handler are required and middleware is optional. An empty filters: {} gives you a route that takes anything reaching it.

A route has no schema key, so validate the payloads you were sent inside the handler.

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

ts
eventsRouter.route(orderPublishRoute).route(orderSubscribeRoute)

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 request that matches no route throws No route matched for PUBLISH on channel /orders/eu. AppSync reads a failed invocation as a refusal, so the publish fails or the subscription is turned away. Cover both operations on every namespace you attach the Lambda to, and see nothing matched for what the other routers do instead.

Convenience methods ​

publish() and subscribe() fill in the operation filter and take the channel path at the top level rather than inside filters.

ts
// Both of these register the same route
eventsRouter.publish({
  channelPath: '/orders/*',
  handler: onOrderPublish,
})

eventsRouter.route({
  filters: { operation: 'PUBLISH', channelPath: '/orders/*' },
  handler: onOrderPublish,
})
MethodSets
publish()operation: 'PUBLISH'
subscribe()operation: 'SUBSCRIBE'

Both take handler exactly as route() does. What they drop is the rest of the filters: custom is the only key left, and channelPath moves out to the top level where it is required rather than optional. To match by namespace name instead, use the full route() form with channelNamespace. 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 requests you want and leave the rest off.

ts
import { isObject } from '@lambda-event-router/base'

eventsRouter.route({
  filters: {
    operation: ['PUBLISH', 'SUBSCRIBE'],
    channelPath: '/orders/*', // Or a pattern: /^\/orders\//
    channelNamespace: 'orders',
    custom: ({ event }) => {
      // Only a custom reaches the payloads
      const payload = event.events?.[0]?.payload

      return isObject(payload) && payload.type === 'refund'
    },
  },
  handler: onRefund,
})
FilterTypeDescription
operationAppSyncEventsOperation | AppSyncEventsOperation[]Matches PUBLISH or SUBSCRIBE. Compared exactly, so patterns and wildcards do nothing here
channelPathFilterStringMatcherMatches the channel path, so /orders/eu/refunds
channelNamespaceFilterStringMatcherMatches the namespace name, so orders, whatever the channel path underneath it looks like
custom(input: AppSyncEventsFilterInput) => boolean | Promise<boolean>Anything the other filters cannot express. Given operation, channelNamespace, channelPath and the raw event

channelPath and channelNamespace match different things. A namespace called orders publishes on /orders/..., so channelPath: '/orders/*' picks it out by the path and channelNamespace: 'orders' picks it out by the name. Reach for the path when you want one channel or a sub-tree, and the name when you want the whole namespace however its channels are laid out.

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

A custom here may be async. The router awaits its result, so a Promise<boolean> resolves before the route is decided and the route matches only when the filter resolves truthy. See custom for where it sits in the filter order.

Handler ​

Handlers take one argument and return what AppSync should do with the request.

ts
import type { AppSyncEventsRequest } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'

export async function onOrderPublish(request: AppSyncEventsRequest): Promise<unknown> {
  const { channelPath, events } = request
  logger.info(`Publishing ${events.length} events to ${channelPath}`)

  return { events }
}

Request object ​

FieldTypeDescription
channelPathstringThe channel path the request is for, such as /orders/eu/refunds
channelNamespacestringThe name of the namespace that channel belongs to
operationAppSyncEventsOperationPUBLISH or SUBSCRIBE
identityAppSyncEventsIdentity | null | undefinedWho the caller is. The shape follows the API's authorisation mode, and it is null where the API does not identify anyone
eventsAppSyncEventsPublishedEvent[]The published events, and empty on a SUBSCRIBE
info{ channel, channelNamespace, operation }The same three values as AppSync sends them, with channel holding its path and segments
request{ headers, domainName }The headers the client sent, and the custom domain the request arrived on
stashRecord<string, unknown>State shared across the functions of a pipeline
prev{ result: Record<string, unknown> } | nullWhat the function before yours returned in a pipeline
eventAppSyncEventsEventThe untouched event from AWS
contextContextThe Lambda context

Context comes from aws-lambda. Everything else here is declared by this package, since AppSync Events has no aws-lambda type of its own.

An event is a Record<string, unknown>. AppSync sends each one as an id and a payload, and the router passes them through without typing either, so narrow a payload with isObject from @lambda-event-router/base before reading into it.

Response type ​

A route asks only for a Promise<unknown>, so the return type is yours to name. What you return goes back to AppSync untouched, which Responses covers. AppSyncEventsPublishResult and AppSyncEventsSubscribeResult are the shapes AppSync accepts, so annotate your handler with one and the compiler holds you to it.

Published events ​

Each entry is an id AppSync minted and the payload the client published.

ts
handle(async ({ events }) => {
  for (const { id, payload } of events) { ... }
})

payload is a JsonValue, so it can be an object, an array, a string, a number, a boolean or null. AppSync turns away a publish whose event is not valid JSON, answering InvalidEventException with "Event should be a valid JSON string", so a handler is only ever given something already parsed.

Reach for payloadSchema when you want a shape rather than a JsonValue.

Payload schema ​

payloadSchema validates every published payload and types it, so a handler reads fields instead of narrowing a JsonValue by hand. Any Standard Schema library works.

ts
import { z } from 'zod'

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

eventsRouter.publish({
  channelPath: '/orders/*',
  payloadSchema: OrderSchema,
  handler: async ({ events }) => {
    for (const { id, total } of events.map(({ id, payload }) => ({ id, total: payload.total }))) {
      logger.info(`Order ${id} is worth ${total}`)
    }

    return { events: [] }
  },
})

One bad payload fails the whole publish. Validation throws SchemaValidationError on the first event that does not match, and a throw is a 502, so the rest of the batch is not delivered either. Where you want the good events through, leave the schema off and return an { id, error } entry for each one you reject.

payloadSchema works on route(), publish(), subscribe() and defineEventsRoute. Leave it off and payload is a JsonValue.

Inferred handlers ​

defineEventsRoute() hands your handler the request without you naming its type, so there is nothing to look up and nothing to keep in sync.

ts
import { defineEventsRoute } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'

export const orderPublishRoute = defineEventsRoute({
  filters: { operation: 'PUBLISH', channelPath: '/orders/*' },
}).handle(async ({ channelPath, events }) => {
  logger.info(`Publishing ${events.length} events to ${channelPath}`)

  return { events }
})

eventsRouter.route(orderPublishRoute)

The builder is defineEventsRoute, not defineRoute. The package holds three routers and defineRoute belongs to AppSyncRouter, so this one carries its own name. filters is optional on it, unlike on route().

A route carries no schema, so both forms hand the handler the same AppSyncEventsRequest. Inference still earns its keep 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 AppSyncEventsRequest.

ts
// handlers/orders.ts
import type { AppSyncEventsRequest } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'

export async function onOrderPublish({ channelPath, events }: AppSyncEventsRequest): Promise<unknown> {
  logger.info(`Publishing ${events.length} events to ${channelPath}`)

  return { events }
}
ts
// events.ts
import { createAppSyncEventsRouter } from '@lambda-event-router/appsync'

import { onOrderPublish } from './handlers/orders.js'

const eventsRouter = createAppSyncEventsRouter()

eventsRouter.publish({
  channelPath: '/orders/*',
  handler: onOrderPublish,
})

AppSyncEventsRequest takes no generic parameter, so there is one request type for every route and nothing to derive from a schema. What varies is the payloads you were sent, and those are yours to narrow. Annotated handlers has the worked version.

Responses ​

What a handler returns is sent to AppSync untouched, and what AppSync wants back depends on the operation.

A PUBLISH handler returns the events it wants delivered, as { events } with each entry carrying the id it arrived with. Subscribers get the payloads you hand back rather than the ones the client sent, so this is where a message gets transformed, dropped or rejected.

ts
import type { AppSyncEventsRequest } from '@lambda-event-router/appsync'
import { isObject } from '@lambda-event-router/base'

export async function onOrderPublish({ events }: AppSyncEventsRequest): Promise<unknown> {
  const stamped = events.map((event) => {
    if (!isObject(event.payload)) return { id: event.id, error: 'A payload has to be an object' }

    return { id: event.id, payload: { ...event.payload, receivedAt: new Date().toISOString() } }
  })

  return { events: stamped }
}
ReturnWhat AppSync does with it
{ events } carrying every eventDelivers all of them to the channel's subscribers
{ events } with one left outDelivers the rest and never sends the one you left out
{ events: [] }Drops the whole batch
An entry of { id, error }Fails that one event and delivers the others
{ error } at the top levelFails the whole publish
A throwFails the whole publish
An entry of { id, payload, error }Fails that event and throws the payload away
{ events, error } togetherFails the whole publish and throws the events away
{}, an entry of { id } alone, or a bare arrayRefused outright, and the publisher gets a 502

The response is checked strictly. An unknown field, on an entry or beside events, is refused the same way. Returning the array on its own is refused too, even though an AppSync JS resolver accepts it, so a Lambda handler always wraps it in { events }.

The publisher is told what the API accepted, not what you broadcast. Dropping a batch still answers successful for every event sent, and only the Lambda log shows it went nowhere.

Three ways of failing reach the publisher differently.

How you failWhat the publisher gets
An { id, error } entry200, and that event in failed under message, in your own words
{ error } at the top level403 and a CustomErrorException carrying your message
A throw502 and a DependencyFailedException reading Unable to process request

Only a throw keeps your message out of the caller's hands. The other two hand it over, so write them for whoever is publishing rather than for your own log.

A SUBSCRIBE handler decides whether the client may listen. Returning allows the subscription and throwing refuses it, and events is empty either way since nothing has been published yet.

ts
export async function onOrderSubscribe({ channelPath, identity }: AppSyncEventsRequest): Promise<void> {
  const groups = identity?.groups ?? []
  if (!groups.includes('staff')) {
    throw new Error(`${identity?.username} may not subscribe to ${channelPath}`)
  }
}

There is no response helper for a refusal, so the message you throw with is what lands in the Lambda error log rather than anything the client is shown.

Middleware ​

Router and route middleware are both typed AppSyncEventsMiddleware, and the chain runs once per request.

ts
import type { AppSyncEventsMiddleware } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'

export const withRequestContext: AppSyncEventsMiddleware = async (request, next) => {
  logger.appendKeys({ channelPath: request.channelPath })

  return next(request)
}
ts
const eventsRouter = createAppSyncEventsRouter({ middleware: [withRequestContext] })

eventsRouter.route({
  filters: { operation: 'PUBLISH', channelPath: '/orders/*' },
  middleware: [withOrderContext],
  handler: onOrderPublish,
})

Router middleware runs before route middleware, and both run before the handler. A middleware that does not call next short-circuits the chain, so the handler never runs. See middleware for the execution order and the three levels it attaches at.

Types ​

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

TypeDescription
AppSyncEventsRequestThe handler argument
AppSyncEventsPublishedEventOne published event, an id and a payload
AppSyncEventsOutgoingEventOne entry of a publish result, carrying a payload or an error
AppSyncEventsPublishResultWhat a PUBLISH handler returns
AppSyncEventsSubscribeResultWhat a SUBSCRIBE handler returns
AppSyncEventsEventThe event AppSync sends, which is also request.event
AppSyncEventsIdentityThe caller, on request.identity
AppSyncEventsOperation'PUBLISH' | 'SUBSCRIBE'
AppSyncEventsFiltersThe filters object
AppSyncEventsFilterInputWhat custom receives
AppSyncEventsMiddlewareRouter and route middleware
AppSyncEventsOperationFiltersThe filters object on publish() and subscribe(), which is custom on its own
AppSyncEventsRouteDefinitionA full route passed to route()
AppSyncEventsRouterOptionsOptions for createAppSyncEventsRouter
AppSyncEventsChannelInputA route passed to publish() or subscribe()
AppSyncPublishInput, AppSyncSubscribeInputAliases of AppSyncEventsChannelInput, one per method

The AppSyncEventsRouter class and the createAppSyncEventsRouter and defineEventsRoute functions come from the same place.

AppSyncEventsRequest, AppSyncEventsPublishedEvent, AppSyncEventsRouteDefinition and the route inputs take the payload type as a parameter, defaulting to JsonValue. A payloadSchema fills it in with the schema's output, so you rarely name it yourself.

AppSyncEventsMiddleware stays on the default. Middleware registers on the router as well as on a route, where no single route's schema applies. JsonValue comes from @lambda-event-router/base.

Code example ​

An Event API for an orders app. Publishes on /orders/* are stamped and their payloads checked, only staff may subscribe to them, and a /presence/* namespace alongside drops the heartbeats that arrive too late to be worth delivering.

Open a file: index.ts | events router | orders | presence

File viewer
TSevents.ts

Each route pins both an operation and a channel pattern, and the two namespaces do not overlap, so no request can match two routes and the order they are registered in makes no difference. Between them the four routes cover every request the two namespaces can send, which is what keeps a stray publish from failing on a route that was never registered.

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.