Skip to content

Local debug bridges

Eugene Lazutkin edited this page Apr 20, 2026 · 1 revision

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

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.

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

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

This package doesn'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.

Framework-native story without the bridge

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.

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

Test coverage

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.

Clone this wiki locally