-
Notifications
You must be signed in to change notification settings - Fork 0
Lambda adapter
The Lambda adapter turns a toolkit Adapter into a single AWS Lambda handler that serves the standard REST route pack behind API Gateway REST (payload 1.0), API Gateway HTTP (payload 2.0), Lambda Function URLs, and ALB target groups — auto-detecting the event shape on every invocation:
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';Shared behavior — the route table, common options (policy, sortableIndices, keyFromPath, exampleFromContext, maxBodyBytes), response envelopes, error mapping, composite keys, and per-tenant filtering — is documented on Framework adapters. This page covers what is specific to Lambda: event-shape detection and multi-value header mirroring, the local debug bridges, body reading, error-envelope guarantees, mountPath, and platform caps.
The adapter ships inside the core package — no extra install and no framework peer dependency. If you author handlers in TypeScript, add @types/aws-lambda as a dev dependency — the adapter exports types that reference aws-lambda symbols but doesn't require them at runtime.
// src/handler.js
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
const ddb = DynamoDBDocumentClient.from(
new DynamoDBClient({region: process.env.AWS_REGION}),
{marshallOptions: {removeUndefinedValues: true}}
);
const planets = new Adapter({client: ddb, table: process.env.PLANETS_TABLE, keyFields: ['name']});
export const handler = createLambdaAdapter(planets, {mountPath: '/planets'});Everything outside handler runs once per container (cold start) and is reused across warm invocations — the DynamoDBDocumentClient, the Adapter, the policy merge, the factory. Keep the top of the module light: AWS SDK import + one client + one adapter is the fast path.
Config fragments are shown as plain CloudFormation / SAM YAML — port to your IaC of choice. API Gateway HTTP API (payload 2.0) is the modern default: flat envelope, native cookies, cheapest per-request.
# template.yaml (SAM)
PlanetsFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ./
Handler: src/handler.handler
Runtime: nodejs20.x
Environment:
Variables:
PLANETS_TABLE: !Ref PlanetsTable
Events:
AnyPlanets:
Type: HttpApi
Properties:
Path: /planets/{proxy+}
Method: ANY
RootPlanets:
Type: HttpApi
Properties:
Path: /planets
Method: ANYTwo events are needed because {proxy+} doesn't match the mount root /planets itself — only /planets/.... Both arrive at the same Lambda and the adapter's mountPath: '/planets' strips them consistently.
The other triggers use the same factory and the same export:
-
Lambda Function URL — same payload 2.0 format; skip the API Gateway layer entirely. Drop
mountPathwhen the function URL is the only thing routing to this Lambda — the adapter then owns/directly:export const handler = createLambdaAdapter(planets); // no mountPath // Function URL: https://abc123.lambda-url.us-east-1.on.aws/earth
-
API Gateway REST API (payload 1.0) — the adapter auto-detects the 1.0 payload (v1 events carry
httpMethod+path, notversion: '2.0') and emitsAPIGatewayProxyResult— the right envelope shape. -
ALB target group — auto-detected via the
event.requestContext.elbmarker. Flip the target group'slambda.multi_value_headers.enabledattribute totrueif downstream clients rely on duplicated headers (e.g. multipleSet-Cookie). The adapter mirrors whichever shape the trigger delivers — no extra configuration on the handler side.
See Event shapes for the detection rules and per-shape gotchas.
Assuming the API Gateway stage is at https://api.example.com/prod and the mount is /planets:
# Create
curl -X POST https://api.example.com/prod/planets/ \
-H 'content-type: application/json' \
-d '{"name":"earth","mass":5.97,"climate":"temperate"}'
# Read one
curl https://api.example.com/prod/planets/earth
# Partial update (PATCH merges, does not replace)
curl -X PATCH https://api.example.com/prod/planets/earth \
-H 'content-type: application/json' \
-d '{"population":8.2e9}'
# List with paging
curl 'https://api.example.com/prod/planets/?offset=0&limit=25'
# Delete
curl -X DELETE https://api.example.com/prod/planets/plutoStage prefixes (/prod) are stripped by the trigger before the Lambda sees the event, so the adapter's mountPath shouldn't include them. See mountPath.
Minimum DynamoDB permissions for the Lambda execution role:
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:BatchGetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:BatchWriteItem
- dynamodb:Query
- dynamodb:Scan
- dynamodb:TransactWriteItems
Resource:
- !GetAtt PlanetsTable.Arn
- !Sub '${PlanetsTable.Arn}/index/*'Prune by route — e.g. drop BatchWriteItem + DeleteItem if you never expose DELETE / or /-by-names.
Don't round-trip to AWS while iterating. Spin up a local listener that speaks the exact Lambda event shape:
import http from 'node:http';
import {createNodeListener} from 'dynamodb-toolkit/lambda/local.js';
import {handler} from './src/handler.js';
http.createServer(createNodeListener(handler)).listen(3000);node local-server.js
curl http://localhost:3000/planets/earthDetails, Fetch-runtime variants, and Koa / Express glue: Local debug bridges.
AWS Lambda delivers HTTP-shaped events through four different payload formats depending on the trigger. The adapter auto-detects the shape on each invocation and returns the matching result envelope — a single handler works for all of them.
The factory's returned handler sniffs the shape from the event itself:
// src/http/lambda/index.js
const detectKind = event => {
if (event?.requestContext?.elb) return 'alb';
if (event?.version === '2.0') return 'v2';
return 'v1';
};-
ALB events carry
event.requestContext.elb(the target-group ARN marker). Checked first because an ALB event might otherwise look like v1. -
v2 / Function URL events carry
event.version === '2.0'— the payload-format marker added by API Gateway HTTP API and Lambda Function URLs. - Everything else is treated as API Gateway REST v1.
Unrecognized events fall through as v1; feeding the handler a bare object that doesn't look like any of the three will produce garbage, but there's no sniff for "this isn't an HTTP event at all." The handler is designed for HTTP triggers.
Each event shape has a matching result shape. The adapter writes the right one:
| Event shape | Type (aws-lambda) |
Envelope fields |
|---|---|---|
| API Gateway REST v1 | APIGatewayProxyResult |
{statusCode, body, headers?, multiValueHeaders?, isBase64Encoded?} |
| API Gateway HTTP v2 / Function URL | APIGatewayProxyStructuredResultV2 |
{statusCode, body, headers?, cookies?, isBase64Encoded?} |
| ALB | ALBResult |
{statusCode, statusDescription?, body, headers?, multiValueHeaders?, isBase64Encoded?} |
The three overlap on {statusCode, body, headers?, multiValueHeaders?}, so the adapter emits a single shape that satisfies whichever envelope the trigger expects. Consumers rarely have to narrow the union.
| Concept | API Gateway v1 | API Gateway v2 / Function URL | ALB |
|---|---|---|---|
| Method | event.httpMethod |
event.requestContext.http.method |
event.httpMethod |
| Path |
event.path (stage prefix stripped) |
event.rawPath |
event.path (listener path) |
| Query (flat) |
event.queryStringParameters (may be null) |
event.queryStringParameters (may be null) |
event.queryStringParameters (may be null) |
| Query (multi) | event.multiValueQueryStringParameters |
n/a (HTTP API collapses duplicates with commas) |
event.multiValueQueryStringParameters (multi-value mode only) |
| Body | event.body: string | null |
event.body: string | null |
event.body: string | null |
| Body base64? | event.isBase64Encoded |
event.isBase64Encoded |
event.isBase64Encoded |
| Headers |
event.headers + event.multiValueHeaders
|
event.headers (comma-joined duplicates) |
event.headers OR event.multiValueHeaders (exclusive) |
| Cookies | event.headers.cookie |
event.cookies: string[] |
event.headers.cookie |
| Path stripping | API Gateway strips stage before dispatch | HTTP API strips stage automatically | None — raw listener path passes through |
| Response shape | APIGatewayProxyResult |
APIGatewayProxyStructuredResultV2 |
ALBResult with multiValueHeaders when mirrored |
Pagination links need the caller's full path + query so clients can follow next / prev without reconstructing a base URL. The adapter preserves whatever the event delivered:
// src/http/lambda/index.js (simplified)
const urlBuilderFor = (event, kind) => ({offset, limit}) => {
const originalPath = kind === 'v2' ? event.rawPath : event.path;
const sp = new URLSearchParams(serializeQuery(event, kind));
sp.set('offset', String(offset));
sp.set('limit', String(limit));
const out = sp.toString();
return out ? `${originalPath}?${out}` : originalPath;
};-
v2 uses
rawQueryStringdirectly — the order and encoding match the original request byte-for-byte. -
v1 / ALB iterate
multiValueQueryStringParameterswhen available (preserves duplicates), elsequeryStringParameters(first-value collapse).
Paths include mountPath — pagination links remain valid for clients traversing the mounted adapter.
ALB and API Gateway REST v1 both support a multi-value-headers mode:
-
Request — every header is delivered as a
string[]inevent.multiValueHeaders,event.headersis stampednull, and duplicate headers (Set-Cookie, repeated custom headers) survive. -
Response — the trigger strictly requires the response in the same shape: return
result.multiValueHeaders, notresult.headers. Mixing raises a platform error.
The adapter detects this case and flips the response shape automatically:
// src/http/lambda/index.js
const wantsMultiValueHeaders = (event, kind) => {
if (kind === 'v2') return false;
return !!(event.multiValueHeaders && event.headers === null);
};Auto-detection means the handler is the same regardless of trigger — no flag, no per-shape factory. The only knob is on the trigger side (for ALB: lambda.multi_value_headers.enabled; for API Gateway REST: the stage's "Use Multi Value Headers" box).
API Gateway v1 without the multi-value mode delivers both forms (headers populated + multiValueHeaders populated), and accepts either on the response — the adapter defaults to headers for simpler downstream logging. API Gateway HTTP v2 / Function URL have no multi-value mode at all; duplicate request headers collapse to a comma-joined value and the response always uses headers.
Cookies arrive in different places per shape:
-
v1 / ALB — in
event.headers.cookie(orevent.multiValueHeaders.cookiein multi-value mode).Cookie: a=1; b=2stays as one header. -
v2 / Function URL — in
event.cookies: string[]. Each array entry is onename=valuepair, pre-split by the trigger.
The adapter flattens v2's event.cookies into event.headers.cookie before dispatching so exampleFromContext sees one consistent shape:
// src/http/lambda/index.js
const flattenV2Cookies = event => {
if (!event.cookies || !event.cookies.length) return;
const headers = event.headers || (event.headers = {});
const joined = event.cookies.join('; ');
headers.cookie = headers.cookie ? `${headers.cookie}; ${joined}` : joined;
};The mutation is in-place and harmless — Lambda events are not reused across invocations. Reading event.headers.cookie works uniformly; you never need to branch on event shape for cookies.
Response cookies (setting cookies on the way out) aren't something the adapter does for its own routes — it's a stateless REST handler. If you layer cookies on top (wrapping the handler), set them per event shape: result.multiValueHeaders['set-cookie'] on ALB/v1-multi, result.headers['set-cookie'] on v1-single, and result.cookies on v2.
All four shapes carry queryStringParameters with a first-value-wins flattening. v1 / ALB (in multi-value mode) additionally carry multiValueQueryStringParameters with the full arrays. The adapter builds the parser input with a first-value-wins policy, matching the other adapters:
// src/http/lambda/index.js (simplified)
const coerceQuery = (event, kind) => {
const out = {};
if (kind !== 'v2' && event.multiValueQueryStringParameters) {
for (const [k, vs] of Object.entries(event.multiValueQueryStringParameters)) {
if (vs?.length && !(k in out)) out[k] = vs[0];
}
}
if (event.queryStringParameters) {
for (const [k, v] of Object.entries(event.queryStringParameters)) {
if (!(k in out)) out[k] = v;
}
}
return out;
};Why prefer multiValueQueryStringParameters when both are present? Because AWS sometimes populates only one of the two on ALB — the adapter reads multi-value first so duplicated entries resolve consistently, then falls back to the single-value bag.
If you need all values for a repeated query param (e.g. ?tag=a&tag=b), read them yourself from event.multiValueQueryStringParameters inside exampleFromContext. The toolkit's route handlers only need single-value semantics.
Lambda bodies arrive as strings. Binary bodies (and sometimes text bodies, depending on trigger config) are base64-encoded with event.isBase64Encoded: true. The body reader decodes transparently and enforces maxBodyBytes on the decoded bytes, so a 5 MB JSON body that arrives as a 6.7 MB base64 string correctly triggers the cap set at 5 MB. See Body reading.
-
Custom authorizers / IAM. The trigger handles auth before the Lambda fires. Claims arrive on
event.requestContext.authorizer.*and are available toexampleFromContextfor tenant scoping — seeexampleFromContextbelow and Framework adapters. -
CORS preflights. API Gateway HTTP API has native CORS config; add it there. On ALB / REST, attach a wrapper that handles
OPTIONS /*before delegating to the adapter. - Cold-start warmup. The factory runs on import. Everything expensive (DynamoDB client, policy merge, keyFromPath) is captured in the closure and reused across warm invocations.
-
Binary response bodies. The adapter only emits JSON responses — bodies are already UTF-8 strings. If you layer a handler that emits binary output, set
isBase64Encoded: trueand encode yourself; the adapter doesn't know about binary responses.
AWS ships sam local, sam-cli, and assorted Docker-based emulators for Lambda local debugging. They work, but they're heavy — Docker image pulls, per-invocation container startup, 2–5 second cold starts on every request. The toolkit ships two zero-dep bridges so the exact Lambda handler runs on localhost against real HTTP traffic with no Docker, no AWS CLI, and no deploy cycle.
-
createNodeListener(handler, options?)— returns a(req, res) => Promise<void>forhttp.createServer, sonode:http(and any Node HTTP framework that accepts a raw request listener) drives the Lambda code path. -
createFetchBridge(handler, options?)— returns a(request) => Promise<Response>for Fetch-style runtimes: Bun.serve, Deno.serve, Cloudflare Workers, Hono, itty-router.
Both bridges synthesize a full API Gateway event (v1 or v2) from each incoming HTTP request, invoke the handler, and translate the Lambda result envelope back into the runtime's native HTTP response. Same module, same export path, same shape options.
import {createNodeListener, createFetchBridge} from 'dynamodb-toolkit/lambda/local.js';Note the .js in the subpath: the bridges live at dynamodb-toolkit/lambda/local.js, a separate entry point so the main module stays lean — dynamodb-toolkit/lambda (the Lambda handler) never pulls in the local-server plumbing; only /local.js does.
A standalone dev server:
import http from 'node:http';
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
import {createNodeListener} from 'dynamodb-toolkit/lambda/local.js';
const handler = createLambdaAdapter(planets, {mountPath: '/planets'});
http.createServer(createNodeListener(handler)).listen(3000);
console.log('Listening on http://localhost:3000');curl http://localhost:3000/planets/earth
curl -X POST http://localhost:3000/planets/ -H 'content-type: application/json' -d '{"name":"mars","mass":0.642}'The listener reads the incoming request, builds an API Gateway v2 event by default (pass {eventShape: 'v1'} to test v1-specific paths — httpMethod + path + multiValueQueryStringParameters), invokes the handler, and writes the Lambda envelope back through the ServerResponse.
For Fetch runtimes:
// Bun
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
import {createFetchBridge} from 'dynamodb-toolkit/lambda/local.js';
const handler = createLambdaAdapter(planets);
Bun.serve({port: 3000, fetch: createFetchBridge(handler)});// Deno
import {createLambdaAdapter} from 'npm:dynamodb-toolkit/lambda';
import {createFetchBridge} from 'npm:dynamodb-toolkit/lambda/local.js';
const handler = createLambdaAdapter(planets);
Deno.serve({port: 3000}, createFetchBridge(handler));// Cloudflare Workers
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
import {createFetchBridge} from 'dynamodb-toolkit/lambda/local.js';
const handler = createLambdaAdapter(planets);
export default {fetch: createFetchBridge(handler)};// Hono (Fetch-based)
import {Hono} from 'hono';
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
import {createFetchBridge} from 'dynamodb-toolkit/lambda/local.js';
const bridge = createFetchBridge(createLambdaAdapter(planets, {mountPath: '/planets'}));
const app = new Hono();
app.all('/planets/*', c => bridge(c.req.raw));
app.get('/health', c => c.json({ok: true}));Both bridges take the same shape:
interface LocalDriverOptions {
eventShape?: 'v1' | 'v2'; // default 'v2'
context?: Context; // fixed per-invocation context
makeContext?: () => Context; // factory for per-invocation context
}eventShape
-
'v2'(default) — API Gateway HTTP v2 / Function URL shape.event.version === '2.0',event.rawPath,event.requestContext.http.method, cookies inevent.cookies. -
'v1'— API Gateway REST v1 shape.event.httpMethod,event.path,event.multiValueHeaders,event.multiValueQueryStringParameters. Use this when your production deployment is v1 and you want the dev bridge to match.
ALB simulation is not provided — ALB events are v1-shaped plus event.requestContext.elb. If you really need ALB-shape simulation, wrap the bridge and stamp elb onto the event before it hits the handler.
context / makeContext
Control the Lambda Context object the handler sees. By default, each invocation gets a minimal local-* context with a random awsRequestId, a local function name, and getRemainingTimeInMillis() returning 30000.
// Fixed context -- useful for tests asserting on awsRequestId.
createNodeListener(handler, {
context: {
awsRequestId: 'test-12345',
functionName: 'planets',
functionVersion: '$LATEST',
invokedFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:planets',
memoryLimitInMB: '128',
logGroupName: '/aws/lambda/planets',
logStreamName: '2026/04/20/[$LATEST]abc',
callbackWaitsForEmptyEventLoop: false,
getRemainingTimeInMillis: () => 30000,
done: () => {}, fail: () => {}, succeed: () => {}
}
});
// Per-invocation factory -- different awsRequestId each time.
createNodeListener(handler, {
makeContext: () => ({
awsRequestId: crypto.randomUUID(),
functionName: 'planets',
// ...
})
});If both context and makeContext are passed, context wins.
The bridges don't depend on Koa or Express. Use createNodeListener + 10 lines of glue — no second integration library required.
Koa:
import Koa from 'koa';
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
import {createNodeListener} from 'dynamodb-toolkit/lambda/local.js';
const listener = createNodeListener(createLambdaAdapter(planets, {mountPath: '/planets'}));
const app = new Koa();
app.use(async ctx => {
await listener(ctx.req, ctx.res);
ctx.respond = false; // tell Koa we've handled the response directly
});
app.listen(3000);Setting ctx.respond = false is the idiomatic Koa way to say "I've already called res.end(), don't touch the response." Without it, Koa overwrites the body with an empty 404.
Express:
import express from 'express';
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
import {createNodeListener} from 'dynamodb-toolkit/lambda/local.js';
const listener = createNodeListener(createLambdaAdapter(planets, {mountPath: '/planets'}));
const app = express();
app.use((req, res) => listener(req, res));
app.listen(3000);No ctx.respond = false equivalent needed — Express doesn't double-handle a response once res.end() is called. If you want the adapter's routes to coexist with other Express routes, mount it at a path prefix:
app.use('/planets', (req, res) => listener(req, res));…and drop mountPath from the adapter itself so it sees the path segment after Express has stripped the prefix.
If you're running a long-lived Node server in production, use the toolkit's dedicated framework adapters instead — see Framework adapters; all adapters can share the same underlying Adapter instance, so only the I/O layer differs. The local bridges are for the case where your Lambda is the production handler and you just want to test it via HTTP locally.
| Runtime | Bridge | Notes |
|---|---|---|
| Node 20+ | createNodeListener |
http.createServer(listener).listen(port). |
| Bun | createFetchBridge |
Bun.serve({port, fetch: bridge}). |
| Deno | createFetchBridge |
Deno.serve({port}, bridge). |
| Cloudflare Workers | createFetchBridge |
export default {fetch: bridge}. |
| Hono / itty-router | createFetchBridge |
Pass the raw Request. |
| Koa / Express (as glue) | createNodeListener |
10 lines of glue; see above. |
The bridges' only node:* import is node:buffer (base64 / bytes conversion). Bun and Deno ship node:buffer as a compat shim; Cloudflare Workers ship it as part of the nodejs_compat flag.
-
Path + method + query — byte-perfect for v2 (
rawPath,rawQueryString). v1 gets bothqueryStringParametersandmultiValueQueryStringParameters. -
Headers — duplicate headers become arrays in
multiValueHeaders(v1), comma-joined (v2). -
Cookies — v1 keeps them in
headers.cookie; v2 splits them intoevent.cookies: string[]the way API Gateway HTTP does. The adapter then flattens v2'scookiesback intoheaders.cookieinternally, matching prod behavior. -
Binary request bodies —
content-typesniffed:text/*,application/json,application/xml,application/x-www-form-urlencoded,*+json,*+xmlpass as strings; everything else is base64-encoded withisBase64Encoded: true, matching API Gateway's default binary-media-types behavior. -
Response envelope — the bridge reads
statusCode,body,headers,multiValueHeaders,cookies(v2), andisBase64Encoded, then writes them to the native HTTP response.
-
Authorizer claims —
event.requestContext.authorizeris not populated. If yourexampleFromContextrelies on JWT / IAM / Lambda-authorizer claims, seed them yourself: a clean pattern is to read claims from a fallback header (event.headers['x-tenant-id']) that only the dev bridge populates — production uses the real authorizer, dev uses the header. -
Platform caps — no 6 MB / 10 MB / 1 MB pre-reject. Your
maxBodyBytesstill applies; platform caps are the trigger's job. When deploying, test platform caps in a real AWS environment. - Cold-start latency — everything is warm. Cold-start simulation requires re-importing the module, which the bridges don't do.
-
Timeout enforcement — the bridge's
context.getRemainingTimeInMillis()always returns a static 30000 (or whatever you pass viacontext). No actual kill-at-timeout. Test long-running behavior in a staging environment. - Lambda Destinations / DLQ — the bridge always returns the handler's result. Failures that would normally trigger Destinations simply return their HTTP envelope on localhost.
The adapter reads JSON bodies from Lambda events with a single-pass size guard plus base64 decoding. No streams — Lambda delivers the body as an already-buffered string. The flow, in order:
-
event.bodyisnullor empty — the parsed body isnull(no payload). -
event.isBase64Encodedistrue— the body is base64-decoded first, and the decoded byte length is checked againstmaxBodyBytes(413when over). - Otherwise the UTF-8 byte length of the string is checked against
maxBodyBytes(413when over). - Non-empty text is parsed with
JSON.parse; a parse failure yields400 BadJsonBody.
The fetch adapter uses a Content-Length fast-path plus a mid-stream byte counter. Lambda doesn't need either — bodies are already fully materialized strings by the time the handler runs, so a single length check before JSON.parse is enough. The reader is much simpler and does less memory shuffling.
Lambda's own platform caps back the adapter's maxBodyBytes — over-cap requests never reach the handler at all, they get rejected at the trigger with an AWS-specific error. See Platform body caps.
const handler = createLambdaAdapter(planets); // 1 MiB by defaultmaxBodyBytes defaults to 1048576 — matching the toolkit's other adapters. Override per adapter:
// Low-power device endpoint -- cap at 64 KiB
createLambdaAdapter(planets, {maxBodyBytes: 64 * 1024});
// Dedicated bulk-load -- allow up to the trigger's cap
createLambdaAdapter(bulk, {maxBodyBytes: 5 * 1024 * 1024});maxBodyBytes is a tenant-level cap on top of the platform cap. Keep it ≤ the trigger's cap so the adapter's structured 413 response fires before AWS's raw platform error.
Lambda base64-encodes bodies in several situations:
- API Gateway REST v1 with "Use Lambda Proxy Integration" + binary media types matching the request's Content-Type.
-
API Gateway HTTP v2 always delivers bodies as strings; binary-typed payloads are base64-wrapped with
event.isBase64Encoded: true. - Function URL same as HTTP v2.
- ALB depends on the target group's "binary media types" config; default is always base64 for non-text.
The reader handles this transparently — decode once, then check bytes vs. the cap. The cap is enforced on decoded bytes, so a 5 MB JSON body sent as 6.7 MB of base64 correctly fires a 413 set at 5 MB.
For the local-debug bridges, the same rule applies: createNodeListener / createFetchBridge decide whether to base64-wrap based on the request's Content-Type (text-ish — string, binary — base64). See Local debug bridges.
| Condition | Status | code |
|---|---|---|
Decoded body exceeds maxBodyBytes
|
413 |
PayloadTooLarge |
| Body is not valid JSON | 400 |
BadJsonBody |
PUT /-load body is not an array |
400 |
BadLoadBody |
Body is null for requests with no payload (empty body on POST, or GET-shaped requests).
When one Lambda mounts multiple adapters and you want different caps per route prefix, pass maxBodyBytes per instance. Each adapter holds its own cap; they don't share state:
const planets = createLambdaAdapter(planetsAdapter, {mountPath: '/planets', maxBodyBytes: 256 * 1024});
const bulk = createLambdaAdapter(bulkAdapter, {mountPath: '/bulk', maxBodyBytes: 5 * 1024 * 1024});
// Dispatch yourself based on event path:
export const handler = async (event, context) => {
const path = event.rawPath ?? event.path ?? '';
if (path.startsWith('/planets')) return planets(event, context);
if (path.startsWith('/bulk')) return bulk(event, context);
return {statusCode: 404, body: ''};
};GET / and DELETE / don't read the body. DELETE /-by-names reads the body only when ?names= is absent. All other body-capable routes always read. The adapter's dispatch logic decides whether to read the body based on the route; unused body bytes stay in the event object until the invocation ends.
This matters for cold-start billing more than for memory: a 5 MB event delivered to a GET / route still counts against the Lambda's event size limit, even though the handler never reads the body.
None. Unlike the express adapter, Lambda has no body-parser middleware to defer to — the event is the full payload. The adapter always reads from event.body directly.
If you wrap the handler (e.g. a custom middleware layer) and that wrapper consumes the event before delegating, pass the original event through — the body field is a plain string, so no "stream consumed" hazards exist the way they do with Fetch's Request.body.
The adapter wraps every route handler in a try / catch. Thrown errors are mapped to a status code and serialized into a JSON body. Callers never see stack traces, raw SDK errors, or — critically — the generic AWS Lambda error envelope that fires when an unhandled exception escapes the handler.
Because the dispatch is inherently async and the adapter wraps it in try/catch, there is no escape route — the adapter always returns a Lambda result envelope, never throws out of the top level. This is deliberate: an unhandled throw lets AWS emit its own generic error response ({"message":"Internal server error"} at 502 on API Gateway, a non-2xx on ALB), which is opaque to clients and breaks structured error contracts.
An error object with a status property in the 400–599 range passes through to the response as-is; everything else routes through the shared status buckets (policy.statusCodes) and the shared error-body builder (policy.errorBody) — both documented on Framework adapters. Body-reading failures (413 PayloadTooLarge, 400 BadJsonBody, 400 BadLoadBody) flow through the same path — see Body reading.
policy.errorBody receives only err; neither the event nor the Lambda context is threaded through. If you need context.awsRequestId or event data in the error body, wrap the handler:
import {createLambdaAdapter} from 'dynamodb-toolkit/lambda';
const inner = createLambdaAdapter(adapter);
export const handler = async (event, context) => {
const result = await inner(event, context);
// Enrich non-2xx JSON bodies with the Lambda request ID.
const ct = result.headers?.['content-type'] ?? '';
if (result.statusCode >= 400 && ct.includes('application/json') && result.body) {
try {
const body = JSON.parse(result.body);
return {
...result,
body: JSON.stringify({...body, requestId: context.awsRequestId})
};
} catch {
// Leave non-JSON 4xx/5xx bodies alone.
}
}
return result;
};This keeps the adapter's default error-body logic intact and layers the Lambda request ID on top. Clients can quote requestId in a bug report — you find the Lambda invocation in CloudWatch with no hunting.
Empty 404 with no body. Lambda has no equivalent of the fetch adapter's onMiss composition hook — the handler is always terminal.
If you want a custom 404 body, wrap the handler and substitute it:
const inner = createLambdaAdapter(adapter, {mountPath: '/planets'});
export const handler = async (event, context) => {
const result = await inner(event, context);
if (result.statusCode === 404 && (!result.body || result.body === '')) {
return {
statusCode: 404,
body: JSON.stringify({code: 'NotFound', path: event.rawPath ?? event.path}),
headers: {'content-type': 'application/json; charset=utf-8'}
};
}
return result;
};Or set policy.statusCodes.miss if you only want to change the item-not-found response on GET /:key (the adapter-level miss path, not the off-route path).
Since the adapter always returns a valid result envelope, Lambda Destinations / DLQ on failure never fire for adapter-routed requests. If you want to trigger them on, say, consistency errors, you must re-throw from a wrapper after inspecting the result — but don't do this by default. Returning a 4xx/5xx HTTP response with a structured body is the right behavior for HTTP-fronted Lambdas; DLQ is for internal event-driven work.
createLambdaAdapter(adapter, options?) takes the shared adapter options — policy, sortableIndices, keyFromPath, exampleFromContext, maxBodyBytes — documented on Framework adapters, plus one Lambda-only option: mountPath. All options are optional. The factory is generic in TItem so a typed Adapter<Item, Key> flows through to the callbacks without casts.
Path prefix the adapter owns. Stripped from the incoming path before route matching.
Default: unset — the adapter sees the full event path and treats it as rooted at /.
// Function URL -- Lambda owns the whole URL.
const handler = createLambdaAdapter(planets);
// Mounted under /planets -- GET /planets/earth -> adapter sees GET /earth.
const handler = createLambdaAdapter(planets, {mountPath: '/planets'});Match rules:
- Exact match (
eventPath === mountPath) — route is/. - Prefix match (
eventPath.startsWith(mountPath + '/')) — route iseventPath.slice(mountPath.length). - Anything else — empty
404.
Matching is case-sensitive. No trailing-slash normalization beyond what the trigger does to the event path.
Stage prefixes are the trigger's job. API Gateway stage variables (/prod, /dev) are stripped before the Lambda sees the event — event.path on v1 is already /planets/earth, not /prod/planets/earth. Set mountPath to the adapter's logical root (/planets), not the full external URL.
ALB doesn't strip anything — the path on the event is whatever the client sent. Keep mountPath aligned with the listener-rule path-pattern if the rule routes a subtree.
If the Lambda owns every path under its trigger (typical for Function URLs and single-resource deployments), omit mountPath — the adapter matches routes against the full event path: event.path on v1 / ALB, event.rawPath on v2.
The shared hook (see Framework adapters) receives its usual options bag — {query, body, adapter, framework} — with framework set to 'lambda' and two Lambda-only fields added:
-
event— the full Lambda event. v1 / v2 / ALB shapes all pass through; reach for whatever your trigger exposes:- v1:
event.requestContext.identity(caller IAM / IP),event.requestContext.authorizer(custom authorizer claims),event.headers. - v2 / Function URL:
event.requestContext.authorizer.lambda(Lambda authorizer claims),event.requestContext.authorizer.jwt.claims(JWT authorizer),event.headers. v2 cookies are already flattened intoevent.headers.cookiebefore this hook runs — read cookies uniformly regardless of trigger. - ALB:
event.requestContext.elb(target-group ARN for multi-env routing),event.headers. No built-in authorizer claims; IAM / auth happens outside ALB.
- v1:
-
context— the LambdaContext:context.awsRequestId,context.invokedFunctionArn,context.getRemainingTimeInMillis(), etc. Useful for correlation IDs and deadline-aware operations.
The framework: 'lambda' discriminator lets a tenant-scoping callback shared across the koa / express / fetch / lambda adapters branch on the host when it needs to.
maxBodyBytes (shared; default 1 MiB) is the tenant-level cap enforced at the adapter layer before JSON.parse runs. Lambda also enforces platform-level caps, and they're different per trigger:
| Trigger | Platform cap |
|---|---|
| API Gateway REST (v1) | 10 MB (sync), 6 MB via SDK |
| API Gateway HTTP (v2) | 6 MB |
| Lambda Function URL | 6 MB |
| ALB | 1 MB (default), configurable up to tens of MB |
Keep maxBodyBytes ≤ the trigger's platform cap; the adapter cap fires first and returns a clean JSON 413 instead of AWS's raw platform error. Requests over the platform cap never reach the handler at all — they get rejected at the trigger with an AWS-specific error.
The adapter targets AWS Lambda's Node runtime (Node 20+). nodejs20.x and nodejs22.x are supported, as are Lambda container images on any base image from the supported Node runtime list. nodejs18.x is end-of-life on AWS Lambda.
AWS Lambda's nodejs20.x runtime image ships Node 20.15 at the time of writing, which predates both require(esm) interop (20.19+ on the 20.x line, 22.12+ on 22.x, unflagged everywhere newer) and native TypeScript (22.6+ with --experimental-strip-types, 23.6+ unflagged). Use ESM for handler modules (the "type": "module" flag in package.json) and compile TypeScript ahead of deployment.
Outside Lambda, require('dynamodb-toolkit/lambda') works on Nodes that ship require(esm):
const {createLambdaAdapter} = require('dynamodb-toolkit/lambda');
const {createNodeListener} = require('dynamodb-toolkit/lambda/local.js');ESM is the recommended default on Lambda — the AWS CLI / IaC tooling supports it natively.
node:buffer is the only node:* import at runtime, and only inside the body reader (read-lambda-body.js) and local.js. The main handler module has no other Node-specific imports — it runs verbatim on any runtime with a reasonable Buffer shim, which covers every Lambda-compatible environment.
Hand-written .d.ts sidecars ship next to each .js file. The factory is generic in TItem so typed Adapter<Item, Key> instances flow through without casts:
import {Adapter} from 'dynamodb-toolkit';
import {createLambdaAdapter, type LambdaAdapterOptions} from 'dynamodb-toolkit/lambda';
interface Planet extends Record<string, unknown> {
name: string;
climate?: string;
}
type PlanetKey = Pick<Planet, 'name'>;
const adapter = new Adapter<Planet, PlanetKey>({client, table: 'planets', keyFields: ['name']});
const opts: LambdaAdapterOptions<Planet> = {
mountPath: '/planets',
keyFromPath: (raw, a) => ({[a.keyFields[0].name]: raw}),
policy: {defaultLimit: 25, maxLimit: 200}
};
export const handler = createLambdaAdapter(adapter, opts);The adapter imports from aws-lambda (namespace @types/aws-lambda) for event / result / context types. @types/aws-lambda is a dev-only type dependency — nothing at runtime depends on it. If your TypeScript config is strict and you don't explicitly add it to your project, you'll get a "cannot find module 'aws-lambda'" error — install it yourself:
npm install --save-dev @types/aws-lambdaCallers who don't care about the precise event type can use the re-exported LambdaEvent / LambdaResult aliases, which expand to the same union:
import type {LambdaEvent, LambdaResult} from 'dynamodb-toolkit/lambda';
const myWrapper = async (event: LambdaEvent): Promise<LambdaResult> => /* ... */;Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History