fix: keep the CommonJS interop shim out of the ESM build (#346) - #347
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #346.
The bug
The dual CJS/ESM build (#326, shipped in
1.5.0) compiles onesrc/tree twice, and the interop shim that restores the historical CommonJS shape was emitted into both artifacts: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 CDKNodejsFunction, SST, Serverless Framework), the bundler resolves theimportcondition, inlinesdist/esminto a generated CJS wrapper, and the onlymodulein scope is the consumer's. The shim replaces theirmodule.exports, deletinghandler.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 inlib/request.js,lib/response.js,lib/mimemap.js,lib/prettyPrint.js,lib/statusCodes.jsandlib/s3-service.js— all of which get inlined into the same consumer bundle. Fixing onlyindex.jswould have left six writes still clobbering the consumer's exports.The fix
src/is now pure ESM. The interop footers are appended todist/cjsonly, by a newscripts/cjs-interop.jsstep inbuild:cjs.The footers are written per file rather than generated from one template, because the shape genuinely differs:
.defaultis correct on the package root but would makemimemap['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 testandprepublishOnlyboth go throughnpm run build, so a toolchain change can't quietly ship a broken artifact.One source change was required:
src/lib/s3-service.jsnow exports itsserviceobject so the footer can reach it (itsclientgetter closes over a module-local, so the footer can't rebuild it). That object has to remain the CJS module value —response.jsreads the S3 methods through it and four unit suites dosinon.stub(S3, 'getSignedUrl')on it. SWC's own_export()emits non-configurable getters, which stubbing cannot replace.dist/cjsis unchanged.require('lambda-api')is still callable,.defaultstill self-references it, andrequire('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: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
exportsmap). Before:After:
Layer 1: 18/18 checks passed.3. The issue's own repro, against the packed tarball:
and the handler actually runs:
200 {"ok":true}. The onlymodule.exportswrites left in the bundle are esbuild's own wrapper — zero from lambda-api.4. No collateral damage. The whole unit suite runs against
dist/cjsviajest.config.jsmoduleNameMapper, so it is direct evidence the CommonJS contract is intact:plus
tsd,eslintandprettierclean, and the e2e suite's existing #295 ESM-bundle, deep-import,exports-map, S3-presign and TypeScriptnode16cases 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.0and this branch, bundling the same ESM entry to CJS:1.5.0--format=cjs["default"], handlerundefined["handler"], functionformat: 'cjs'["default"], handlerundefined["handler"], functionlibraryTarget: commonjs2["handler"], function["handler"], functionrollup 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
modulerather 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-compatguard actually asserts.Also added
module-compatnow pins thedist/cjsinterop shape the footers must preserve (default-export modules resolve to their value, no stray.defaulton the lib modules, s3-service stays a single stubbable object).__tests__/cjs-interop.unit.jspins the build step's footer classification across all three outcomes — 17 cases coveringexport defaultin 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.mddocuments the invariant so the shim isn't reintroduced insrc/.