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

Closed

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

  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

Activity

  1. naorpeled commented on Aug 22, 2026

    @naorpeled
    Collaborator

    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 🙏

  2. sanjay-nid commented on Aug 24, 2026

    @sanjay-nid
    Author

    Confirming the fix works — thank you for the fast turnaround, @naorpeled!

    Verified against 1.5.1 from npm:

    • dist/esm/* contains zero module.exports writes; the interop footer lands only in dist/cjs/index.js
    • The CJS entry keeps the historical callable API (require('lambda-api')(...)) and the .default self-reference
    • The minimal esbuild repro from this issue now yields [ 'handler' ] instead of [ 'default' ]
    • Rebuilt the production Lambda service that originally hit this: the bundled artifact exports handler again, and our test suite passes identically to the 1.4.0 baseline

    We're rolling ^1.5.1 out across our affected services. The esm-to-cjs-bundle e2e fixture in #347 covering this exact scenario is much appreciated.

  3. slootjes commented on Sep 16, 2026

    @slootjes

    Unfortunately I still have issues with this on version 1.5.1, I've always used it like this:

    import lambdaApi from 'lambda-api';
    const api = lambdaApi.default();
    

    and after updating it just breaks with the error as mentioned by @sanjay-nid

    Changing it into:

    import createAPI from 'lambda-api';
    const api = createAPI();
    

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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