Repository navigation
ESM dist's CommonJS interop shim overwrites module.exports when bundled to CJS (breaks AWS Lambda: Runtime.HandlerNotFound) — regression in 1.5.0 via #326 #346
Description
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.0unaffected) - esbuild
0.28.x(any version),--format=cjs --platform=node - AWS Lambda
nodejs22.x
Activity
naorpeled commentedon Aug 22, 2026
Hey @sanjay-nid,
Sorry for the delay on this, I've created a fix in 1.5.1, please let me know if you're still noticing issues 🙏
sanjay-nid commentedon Aug 24, 2026
Confirming the fix works — thank you for the fast turnaround, @naorpeled!
Verified against
1.5.1from npm:dist/esm/*contains zeromodule.exportswrites; the interop footer lands only indist/cjs/index.jsrequire('lambda-api')(...)) and the.defaultself-reference[ 'handler' ]instead of[ 'default' ]handleragain, and our test suite passes identically to the 1.4.0 baselineWe're rolling
^1.5.1out across our affected services. Theesm-to-cjs-bundlee2e fixture in #347 covering this exact scenario is much appreciated.slootjes commentedon Sep 16, 2026
Unfortunately I still have issues with this on version 1.5.1, I've always used it like this:
and after updating it just breaks with the error as mentioned by @sanjay-nid
Changing it into:
works but raises the following typescript issue in my IDE:
TS2349: This expression is not callable.. It's not great having a minor release causing a full break on the entire library :(edit; if it helps, I'm using esbuild to build with esm.