Skip to content

Lambda adapter

Eugene Lazutkin edited this page Jul 16, 2026 · 1 revision

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.

Getting started

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.

Trigger configuration

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: ANY

Two 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 mountPath when 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, not version: '2.0') and emits APIGatewayProxyResult — the right envelope shape.

  • ALB target group — auto-detected via the event.requestContext.elb marker. Flip the target group's lambda.multi_value_headers.enabled attribute to true if downstream clients rely on duplicated headers (e.g. multiple Set-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.

Try it

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/pluto

Stage prefixes (/prod) are stripped by the trigger before the Lambda sees the event, so the adapter's mountPath shouldn't include them. See mountPath.

IAM

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.

Local development before deploying

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/earth

Details, Fetch-runtime variants, and Koa / Express glue: Local debug bridges.

Event shapes

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.

Detection

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.

Result envelope

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.

Per-shape translation table

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

URL reconstruction

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 rawQueryString directly — the order and encoding match the original request byte-for-byte.
  • v1 / ALB iterate multiValueQueryStringParameters when available (preserves duplicates), else queryStringParameters (first-value collapse).

Paths include mountPath — pagination links remain valid for clients traversing the mounted adapter.

Multi-value headers

ALB and API Gateway REST v1 both support a multi-value-headers mode:

  • Request — every header is delivered as a string[] in event.multiValueHeaders, event.headers is stamped null, and duplicate headers (Set-Cookie, repeated custom headers) survive.
  • Response — the trigger strictly requires the response in the same shape: return result.multiValueHeaders, not result.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

Cookies arrive in different places per shape:

  • v1 / ALB — in event.headers.cookie (or event.multiValueHeaders.cookie in multi-value mode). Cookie: a=1; b=2 stays as one header.
  • v2 / Function URL — in event.cookies: string[]. Each array entry is one name=value pair, 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.

Query parameters

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.

event.isBase64Encoded

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.

What the adapter does not do

  • Custom authorizers / IAM. The trigger handles auth before the Lambda fires. Claims arrive on event.requestContext.authorizer.* and are available to exampleFromContext for tenant scoping — see exampleFromContext below 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: true and encode yourself; the adapter doesn't know about binary responses.

Local debug bridges

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> for http.createServer, so node: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

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.

createNodeListener

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.

createFetchBridge

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}));

Bridge options

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 in event.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.

Plug into an existing Koa / Express app

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 support

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.

What the bridges simulate accurately

  • Path + method + query — byte-perfect for v2 (rawPath, rawQueryString). v1 gets both queryStringParameters and multiValueQueryStringParameters.
  • Headers — duplicate headers become arrays in multiValueHeaders (v1), comma-joined (v2).
  • Cookies — v1 keeps them in headers.cookie; v2 splits them into event.cookies: string[] the way API Gateway HTTP does. The adapter then flattens v2's cookies back into headers.cookie internally, matching prod behavior.
  • Binary request bodiescontent-type sniffed: text/*, application/json, application/xml, application/x-www-form-urlencoded, *+json, *+xml pass as strings; everything else is base64-encoded with isBase64Encoded: true, matching API Gateway's default binary-media-types behavior.
  • Response envelope — the bridge reads statusCode, body, headers, multiValueHeaders, cookies (v2), and isBase64Encoded, then writes them to the native HTTP response.

What the bridges deliberately don't simulate

  • Authorizer claimsevent.requestContext.authorizer is not populated. If your exampleFromContext relies 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 maxBodyBytes still 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 via context). 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.

Body reading

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:

  1. event.body is null or empty — the parsed body is null (no payload).
  2. event.isBase64Encoded is true — the body is base64-decoded first, and the decoded byte length is checked against maxBodyBytes (413 when over).
  3. Otherwise the UTF-8 byte length of the string is checked against maxBodyBytes (413 when over).
  4. Non-empty text is parsed with JSON.parse; a parse failure yields 400 BadJsonBody.

Why no streaming?

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.

Default cap

const handler = createLambdaAdapter(planets);  // 1 MiB by default

maxBodyBytes 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.

Base64 bodies

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.

Error responses

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).

Per-adapter body caps

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: ''};
};

Bodies on non-body routes

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.

Interaction with host body parsers

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.

Error handling

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 400599 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.

Request-context error bodies

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.

Unrecognized routes and paths outside mountPath

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).

Lambda Destinations / DLQ

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.

Lambda-specific options

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.

mountPath

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 is eventPath.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.

exampleFromContext

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 into event.headers.cookie before 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.
  • context — the Lambda Context: 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.

Platform body caps

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.

Compatibility

Lambda runtimes

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.

ESM on 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.

Runtime surface

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.

TypeScript

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-lambda

Callers 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> => /* ... */;

Clone this wiki locally