Summary
The dual CJS/ESM build introduced in #326 (first shipped in 1.5.0) added a runtime interop shim at the end of the ESM dist files:
// dist/esm/index.js (line ~389)
if (typeof module !== 'undefined') {
module.exports = createAPI;
// to CommonJS with esModuleInterop:false (they emit `require('lambda-api').default(...)`).
module.exports.default = createAPI;
}
In native Node ESM this is dead code (module is undefined). The problem appears in a common bundling scenario: when an application imports lambda-api from ESM source and is later bundled to CommonJS (for example esbuild --format=cjs), esbuild resolves the package through the import condition of the exports map and inlines dist/esm/index.js into the generated CJS bundle. At runtime the generated bundle executes in a CommonJS scope where module is defined, causing the interop shim to run against the consumer's module rather than lambda-api's own module — replacing the consuming bundle's entire module.exports with createAPI.
For AWS Lambda users who bundle to a single CJS file (a very common setup: esbuild --format=cjs, AWS CDK NodejsFunction, SST, Serverless Framework), this silently deletes their handler export and the function fails at every invocation with:
Runtime.HandlerNotFound: index.handler is undefined or not exported
This is particularly severe for Lambda workloads because deployment succeeds, the bundle passes local build validation, and the failure is only discovered when Lambda attempts to resolve the handler during invocation — which also makes it hard to connect the failure back to lambda-api (nothing in the error mentions the package).
Reproduction (minimal)
// entry.mjs
import createAPI from "lambda-api";
const api = createAPI();
export const handler = async (event, context) => api.run(event, context);
npm install lambda-api@1.5.0 esbuild
npx esbuild entry.mjs --bundle --platform=node --format=cjs --outfile=out.cjs \
--external:@aws-sdk/client-s3
node -e "const m = require('./out.cjs'); console.log(Object.keys(m), typeof m.handler)"
Actual (1.5.0): [ 'default' ] undefined — module.exports is now createAPI itself (a function with a self-referencing .default), and handler is gone.
Expected (and actual on 1.4.0): [ 'handler' ] function.
Root cause
The generated bundle makes the mechanism visible. Simplified shape of out.cjs:
// --- esbuild sets up the bundle's exports (entry point) ---
var entry_exports = {};
__export(entry_exports, { handler: () => handler });
module.exports = __toCommonJS(entry_exports); // `handler` correctly exported here
// --- lambda-api's dist/esm/index.js has been inlined below ---
var createAPI = (opts) => new API(opts);
if (typeof module !== 'undefined') {
module.exports = createAPI; // <-- overwrites the bundle's exports
module.exports.default = createAPI; // `handler` is gone
}
At that point there is only one module in scope — the bundle's own. The shim's write lands on the consumer's export object, which is why handler disappears.
This behavior is expected from esbuild and other bundlers that inline ESM modules into a generated CommonJS wrapper — it is not a bundler bug. The problem is that the ESM artifact performs a side-effectful write to module.exports, which becomes the consumer bundle's export object after inlining.
I haven't found a reliable runtime guard that can distinguish lambda-api's standalone CJS module execution from execution after bundler inlining, because in both cases module and module.exports appear valid from the shim's perspective.
To be clear, I believe the shim itself still serves a purpose: it preserves the historical callable CommonJS API (require('lambda-api')(...) and .default) after the dual-build migration. My concern is only where the shim executes. Keeping it in the dedicated CJS artifact while omitting it from the ESM artifact would preserve the compatibility goal of #326 while eliminating side effects from the ESM build.
Workarounds we validated (for anyone else hitting this)
- Pin
lambda-api@1.4.0 (last release before the dual build).
- Or force the CJS entry in esbuild:
alias: { 'lambda-api': './node_modules/lambda-api/dist/cjs/index.js' } — works, but depends on internal dist layout.
Environment
- lambda-api
1.5.0 (regression; 1.4.0 unaffected)
- esbuild
0.28.x (any version), --format=cjs --platform=node
- AWS Lambda
nodejs22.x
Summary
The dual CJS/ESM build introduced in #326 (first shipped in
1.5.0) added a runtime interop shim at the end of the ESM dist files:In native Node ESM this is dead code (
moduleis undefined). The problem appears in a common bundling scenario: when an application imports lambda-api from ESM source and is later bundled to CommonJS (for exampleesbuild --format=cjs), esbuild resolves the package through theimportcondition of the exports map and inlinesdist/esm/index.jsinto the generated CJS bundle. At runtime the generated bundle executes in a CommonJS scope wheremoduleis defined, causing the interop shim to run against the consumer'smodulerather than lambda-api's own module — replacing the consuming bundle's entiremodule.exportswithcreateAPI.For AWS Lambda users who bundle to a single CJS file (a very common setup: esbuild
--format=cjs, AWS CDKNodejsFunction, SST, Serverless Framework), this silently deletes theirhandlerexport and the function fails at every invocation with:This is particularly severe for Lambda workloads because deployment succeeds, the bundle passes local build validation, and the failure is only discovered when Lambda attempts to resolve the handler during invocation — which also makes it hard to connect the failure back to lambda-api (nothing in the error mentions the package).
Reproduction (minimal)
npm install lambda-api@1.5.0 esbuild npx esbuild entry.mjs --bundle --platform=node --format=cjs --outfile=out.cjs \ --external:@aws-sdk/client-s3 node -e "const m = require('./out.cjs'); console.log(Object.keys(m), typeof m.handler)"Actual (1.5.0):
[ 'default' ] undefined—module.exportsis nowcreateAPIitself (a function with a self-referencing.default), andhandleris gone.Expected (and actual on 1.4.0):
[ 'handler' ] function.Root cause
The generated bundle makes the mechanism visible. Simplified shape of
out.cjs:At that point there is only one
modulein scope — the bundle's own. The shim's write lands on the consumer's export object, which is whyhandlerdisappears.This behavior is expected from esbuild and other bundlers that inline ESM modules into a generated CommonJS wrapper — it is not a bundler bug. The problem is that the ESM artifact performs a side-effectful write to
module.exports, which becomes the consumer bundle's export object after inlining.I haven't found a reliable runtime guard that can distinguish lambda-api's standalone CJS module execution from execution after bundler inlining, because in both cases
moduleandmodule.exportsappear valid from the shim's perspective.To be clear, I believe the shim itself still serves a purpose: it preserves the historical callable CommonJS API (
require('lambda-api')(...)and.default) after the dual-build migration. My concern is only where the shim executes. Keeping it in the dedicated CJS artifact while omitting it from the ESM artifact would preserve the compatibility goal of #326 while eliminating side effects from the ESM build.Workarounds we validated (for anyone else hitting this)
lambda-api@1.4.0(last release before the dual build).alias: { 'lambda-api': './node_modules/lambda-api/dist/cjs/index.js' }— works, but depends on internal dist layout.Environment
1.5.0(regression;1.4.0unaffected)0.28.x(any version),--format=cjs --platform=nodenodejs22.x