Skip to content

feat(build): ship ESM alongside CommonJS with an exports map - #118

Merged
yeboster merged 1 commit into
mainfrom
feat/esm-build
Aug 28, 2026
Merged

feat(build): ship ESM alongside CommonJS with an exports map#118
yeboster merged 1 commit into
mainfrom
feat/esm-build

Conversation

@yeboster

Copy link
Copy Markdown
Contributor

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.ts in the consumer app does exactly this:

import { RecordClassEnum } from "@metanames/sdk";

This PR adds a second build emitting ES2022 modules to dist/esm, plus an exports map routing import there and require to the existing output. Types are emitted once (by the CJS build), so the two cannot drift.

Impact

esbuild --bundle --minify --platform=node, consumer importing only RecordClassEnum:

entry bytes
require("@metanames/sdk") 1,642,801
import { RecordClassEnum } from "@metanames/sdk" 393

A consumer that genuinely uses MetaNamesSdk still 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

⚠️ Adding an exports map makes every unlisted path unreachable. The consumer app imports four deep paths today:

@metanames/sdk/dist/models/domain
@metanames/sdk/dist/providers/config
@metanames/sdk/dist/interface
@metanames/sdk/dist/transactions/ledger

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:

  • import and require both load the SDK
  • clean subpaths and legacy dist/ paths both resolve, at runtime and for types
  • type imports check clean under moduleResolution: bundler and node16
  • npm pack --dry-run includes dist/esm/

sideEffects: false in two places

Declared in the root manifest and in the generated dist/esm/package.json. The second is not redundant. dist/esm/package.json has 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.js

TypeScript 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.js
  • partisia-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 --noEmit clean, yarn build clean, npx jest -i — 20 suites / 255 tests, all passing.

Note on tsconfig.json

exclude gains coverage, dist, docs, scripts; without it the second tsc pass reads its own output back in. The same line is touched by chore/security-hardening and perf/native-idna; the resulting content is identical in all three, so merges are clean in any order.

https://claude.ai/code/session_01GceWCwGXu66D1xEBDxZWBb

@github-actions

Copy link
Copy Markdown

Total Coverage: 87.16%

Coverage Report
File Branch Funcs Lines Uncovered Lines
src
   actions.ts 100% 100% 100%
   index.ts 100% 100% 100%
   interface.ts 100% 100% 100%
   meta-names-sdk.ts 75% 75% 95.65% 22, 44, 45
   partisia-name-system.ts 58% 87.50% 84.07% 106, 114, 12, 139, 152, 160, 164, 169, 170, 171, 178, 180, 188, 189, 19, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 22, 220, 221, 222, 29, 33, 42, 49, 54, 55, 7, 78, 82
   version.ts 100% 100% 100%
src/actions
   domain.ts 64.29% 100% 100% 35, 44, 56, 71, 74
   record.ts 77.78% 100% 100% 14, 48
src/models
   domain.ts 100% 83.33% 95.95% 45, 46, 47
   index.ts 100% 100% 100%
src/models/helpers
   domain.ts 100% 100% 100%
src/providers
   config.ts 66.67% 100% 98.11% 40, 49, 50
   index.ts 100% 100% 100%
   secrets.ts 45.45% 62.50% 78.57% 19, 23, 24, 25, 26, 27, 29, 30, 44, 50, 51, 52, 53, 56, 57, 58, 59, 62, 63, 64, 65
src/providers/config
   mainnet.ts 100% 100% 100%
   testnet.ts 100% 100% 100%
src/repositories
   contract-repository.ts 75.81% 100% 98.19% 103, 114, 123, 124, 126, 127, 129, 130, 132, 133, 142, 158, 180, 196, 34, 46, 47, 57, 63
   domain-repository.ts 60% 100% 100% 102, 113, 117, 118, 133, 142, 150, 176, 220, 225, 240, 256, 262, 269, 34, 53, 54, 96, 98, 99
   index.ts 100% 75% 100%
   record-repository.ts 71.43% 100% 100% 24, 34, 50, 60
src/repositories/contracts
   meta-names-contract-repository.ts 84.62% 88.89% 88.66% 58, 59, 60, 61, 62, 63, 64, 65, 77, 78, 79, 80, 90
src/repositories/helpers
   avl-client.ts 80% 77.78% 61.62% 16, 22, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 95
   client.ts 88.89% 100% 100% 31
   contract.ts 75% 100% 92.45% 41, 42, 43, 44, 45, 50
src/transactions
   helper.ts 77.78% 85.71% 90.91% 51, 52, 53, 54, 55, 56, 57, 78, 79, 80
   index.ts 50% 25% 31.72% 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 13, 132, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 93, 94, 95, 96, 97, 98, 99
   ledger.ts 100% 0% 43.62% 100, 101, 102, 103, 104, 105, 106, 11, 116, 117, 118, 119, 12, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 13, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 14, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 15, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 16, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 17, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 18, 180, 181, 182, 183, 184, 185, 186, 187, 188, 19, 20, 21, 26, 27, 28, 29, 30, 83, 84, 85, 86, 87, 88, 89, 97, 98, 99
src/validators
   base-validator.ts 100% 100% 100%
   domain-validator.ts 87.50% 100% 100% 29, 30, 31
   index.ts 100% 100% 100%
   record-validator.ts 100% 100% 100%
src/validators/records
   default-validator.ts 100% 100% 100%
   discord-validator.ts 100% 100% 100%
   email-validator.ts 100% 100% 100%
   main-validator.ts 100% 100% 100%
   price-validator.ts 100% 100% 100%
   regex-validator.ts 100% 100% 100%
   twitter-validator.ts 100% 100% 100%
   uri-validator.ts 100% 100% 100%
   wallet-validator.ts 100% 100% 100%
test
test/helpers
   config.ts 0% 100% 100% 7
   helper.ts 100% 60% 68% 25, 26, 27, 28, 29, 30, 31, 32, 35, 36, 37, 38, 39, 40, 41, 42
   index.ts 100% 100% 100%

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
yeboster merged commit 2f290d1 into main Aug 28, 2026
8 checks passed
@yeboster
yeboster deleted the feat/esm-build branch August 28, 2026 21:45
@github-actions

Copy link
Copy Markdown

Total Coverage: 87.85%

Coverage Report
File Branch Funcs Lines Uncovered Lines
src
   actions.ts 100% 100% 100%
   index.ts 100% 100% 100%
   interface.ts 100% 100% 100%
   meta-names-sdk.ts 75% 75% 95.65% 22, 44, 45
   partisia-name-system.ts 58% 87.50% 84.07% 106, 114, 12, 139, 152, 160, 164, 169, 170, 171, 178, 180, 188, 189, 19, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 22, 220, 221, 222, 29, 33, 42, 49, 54, 55, 7, 78, 82
   version.ts 100% 100% 100%
src/actions
   domain.ts 64.29% 100% 100% 35, 44, 56, 71, 74
   record.ts 77.78% 100% 100% 14, 48
src/models
   domain.ts 100% 83.33% 95.95% 45, 46, 47
   index.ts 100% 100% 100%
src/models/helpers
   domain.ts 100% 100% 100%
src/providers
   config.ts 66.67% 100% 98.11% 40, 49, 50
   index.ts 100% 100% 100%
   secrets.ts 45.45% 62.50% 78.57% 19, 23, 24, 25, 26, 27, 29, 30, 44, 50, 51, 52, 53, 56, 57, 58, 59, 62, 63, 64, 65
src/providers/config
   mainnet.ts 100% 100% 100%
   testnet.ts 100% 100% 100%
src/repositories
   contract-repository.ts 75.81% 100% 98.19% 103, 114, 123, 124, 126, 127, 129, 130, 132, 133, 142, 158, 180, 196, 34, 46, 47, 57, 63
   domain-repository.ts 60% 100% 100% 102, 113, 117, 118, 133, 142, 150, 176, 220, 225, 240, 256, 262, 269, 34, 53, 54, 96, 98, 99
   index.ts 100% 75% 100%
   record-repository.ts 71.43% 100% 100% 24, 34, 50, 60
src/repositories/contracts
   meta-names-contract-repository.ts 84.62% 88.89% 86.14% 57, 58, 59, 60, 61, 62, 63, 64, 78, 79, 80, 81, 82, 83, 84, 94
src/repositories/helpers
   avl-client.ts 80% 77.78% 61.62% 16, 22, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 95
   client.ts 90% 100% 100% 44
   contract.ts 75% 100% 92.45% 41, 42, 43, 44, 45, 50
src/transactions
   helper.ts 76.92% 85.71% 93.18% 51, 52, 53, 54, 55, 56, 57, 79, 79
   index.ts 20% 25% 37.35% 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 13, 14, 142, 147, 15, 16, 160, 161, 162, 164, 164, 165, 166, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 93, 94, 95, 96, 97, 98, 99
   ledger.ts 100% 0% 43.62% 100, 101, 102, 103, 104, 105, 106, 11, 116, 117, 118, 119, 12, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 13, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 14, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 15, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 16, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 17, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 18, 180, 181, 182, 183, 184, 185, 186, 187, 188, 19, 20, 21, 26, 27, 28, 29, 30, 83, 84, 85, 86, 87, 88, 89, 97, 98, 99
src/validators
   base-validator.ts 100% 100% 100%
   domain-validator.ts 87.50% 100% 100% 29, 30, 31
   index.ts 100% 100% 100%
   record-validator.ts 100% 100% 100%
src/validators/idna
   index.ts 81.54% 100% 95.93% 108, 110, 110, 111, 118, 119, 120, 121, 122, 123, 124, 125, 136, 138, 139, 144, 151, 155, 168, 210, 46
   table.ts 100% 100% 100%
src/validators/records
   default-validator.ts 100% 100% 100%
   discord-validator.ts 100% 100% 100%
   email-validator.ts 100% 100% 100%
   main-validator.ts 100% 100% 100%
   price-validator.ts 100% 100% 100%
   regex-validator.ts 100% 100% 100%
   twitter-validator.ts 100% 100% 100%
   uri-validator.ts 100% 100% 100%
   wallet-validator.ts 100% 100% 100%
test
test/helpers
   config.ts 0% 100% 100% 7
   helper.ts 100% 60% 68% 25, 26, 27, 28, 29, 30, 31, 32, 35, 36, 37, 38, 39, 40, 41, 42
   index.ts 100% 100% 100%

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant