Handlers
A handler is the function that runs when an event matches a route. Your business logic lives here, and the rest of the library is there to get events to it already parsed and typed.
Handlers are always async and take a single argument. What they return is set by the event source. You can let defineRoute infer its types from the schemas on the route or name those types yourself, and both are first-class.
Requests
Each router hands your handler one object, typed as <Source>Request, and it arrives ready to use:
- JSON bodies and messages are parsed
- base64 payloads are decoded, and binary ones reach you as a
Buffer - Message attributes come through as real values, so a number is a number and binary is a
Buffer - HTTP headers are lowercased
- Schema output is applied, so a
z.coerce.boolean().default(false)reaches you as aboolean - The raw
eventorrecordand the Lambdacontextare always there as a fallback
An HTTP body reaches you as a Buffer when the request's Content-Type is not a text one, so an upload arrives as the bytes that were sent. See binary bodies for typing a route that takes them.
Record based routers call the handler once per record rather than once per batch, so a queue delivering ten messages runs your handler ten times and you write code for a single message.
// One record at a time, with the message body already parsed
async function processOrder({ body, messageAttributes, record, context }: SQSRequest<Order>) {
logger.info('Processing order', {
orderId: body.orderId, // parsed from the message JSON and typed by bodySchema
dryRun: messageAttributes.dryRun, // a real value, not the raw AWS attribute wrapper
attempt: record.attributes.ApproximateReceiveCount, // the raw record, for anything not lifted out
requestId: context.awsRequestId, // the Lambda context
})
}// Path and query params, headers and any authoriser claims, all on the one argument
async function updateOrder({
path,
query,
body,
headers,
auth,
}: ApiRequest<PathParams, QueryParams, OrderUpdate>) {
await orders.update(path.orderId, body, {
notify: query.notify === 'true',
idempotencyKey: headers['idempotency-key'], // headers are lowercased for you
actor: auth?.claims?.sub, // undefined unless an authoriser ran
})
// ...
}// Streams name the request after the event, so a modify carries both images
async function onOrderModified({ keys, newImage, oldImage }: DynamoDBModifyRequest<OrderKeys, Order, Order>) {
if (newImage.status === oldImage.status) return
await audit.record(keys.orderId, { from: oldImage.status, to: newImage.status })
}Field names are per router, and each router page documents its own.
Responses
What you return is limited to what the event source can do with it, which is why this varies more between routers than anything else. There are four shapes.
| Shape | Routers | What you write |
|---|---|---|
| Nothing | Record based, so SQS, SNS, Kinesis and the rest | Do the work and return |
| A body the router wraps | HTTP | Return a value, or a status code and body |
| The event, changed | Cognito | Change the event and return it |
| A result the router reports | CodePipeline, S3 Batch | Return the outcome, or nothing |
// SQSResponse is undefined, so there is nothing to hand back
async function adjustStock({ body }: SQSRequest<StockChange>): Promise<SQSResponse> {
const remaining = await inventory.adjust(body.sku, body.delta)
if (remaining <= body.reorderAt) {
await purchasing.reorder(body.sku, body.reorderQuantity)
}
}// Return a body and the router builds the response around it
async function getCustomerOrders({
path,
query,
}: ApiRequest<{ customerId: string }, { status?: string }>): Promise<HandlerResponse<OrderSummary[]>> {
const customerOrders = await orders.forCustomer(path.customerId, query.status ?? 'OPEN')
return customerOrders.map(({ orderId, total, placedAt }) => ({ orderId, total, placedAt }))
}// Change the event and hand it back
async function preSignUp({ event, userAttributes }: PreSignUpRequest): Promise<PreSignUpTriggerEvent> {
const domain = userAttributes.email?.split('@').at(1)
if (!domain || !ALLOWED_DOMAINS.has(domain)) {
throw new Error(`Sign up is not open to ${domain ?? 'addresses without a domain'}`)
}
event.response.autoConfirmUser = true
event.response.autoVerifyEmail = true
return event
}// Return the outcome and the router calls PutJobSuccessResult for you
async function runMigrations({
userParameters,
inputArtifacts,
}: CodePipelineRequest<{ schema: string }>): Promise<CodePipelineResponse> {
const [bundle] = inputArtifacts
const applied = await migrate(userParameters.schema, bundle)
return { outputVariables: { migrationsApplied: String(applied.length) } }
}Throwing
Throwing from a handler fails the invocation, and what that costs depends on the source. Record based routers report the failing record so it can be retried, and CodePipeline calls PutJobFailureResult before rethrowing. Each router page covers its own behaviour and the options that change it.
The HTTP and authorizer routers go further and return a thrown response as the response, so APIGatewayRouter, ALBRouter, VPCLatticeRouter, WebSocketRouter, LambdaAuthorizerRouter and AppSyncAuthorizerRouter.
import { Conflict, NotFound } from '@lambda-event-router/apigateway'
export async function cancelOrderHandler({ path }: ApiRequest<{ orderId: string }>): Promise<OrderCancelled> {
const order = await orders.get(path.orderId)
if (!order) {
throw NotFound({ error: `Order ${path.orderId} not found` })
}
if (order.status === 'SHIPPED') {
throw Conflict({ error: 'Shipped orders cannot be cancelled' })
}
await orders.update(order.orderId, { status: 'CANCELLED' })
await refunds.start(order)
await notifications.send(order.customerEmail, 'order-cancelled')
return { orderId: order.orderId, status: 'CANCELLED' }
}A throw carries the same weight from any depth, so business logic several calls below the handler can end the request without each function in between passing a failure back up.
The authorizer routers work the same way with Allow and Deny.
Anything else thrown from an HTTP handler becomes a 500 with the error message as the response body. Keep internal detail out of the messages your handlers can throw, because an error naming a connection string or an internal hostname puts it on the wire.
// A plain Error is not a response, so the router logs it and answers with a 500
export async function getOrderHandler({ path }: ApiRequest<{ orderId: string }>) {
const order = await orders.get(path.orderId) // Throws Error('connect ETIMEDOUT 10.0.1.5:8000')
return { orderId: order.orderId, status: order.status } // Does not get reached
}
// The caller gets { "statusCode": 500, "body": { "error": "connect ETIMEDOUT 10.0.1.5:8000" }}HTTP responses
A bare value becomes a 200 and the body is serialised for you, so these three are the same response:
import { Ok } from '@lambda-event-router/apigateway'
return { orderId: order.orderId }
return Ok({ orderId: order.orderId })
return { statusCode: 200, body: { orderId: order.orderId } }For any other code, use a helper or set it yourself, so Created(order) and { statusCode: 201, body: order } both give a 201. Ok, Created, NoContent, NotFound, BadRequest, Conflict and the rest are all exported from the router's package.
Empty returns become a 204 rather than a 200 with nothing in it.
| You return | You get |
|---|---|
| An object or an array | 200, with a JSON content type |
| A string, a number or another primitive | 200 |
undefined, null, '', true or {} | 204 |
Schema validation
Schemas are attached to the route rather than to the handler, and the validated output is what your handler receives, so defaults and coercion are applied before your code runs. See routing for where the schemas go and what a failure does.
const BlogEventSchema = z.object({
blogId: z.string(),
publishedAt: z.coerce.date(),
draft: z.coerce.boolean().default(false),
})
kinesisRouter.route({
filters: { eventSourceArn: BLOG_STREAM_ARN },
dataSchema: BlogEventSchema,
handler: async ({ data }) => {
// publishedAt is a Date and draft is a boolean, whatever the record held
if (data.draft) return
await search.index(data.blogId, { publishedAt: data.publishedAt })
},
})Inferred handlers
This section and the next process the same order queue, once each way, so you can compare them.
defineRoute reads the schemas on the route and hands your handler a typed request, so you never need to know the shape of any router's request object. No types to declare and nothing to keep in sync as the schemas change.
Open a file: routes/processOrder.ts | schemas/order.ts | sqs.ts | index.ts
This pays off in a Lambda taking several event sources. SQS, API Gateway and DynamoDB Streams hand their handlers completely different objects, and inference gets all three typed without you looking any of them up.
defineRoute is named define<Router>Route in packages holding more than one router, and defineEventRoute in base.
Annotated handlers
The same queue again, with the handler's types named rather than inferred. That puts an explicit type at the boundary and leaves the handler a plain function, at the cost of writing those types yourself.
Open a file: handlers/processOrder.ts | schemas/order.ts | sqs.ts | index.ts
Same four files, same schemas, same handler body. Both differ in one thing only, which is that SQSRequest<Order, OrderAttributes> names what defineRoute worked out for itself.
Derive those types from the schema with z.infer rather than writing an interface that mirrors it, so the two cannot drift.
A message attribute type is only accepted if every value is a string, a string[], a number or a Buffer.z.infer on OrderAttributesSchema gives { dryRun: boolean }, which SQSRequest rejects on its own. Intersecting it with SQSMessageAttributes satisfies the constraint and leaves dryRun as a boolean. Inferred handlers never meet this, because defineRoute applies the intersection for you.
Most routers take the payload first and its attributes second, the way SQSRequest<TBody, TMessageAttributes> does, and later parameters have defaults so passing only the first leaves the rest alone. Each router page lists its own parameters in order.