Skip to content

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

@sanjay-nid

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' ] undefinedmodule.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)

  1. Pin lambda-api@1.4.0 (last release before the dual build).
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions