feat(build): ship ESM alongside CommonJS with an exports map - #118
Merged
Conversation
|
Total Coverage: 87.16% Coverage Report
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
This was referenced Aug 28, 2026
The package was CommonJS only. A bundler cannot tree-shake CommonJS, so a React app that imported a single enum from the barrel still pulled the entire SDK -- and its whole dependency graph -- into the chunk. In the consumer app `lib/constants.ts` does exactly that. This adds a second build (`tsc -p tsconfig.esm.json`) emitting ES2022 modules to `dist/esm`, and an `exports` map that routes `import` there while `require` keeps resolving to the existing CommonJS output. Types are emitted once, by the CommonJS build, so the two cannot drift. Measured with esbuild (--bundle --minify --platform=node) on a consumer that imports only `RecordClassEnum`: require(...) 1,642,801 bytes import ... 393 bytes `sideEffects: false` is declared in both the root manifest and the generated `dist/esm/package.json`. The latter is not redundant: bundlers read the *nearest* manifest, and `dist/esm/package.json` has to exist anyway to mark the directory as ESM, so without repeating the flag there the root declaration stops applying to those files -- which is what made the first attempt at this shave only 100 KB instead of everything. `scripts/finalize-esm.js` post-processes the ESM output. TypeScript emits relative specifiers exactly as written, and this codebase writes them without a file extension; Node's ESM resolver does not guess extensions or directory indexes. The script rewrites `./models` to `./models/index.js` and `./interface` to `./interface.js`. It does the same for deep imports into dependencies (`partisia-blockchain-applications-crypto/lib/main/transaction`), which need it for the same reason. The `exports` map deliberately keeps `./dist/*` reachable. Consumers import deep paths like `@metanames/sdk/dist/providers/config` today, and adding an `exports` map without those entries would silently break them. Cleaner subpaths (`@metanames/sdk/providers/config`) are exported alongside, and are what new code should use. Verified against a scratch consumer package: `import` and `require` both load, with clean subpaths and legacy `dist/` paths, and the type imports resolve under both `moduleResolution: bundler` and `node16`. Claude-Session: https://claude.ai/code/session_01GceWCwGXu66D1xEBDxZWBb
yeboster
force-pushed
the
feat/esm-build
branch
from
August 28, 2026 21:43
7cce5aa to
5da98a5
Compare
|
Total Coverage: 87.85% Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
What
The package ships CommonJS only. Bundlers cannot tree-shake CommonJS, so a React app that imports a single enum from the barrel pulls in the whole SDK and its entire dependency graph. That is not hypothetical —
lib/constants.tsin the consumer app does exactly this:This PR adds a second build emitting ES2022 modules to
dist/esm, plus anexportsmap routingimportthere andrequireto the existing output. Types are emitted once (by the CJS build), so the two cannot drift.Impact
esbuild
--bundle --minify --platform=node, consumer importing onlyRecordClassEnum:require("@metanames/sdk")import { RecordClassEnum } from "@metanames/sdk"A consumer that genuinely uses
MetaNamesSdkstill pays for it (1,631,101 bytes) — that's what PRs on the dependency side address. The win here is that consumers stop paying for code they don't touch.Backwards compatibility
exportsmap makes every unlisted path unreachable. The consumer app imports four deep paths today:All four stay reachable via an explicit
./dist/*pattern. Cleaner subpaths (@metanames/sdk/providers/config) are exported alongside and are what new code should use; the app can migrate at its own pace.Verified against a scratch consumer package:
importandrequireboth load the SDKdist/paths both resolve, at runtime and for typesmoduleResolution: bundlerandnode16npm pack --dry-runincludesdist/esm/sideEffects: falsein two placesDeclared in the root manifest and in the generated
dist/esm/package.json. The second is not redundant.dist/esm/package.jsonhas to exist to mark the directory as ESM for Node, and bundlers read the nearest manifest — so without repeating the flag, the root declaration stops applying to precisely the files that need it. The first attempt at this shaved 100 KB instead of everything for exactly that reason.scripts/finalize-esm.jsTypeScript emits relative specifiers exactly as written, and this codebase writes them without extensions. Node's ESM resolver does not guess extensions or directory indexes, so
import './models'would fail at runtime. The script rewrites specifiers to what they actually resolve to:./models→./models/index.js./interface→./interface.jspartisia-blockchain-applications-crypto/lib/main/transaction→…/transaction.js(deep imports into CJS dependencies need the same treatment)It throws rather than guessing if a relative specifier cannot be resolved, so a rename that breaks the mapping fails the build instead of shipping.
Test status
npx tsc --noEmitclean,yarn buildclean,npx jest -i— 20 suites / 255 tests, all passing.Note on
tsconfig.jsonexcludegainscoverage,dist,docs,scripts; without it the secondtscpass reads its own output back in. The same line is touched bychore/security-hardeningandperf/native-idna; the resulting content is identical in all three, so merges are clean in any order.https://claude.ai/code/session_01GceWCwGXu66D1xEBDxZWBb