-
Notifications
You must be signed in to change notification settings - Fork 0
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. This package 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';local.js is a separate entry point so the main module stays pure — nothing imports node:http / Buffer / streams in the production code path. Tree-shakers and bundlers see dynamodb-toolkit-lambda (the Lambda handler) as Node-free; only /local.js pulls in Node primitives.
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.
// 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
}-
'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.
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.
This package doesn't depend on Koa or Express. Use createNodeListener + 10 lines of glue — no second integration library required:
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.
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 and deploying the same code as a Lambda, consider using dynamodb-toolkit-koa / -express / -fetch for the server path and keeping -lambda for the Lambda path — both share the same underlying Adapter instance, so only the I/O layer differs. The local bridge is for the case where your Lambda is the production handler and you just want to test it via HTTP locally.
-
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 via a wrapper:const base = createNodeListener(handler); const devListener = async (req, res) => { // Inject dev claims before dispatching // (the bridge doesn't see this, you need to patch the request or use a test context) return base(req, res); };
A cleaner 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. See Options → exampleFromContext. -
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 bridges have their own test suite — tests/test-local.js — that exercises both createNodeListener and createFetchBridge against a real HTTP server and real Fetch requests respectively. Both shapes (v1 and v2) are covered. See Compatibility for the cross-runtime test matrix.