Component
midnight-js
Summary
Replace rollup with plain tsc as the build for every published package, and drop the CJS half of the output matrix (ESM-only).
Rollup is not bundling anything in this repo. build-tools/rollup.config.factory.mjs:8-11 sets external: [/node_modules/], so every runtime dependency stays external — verified on the artifacts: packages/indexer-public-data-provider/dist/index.mjs opens with 12 external imports (@apollo/client, rxjs, graphql-ws, …) and packages/utils/dist/index.mjs imports @noble/hashes from outside the bundle. No minification, no browser target (no package declares a browser field). Rollup's only two jobs here are concatenating a package's own source files into one file and rolling up its .d.ts.
We pay the full price of a bundler for concatenation nobody consumes.
packages/compact is the existence proof for the target: "type": "module", module/moduleResolution: NodeNext, plain tsc -p tsconfig.build.json, .js extensions on relative imports, per-file ESM output. It builds in 0.85 s.
Why now
5.0.0 has not shipped GA (5.0.0-beta.6). Dropping CJS is a breaking change; doing it inside the existing beta line costs nothing, doing it after GA costs a major.
engines is already node >=22. Node ≥ 22.12 supports require(esm) unflagged, so CJS consumers keep working through the interop rather than through a second build.
- ESM-only is what makes the
tsc migration cheap. Dual-format tsc needs two tsconfigs, two output trees and package.json type shims per directory; single-format tsc needs one tsconfig and nothing else.
Current state (measured on main, 8949082)
| Fact |
Value |
Cold full build (turbo run build --force) |
34.8 s wall / 80 s CPU for ~17k LOC |
packages/protocol build |
9.8 s for 164 LOC — 10 entries × 2 rollup passes = 20 passes |
packages/compact build (tsc) |
0.85 s |
| Rollup configs |
16, of which 3 (protocol, midnight-js, testkit-js-e2e) bypass the shared factory with a copied entries.flatMap(...) |
rollup in devDependencies |
2 of 16 packages. yarn workspace @midnight-ntwrk/midnight-js-contracts build fails with command not found: rollup; builds only work via turbo from the root. Violates "Missing devDependencies" in CLAUDE.md |
| Dead plugins |
resolve() and commonjs() in the factory can never fire — external pre-empts them |
| Output files per entry |
5 (.mjs, .cjs, .d.mts, .d.cts, .d.ts) |
Correctness cost, not hypothetical: 388ceee5 ("share one error module across protocol bundles"). Each rollup entry is bundled independently, so a relatively-imported src/errors.ts was inlined into every entry and each declared its own class …Error. A class only ever equals itself, so instanceof answered false across entries of the same package — silently, exactly where a caller distinguishes failure modes. The fix needed a bespoke share-error-module rollup plugin, Symbol.hasInstance on the error classes, and a dist-level identity test. Under per-file tsc output that class of bug cannot exist: module identity is preserved 1:1.
Target
Per published package:
"type": "module"
build: "tsc -p tsconfig.build.json"
module/moduleResolution: NodeNext (drop bundler)
exports map keeps its current subpath keys, each pointing at ./dist/<name>.js + ./dist/<name>.d.ts
- drop
main-vs-module duality, drop .cjs / .d.cts / .d.mts outputs
engines.node → >=22.12
Subpath entries survive unchanged in count and laziness: protocol (10 subpaths, incl. the lazy ledger-era accessors) and midnight-js (5) each emit one file per source module, so lazy loading of the heavy WASM eras becomes easier to reason about than it is today, not harder.
Scope
15 packages need work (compact is already there):
contracts, dapp-connector-proof-provider, fetch-zk-config-provider, http-client-proof-provider, indexer-public-data-provider, level-private-state-provider, logger-provider, midnight-js, network-id, node-zk-config-provider, protocol, types, utils, testkit-js, testkit-js-e2e
Suggested phases, each independently mergeable:
- Spike on one leaf package (
network-id, 5 specifiers, 64 LOC). Prove the tsconfig, the exports shape and the published-artifact resolution end to end. Record before/after build time.
- Import-extension codemod. 491 extensionless relative specifiers across the 15 packages (
contracts 186, indexer-public-data-provider 82, testkit-js 54, utils 41, types 36, testkit-js-e2e 29, level-private-state-provider 20, protocol 10, rest ≤ 8). Zero of them are directory imports, so this is a pure "append .js" rewrite with no /index.js special cases. Land with an ESLint rule (import/extensions) so it cannot regress.
- Per-package migration, leaves first, following the dependency layering in CLAUDE.md:
types → network-id/utils → providers → contracts → midnight-js barrel → testkit.
- Cleanup: delete
build-tools/rollup.config.factory.mjs, all 16 rollup.config.mjs, the share-error-module plugin and the Symbol.hasInstance workaround (keep the dist identity test as a regression gate), rollup deps from the root package.json, the rollup resolution pin, and the .rollup.cache entries in clean scripts.
Acceptance criteria
Risks and open questions
moduleResolution: bundler → NodeNext is the real risk, not the codemod. bundler is permissive about dependency exports maps; NodeNext is not. Needs a spike per risky dependency: @apollo/client/* subpaths (/core, /link/http, /link/retry, /link/subscriptions, /utilities), isomorphic-ws, cross-fetch, graphql-ws, and the WASM packages @midnightntwrk/ledger-v9, @midnightntwrk/onchain-runtime-v4, @midnight-ntwrk/compact-runtime, @midnight-ntwrk/platform-js.
- CJS interop shifts.
packages/indexer-public-data-provider/src/config.ts:18-23 documents that isomorphic-ws (module.exports = require('ws'), no exports map) exposes only default to the ESM build and named imports work only in the CJS build. Removing the CJS build removes that escape hatch — the workaround must be verified as the permanent path.
__DEBUG__ loses its build-time substitution. Only consumer is packages/contracts/src/submit-tx.ts (declared at :28, used at :60 and :87), fed today by @rollup/plugin-replace off process.env.CI. Needs a decision: runtime flag, or keep a minimal transform. Note the current mechanism ships different code to npm depending on the CI env var — worth revisiting on its own merits.
- Breaking change for CJS consumers. Anyone on Node < 22.12, or on a bundler that will not follow
require(esm), breaks. Needs a product call — sign-off required before phase 3. docs/releases/v3.0.0/new-features.md:325 still shows a require() example and would need updating.
importHelpers: true in tsconfig.base.json:7 with no tslib dependency anywhere. A no-op at target: es2024, but it should be removed rather than left as a trap.
- The in-flight
.d.ts API-report gate (on feat/1006-types-d14-api-report-gate) reads a rolled-up single .d.ts. Per-file declarations change its input shape; the gate needs reworking in the same series.
Sequencing
Must land after the HF v8→v9 MJS-01 stack (#1004 and PRs #1155 / #1156 / #1159 / #1164). A 491-specifier codemod plus 15 package.json rewrites would conflict with everything currently in flight, and protocol is the package that stack is actively reshaping.
Non-goals
- Introducing a different bundler (tsdown, tsup, unbuild). Evaluated; rejected for now. Nothing here needs bundling, and
packages/compact already proves tsc is sufficient. Revisit only if a browser-targeted, dependency-inlining artifact becomes a requirement.
- Changing any public API surface. This is a packaging and toolchain change only.
Component
midnight-js
Summary
Replace rollup with plain
tscas the build for every published package, and drop the CJS half of the output matrix (ESM-only).Rollup is not bundling anything in this repo.
build-tools/rollup.config.factory.mjs:8-11setsexternal: [/node_modules/], so every runtime dependency stays external — verified on the artifacts:packages/indexer-public-data-provider/dist/index.mjsopens with 12 external imports (@apollo/client,rxjs,graphql-ws, …) andpackages/utils/dist/index.mjsimports@noble/hashesfrom outside the bundle. No minification, no browser target (no package declares abrowserfield). Rollup's only two jobs here are concatenating a package's own source files into one file and rolling up its.d.ts.We pay the full price of a bundler for concatenation nobody consumes.
packages/compactis the existence proof for the target:"type": "module",module/moduleResolution: NodeNext, plaintsc -p tsconfig.build.json,.jsextensions on relative imports, per-file ESM output. It builds in 0.85 s.Why now
5.0.0has not shipped GA (5.0.0-beta.6). Dropping CJS is a breaking change; doing it inside the existing beta line costs nothing, doing it after GA costs a major.enginesis alreadynode >=22. Node ≥ 22.12 supportsrequire(esm)unflagged, so CJS consumers keep working through the interop rather than through a second build.tscmigration cheap. Dual-formattscneeds two tsconfigs, two output trees andpackage.jsontype shims per directory; single-formattscneeds one tsconfig and nothing else.Current state (measured on
main, 8949082)turbo run build --force)packages/protocolbuildpackages/compactbuild (tsc)protocol,midnight-js,testkit-js-e2e) bypass the shared factory with a copiedentries.flatMap(...)rollupindevDependenciesyarn workspace @midnight-ntwrk/midnight-js-contracts buildfails withcommand not found: rollup; builds only work via turbo from the root. Violates "Missing devDependencies" in CLAUDE.mdresolve()andcommonjs()in the factory can never fire —externalpre-empts them.mjs,.cjs,.d.mts,.d.cts,.d.ts)Correctness cost, not hypothetical:
388ceee5("share one error module across protocol bundles"). Each rollup entry is bundled independently, so a relatively-importedsrc/errors.tswas inlined into every entry and each declared its ownclass …Error. A class only ever equals itself, soinstanceofansweredfalseacross entries of the same package — silently, exactly where a caller distinguishes failure modes. The fix needed a bespokeshare-error-modulerollup plugin,Symbol.hasInstanceon the error classes, and a dist-level identity test. Under per-filetscoutput that class of bug cannot exist: module identity is preserved 1:1.Target
Per published package:
"type": "module"build: "tsc -p tsconfig.build.json"module/moduleResolution:NodeNext(dropbundler)exportsmap keeps its current subpath keys, each pointing at./dist/<name>.js+./dist/<name>.d.tsmain-vs-moduleduality, drop.cjs/.d.cts/.d.mtsoutputsengines.node→>=22.12Subpath entries survive unchanged in count and laziness:
protocol(10 subpaths, incl. the lazy ledger-era accessors) andmidnight-js(5) each emit one file per source module, so lazy loading of the heavy WASM eras becomes easier to reason about than it is today, not harder.Scope
15 packages need work (
compactis already there):contracts,dapp-connector-proof-provider,fetch-zk-config-provider,http-client-proof-provider,indexer-public-data-provider,level-private-state-provider,logger-provider,midnight-js,network-id,node-zk-config-provider,protocol,types,utils,testkit-js,testkit-js-e2eSuggested phases, each independently mergeable:
network-id, 5 specifiers, 64 LOC). Prove the tsconfig, theexportsshape and the published-artifact resolution end to end. Record before/after build time.contracts186,indexer-public-data-provider82,testkit-js54,utils41,types36,testkit-js-e2e29,level-private-state-provider20,protocol10, rest ≤ 8). Zero of them are directory imports, so this is a pure "append.js" rewrite with no/index.jsspecial cases. Land with an ESLint rule (import/extensions) so it cannot regress.types→network-id/utils→ providers →contracts→midnight-jsbarrel → testkit.build-tools/rollup.config.factory.mjs, all 16rollup.config.mjs, theshare-error-moduleplugin and theSymbol.hasInstanceworkaround (keep the dist identity test as a regression gate), rollup deps from the rootpackage.json, therollupresolution pin, and the.rollup.cacheentries incleanscripts.Acceptance criteria
tsconly; no rollup left in the repo (grep -r rollupclean outside lockfile history)yarn workspace <pkg> buildworks standalone for every package, not just via turbo from the rootturbo run build --forcemeasurably faster; number recorded in the PRrequire()consumer on Node ≥ 22.12 — verified against a packed tarball (yarn pack), not against the workspaceexportssubpath keys unchanged forprotocolandmidnight-js; no internal module reachable that was not reachable beforeexportsmapdist-lazinessgate passes)share-error-moduleplugin removedyarn check, full unit suite, integration and e2e greentestkit-js-e2egonedocs-api(typedoc) still generates — it reads sources viaentryPointStrategy: packages, so it should be unaffected; confirm rather than assumerequire(esm)migration note for consumersRisks and open questions
moduleResolution: bundler→NodeNextis the real risk, not the codemod.bundleris permissive about dependencyexportsmaps;NodeNextis not. Needs a spike per risky dependency:@apollo/client/*subpaths (/core,/link/http,/link/retry,/link/subscriptions,/utilities),isomorphic-ws,cross-fetch,graphql-ws, and the WASM packages@midnightntwrk/ledger-v9,@midnightntwrk/onchain-runtime-v4,@midnight-ntwrk/compact-runtime,@midnight-ntwrk/platform-js.packages/indexer-public-data-provider/src/config.ts:18-23documents thatisomorphic-ws(module.exports = require('ws'), noexportsmap) exposes onlydefaultto the ESM build and named imports work only in the CJS build. Removing the CJS build removes that escape hatch — the workaround must be verified as the permanent path.__DEBUG__loses its build-time substitution. Only consumer ispackages/contracts/src/submit-tx.ts(declared at :28, used at :60 and :87), fed today by@rollup/plugin-replaceoffprocess.env.CI. Needs a decision: runtime flag, or keep a minimal transform. Note the current mechanism ships different code to npm depending on the CI env var — worth revisiting on its own merits.require(esm), breaks. Needs a product call — sign-off required before phase 3.docs/releases/v3.0.0/new-features.md:325still shows arequire()example and would need updating.importHelpers: trueintsconfig.base.json:7with notslibdependency anywhere. A no-op attarget: es2024, but it should be removed rather than left as a trap..d.tsAPI-report gate (onfeat/1006-types-d14-api-report-gate) reads a rolled-up single.d.ts. Per-file declarations change its input shape; the gate needs reworking in the same series.Sequencing
Must land after the HF v8→v9 MJS-01 stack (#1004 and PRs #1155 / #1156 / #1159 / #1164). A 491-specifier codemod plus 15
package.jsonrewrites would conflict with everything currently in flight, andprotocolis the package that stack is actively reshaping.Non-goals
packages/compactalready provestscis sufficient. Revisit only if a browser-targeted, dependency-inlining artifact becomes a requirement.