Skip to content

[Task] Replace rollup with tsc and go ESM-only across all published packages #1173

Description

@sp-io

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:

  1. 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.
  2. 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.
  3. Per-package migration, leaves first, following the dependency layering in CLAUDE.md: typesnetwork-id/utils → providers → contractsmidnight-js barrel → testkit.
  4. 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

  • Every published package builds with tsc only; no rollup left in the repo (grep -r rollup clean outside lockfile history)
  • yarn workspace <pkg> build works standalone for every package, not just via turbo from the root
  • Cold turbo run build --force measurably faster; number recorded in the PR
  • Published artifact resolves for an ESM consumer and for a require() consumer on Node ≥ 22.12 — verified against a packed tarball (yarn pack), not against the workspace
  • exports subpath keys unchanged for protocol and midnight-js; no internal module reachable that was not reachable before
  • Deep-import of an internal file is still blocked by the exports map
  • Lazy ledger-era loading still lazy (existing dist-laziness gate passes)
  • Error-class identity across subpaths still holds (existing dist identity gate passes) with the share-error-module plugin removed
  • yarn check, full unit suite, integration and e2e green
  • TS5069 warning in testkit-js-e2e gone
  • docs-api (typedoc) still generates — it reads sources via entryPointStrategy: packages, so it should be unaffected; confirm rather than assume
  • CHANGELOG entry flags the CJS drop as breaking, with the require(esm) migration note for consumers

Risks and open questions

  1. moduleResolution: bundlerNodeNext 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.
  2. 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.
  3. __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.
  4. 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.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bot:ai-assistedAuthored or substantially edited by an AI agentchoreMaintenance, refactoring, toolingcomponent:midnight-jsBrowser/Node JavaScript SDK and provider implementationsstatus:untriagedTriage has not yet classified this issue

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions