Skip to content

fix: keep the CommonJS interop shim out of the ESM build (#346) - #347

Merged
naorpeled merged 5 commits into
mainfrom
claude/lambda-api-issue-346-5e3a86
Aug 22, 2026
Merged

fix: keep the CommonJS interop shim out of the ESM build (#346)#347
naorpeled merged 5 commits into
mainfrom
claude/lambda-api-issue-346-5e3a86

Conversation

@naorpeled

@naorpeled naorpeled commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #346.

The bug

The dual CJS/ESM build (#326, shipped in 1.5.0) compiles one src/ tree twice, and the interop shim that restores the historical CommonJS shape was emitted into both artifacts:

// dist/esm/index.js
if (typeof module !== 'undefined') {
  module.exports = createAPI;
  module.exports.default = createAPI;
}

In native Node ESM that is dead code. When a consumer imports lambda-api from ESM source and bundles to CommonJS (esbuild --format=cjs, AWS CDK NodejsFunction, SST, Serverless Framework), the bundler resolves the import condition, inlines dist/esm into a generated CJS wrapper, and the only module in scope is the consumer's. The shim replaces their module.exports, deleting handler.

The deploy succeeds; every invocation then fails with Runtime.HandlerNotFound: index.handler is undefined or not exported, and nothing in the error names lambda-api.

It was in 7 files, not 1

The issue reports index.js, but the shim was also in lib/request.js, lib/response.js, lib/mimemap.js, lib/prettyPrint.js, lib/statusCodes.js and lib/s3-service.js — all of which get inlined into the same consumer bundle. Fixing only index.js would have left six writes still clobbering the consumer's exports.

The fix

src/ is now pure ESM. The interop footers are appended to dist/cjs only, by a new scripts/cjs-interop.js step in build:cjs.

The footers are written per file rather than generated from one template, because the shape genuinely differs: .default is correct on the package root but would make mimemap['default'] resolve to the whole MIME map. The script also loads each patched file back and fails the build if the CommonJS shape is wrong — npm test and prepublishOnly both go through npm run build, so a toolchain change can't quietly ship a broken artifact.

One source change was required: src/lib/s3-service.js now exports its service object so the footer can reach it (its client getter closes over a module-local, so the footer can't rebuild it). That object has to remain the CJS module value — response.js reads the S3 methods through it and four unit suites do sinon.stub(S3, 'getSignedUrl') on it. SWC's own _export() emits non-configurable getters, which stubbing cannot replace.

dist/cjs is unchanged. require('lambda-api') is still callable, .default still self-references it, and require('lambda-api/lib/*') still returns the value itself.

Verification (red → green)

Both regression tests were written first and confirmed failing on the unfixed tree.

1. module-compat — the ESM artifact must not touch the CommonJS module system. Before:

✕ never touches the CommonJS module system (issue #346)
    - Array []
    + Array [ "index.js", "lib/mimemap.js", "lib/prettyPrint.js",
    +   "lib/request.js", "lib/response.js", "lib/s3-service.js",
    +   "lib/statusCodes.js" ]

This runs under jest unit, so it already gates PRs across the Node 14–22 matrix — no CI change needed.

2. New e2e Layer 1 fixture — bundles an ESM handler to CJS with real esbuild against the packed tarball (so it honors the published exports map). Before:

▸ esbuild ESM entry -> cjs output (issue #346: consumer exports survive)
  ✗ bundles, keeps `handler` export, and returns 200
      bundle exports ["default"] — handler is undefined (issue #346 regression)

After: Layer 1: 18/18 checks passed.

3. The issue's own repro, against the packed tarball:

$ node -e "const m = require('./out.cjs'); console.log(Object.keys(m), typeof m.handler)"
[ 'handler' ] function          # was: [ 'default' ] undefined

and the handler actually runs: 200 {"ok":true}. The only module.exports writes left in the bundle are esbuild's own wrapper — zero from lambda-api.

4. No collateral damage. The whole unit suite runs against dist/cjs via jest.config.js moduleNameMapper, so it is direct evidence the CommonJS contract is intact:

Test Suites: 27 passed, 27 total
Tests:       4 skipped, 514 passed, 518 total

plus tsd, eslint and prettier clean, and the e2e suite's existing #295 ESM-bundle, deep-import, exports-map, S3-presign and TypeScript node16 cases all still pass.

Not just esbuild

The issue reports esbuild, but the defect is in the artifact, not the bundler. Verified against the published 1.5.0 and this branch, bundling the same ESM entry to CJS:

bundler 1.5.0 this branch
esbuild --format=cjs ["default"], handler undefined ["handler"], function
rollup format: 'cjs' ["default"], handler undefined ["handler"], function
webpack libraryTarget: commonjs2 ["handler"], function ["handler"], function

rollup users were affected too. webpack was not — it wraps each ESM module in its own scope, so the shim's write landed on lambda-api's own module rather than the bundle's.

Only the esbuild case is in the e2e suite; rollup and webpack were checked manually rather than added, to keep Layer 1 fast and Docker-free. The underlying invariant is bundler-agnostic and is what the module-compat guard actually asserts.

Also added

module-compat now pins the dist/cjs interop shape the footers must preserve (default-export modules resolve to their value, no stray .default on the lib modules, s3-service stays a single stubbable object).

__tests__/cjs-interop.unit.js pins the build step's footer classification across all three outcomes — 17 cases covering export default in every form, export const / export async function / export * / export { x }, and the ambiguous mixes that must fail rather than silently drop exports. Mutation-checked: reverting either half of the classification regex makes specific cases fail.

AGENTS.md documents the invariant so the shim isn't reintroduced in src/.

The dual CJS/ESM build (#326, shipped in 1.5.0) compiles a single `src/` tree
twice. Seven source files ended with an interop shim:

    if (typeof module !== 'undefined') {
      module.exports = createAPI;
      module.exports.default = createAPI;
    }

which restores the historical CommonJS shape — but it was emitted into
`dist/esm` as well. Bundlers that inline the ESM artifact into a generated
CommonJS wrapper (esbuild `--format=cjs`, AWS CDK `NodejsFunction`, SST,
Serverless Framework) leave exactly one `module` in scope: the consumer's. The
shim then replaced the consumer's `module.exports`, deleting their `handler`.

On Lambda this deployed cleanly and failed on every invocation with
`Runtime.HandlerNotFound: index.handler is undefined or not exported`, with
nothing in the error naming lambda-api.

`src/` is now pure ESM. The interop footers are appended to `dist/cjs` only, by
`scripts/cjs-interop.js`, which also loads each patched file back and fails the
build if the CommonJS shape is wrong. Footers are per-file on purpose: the
`.default` self-reference is correct on the package root but would make
`mimemap['default']` resolve to the whole MIME map.

`src/lib/s3-service.js` now exports its `service` object so the footer can
reach it; that object must stay the module value so `response.js` and the unit
suites (`sinon.stub`) share one set of mutable properties.

Nothing about `dist/cjs` changes: `require('lambda-api')` is still callable,
`.default` still self-references, and `require('lambda-api/lib/*')` still
returns the value itself.

Regression coverage:
- `module-compat` fails if any `dist/esm` file references `module.exports`,
  `typeof module`, or `exports.` — it named all 7 files before the fix.
- `module-compat` pins the `dist/cjs` interop shape the footers must preserve.
- New e2e Layer 1 fixture bundles an ESM handler with esbuild `--format=cjs`
  against the packed tarball and asserts the consumer's `handler` export
  survives and returns 200. It reported `["default"] / handler undefined`
  before the fix.

Closes #346
The footer table listed all seven modules by name, so adding a new
`src/lib/*.js` with a default export would silently get no footer and its
CommonJS shape would drift from its siblings — a failure nothing would catch.

The footer is now derived from the module's own exports:

  - only `default`  -> collapse to that value
  - named exports   -> leave SWC's output alone
  - both            -> fail the build, ambiguous, needs an explicit entry

That leaves two real exceptions rather than a seven-entry table: the package
root (callable + `.default` self-reference) and lib/s3-service.js (no default
export; must resolve to the mutable service object).

Also drops the build-time contract check. It duplicated
__tests__/module-compat.unit.js, which already runs on the publish path —
`prepublishOnly` -> `npm test` -> `jest unit`, and `unit` matches
module-compat.unit.js.

dist/cjs is byte-for-byte identical to the previous implementation.
The named-export check listed keywords (`const|let|var|function|class|{`),
which only covered the forms that happen to exist in src/ today. Three forms
slipped through, and two of them were silent:

  export async function a(){}; export default b  -> footer applied, `a` DROPPED
  export * from './x.js';      export default b  -> footer applied, re-exports DROPPED
  export { x as default }                        -> no footer, require() gave {default}

Stated as a negative lookahead instead — any `export` line that is not
`export default` is a named export — so every form classifies correctly and
mixed modules fail the build rather than losing exports. `as default` now
counts as a default export, so `export { x as default }` is caught too.

Verified across all five shapes: the three above now fail the build loudly,
default-only still collapses, named-only is still left alone. dist/cjs is
byte-for-byte unchanged.

Also marks the s3-service `service` export @internal — it exists so the build
step can reach the object, not as public API.
The classification is the part of the build step that fails silently — a module
mixing a default with named exports would collapse to the default and drop the
rest — but it had no automated coverage; it was only ever checked by hand.

scripts/cjs-interop.js now exposes `footerFor(relative, source)` and runs its
side effects under `require.main === module`, so the decision is testable
without touching the filesystem. __tests__/cjs-interop.unit.js pins 17 cases
across all three outcomes plus the exceptions, and runs under `jest unit`, so
it gates PRs on the Node matrix.

Mutation-checked: restoring the enumerated named-export regex fails
`default + async function` and `default + star`; dropping the `as default`
alternation fails `export { x as default }`.

dist/cjs is byte-for-byte unchanged.
… comments

The s3-service key was a computed `path.join('lib','s3-service.js')` so it
would match `path.relative` on Windows. The relative path is now normalized to
forward slashes at the call site instead, so the table reads as plain strings.

The explanatory comments moved out of the emitted footers and into the table
itself — dist/ is generated output, and the MARKER already points readers at
this script. The root footer drops from six lines to three.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant