fix(esm): make the published ESM build loadable under Node's native loader#1475
fix(esm): make the published ESM build loadable under Node's native loader#1475droplet-rl wants to merge 1 commit into
Conversation
…oader
The ESM build (`tsc --module es2015` + a stamped `{"type":"module"}`) emitted
output that Node's native ESM loader rejects, so any consumer loading the
package under real Node ESM (Vite dev SSR, Next RSC, ts-node/ESM, plain `node`)
crashes. Bundler consumers (webpack/rollup/vite build) tolerate it, which is
why it went unnoticed.
Four classes of fix — no public API or output-layout change, so the
`./dist/esm/*` / `./dist/cjs/*` deep paths consumers rely on still resolve:
- Relative imports were extensionless. Add a `tsc-alias --resolve-full-paths`
post-step to `build:esm`; emitted relative imports get `.js` / `/index.js`
(606 specifiers, zero source churn).
- Deep imports into exports-less CJS deps were extensionless: add explicit
`.js`/`/index.js` for `@across-protocol/contracts/dist/*`,
`@eth-optimism/sdk/dist/*`, `ethers/lib/utils`, `arweave/node/lib/*`.
- CJS named-import interop: `lodash` and `@coral-xyz/anchor` don't expose
Node-ESM-detectable named exports; switch to default/namespace import with
an interop fallback (dual CJS/ESM safe).
- `require.main === module` CLI guards throw in ESM scope; gate behind
`typeof require !== "undefined"`.
Verified: `yarn dev` (cjs/esm/types) clean; ESM and CJS `index` both import
clean under `node`; the across-protocol/dapp SDK import set loads under Node
native ESM; eslint + prettier clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@codex review |
Review —
|
|
Codex Review: Didn't find any major issues. Another round soon, please! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Thanks Codex — that matches my manual pass (built the branch and exercised the actual To consolidate for whoever merges: the only open item is the non-blocking one from my earlier review — No changes needed otherwise. LGTM. |
gsteenkamp89
left a comment
There was a problem hiding this comment.
@droplet-rl For my understanding, why are we adding file extensions to some imports but not all?
|
@gsteenkamp89 Good question — it looks selective in the diff, but the underlying rule is uniform: Node's ESM loader needs an explicit file extension exactly when a specifier resolves to a literal file path it can't otherwise complete. That splits imports into three buckets: 1. Relative imports ( 2. Bare-package imports to a package root or its declared subpath exports — no extension, intentionally. 3. Bare-package imports that reach a literal file inside a dependency's tree — these get So: every relative import → extension (auto, at build); deep file-paths into exports-less deps → extension (manual, in source); package-root / named-subpath imports → no extension (resolved via the dep's package.json). The diff just makes (3) visible while (1) happens silently in the build. |
Problem
The published ESM build does not load under Node's native ESM loader. Any consumer that loads the package under real Node ESM — Vite dev SSR (TanStack Start / Next), Next RSC,
ts-node/ESM, or plainnode --input-type=module— crashes immediately. Bundler consumers (webpack / rollup /vite build) tolerate the malformed output, which is why this went unnoticed.This is the root cause of the
across-protocol/dappregression wherepnpm start(vite dev) 500s on every route:Root cause
The ESM build is
tsc --module es2015+ a stamped{"type":"module"}.tscneither adds extensions to relative imports nor normalizes CJS interop, so the emitted ESM is not spec-valid for Node.Fix — four classes, no public API or output-layout change
The
dist/{esm,cjs,types}/...file layout is unchanged, so the./dist/esm/*/./dist/cjs/*deep-path exports consumers rely on still resolve. (Audited the V4 prod consumers: relayer & indexer use only the package root + named subpath exports; quote-api has a few deepdist/imports — all preserved.)tsc-alias --resolve-full-pathspost-step tobuild:esm. Emitted relative imports get.js//index.js(606 specifiers fixed at build time, zero source churn)..js//index.jsfor@across-protocol/contracts/dist/*,@eth-optimism/sdk/dist/*,ethers/lib/utils,arweave/node/lib/*(tsc-alias only rewrites relative imports, not bare-package ones).lodashand@coral-xyz/anchordon't expose Node-ESM-detectable named exports. Switched to default/namespace import with an interop fallback that is dual CJS/ESM safe (verified both builds).require.main === moduleCLI guards throw in ESM scope → gated behindtypeof require !== "undefined"(inert in ESM/browser, unchanged in Node CJS).Verification
yarn dev(cjs / esm / types) builds clean;eslint+prettierclean on all changed files.indexboth import clean undernode(17 exports each).across-protocol/dappSDK import set (everythingvendor/across/sharedpulls —arch/evm/BlockUtils,utils/{Address,Network,Token,Event,Spoke,...}Utils,constants,interfaces/SpokePool, …) loads under Node native ESM: 13/13 modules, 0 failures (was 4 failures). This is exactly the dev-SSR externalization path that was failing.pnpm startbefore/after (overlaying this build): module-load errors 4 → 0.Follow-ups (separate, optional)
node --input-type=module -e 'import(".../dist/esm/src/index.js")'check) would catch ESM-load regressions that the bundler-based tests miss.preserveModules) would handle interop wholesale; this PR is the minimal, layout-preserving fix that unblocks consumers now.@across-protocol/sdk/dist/esm/src/...(e.g. the dapp'svendor/across/shared) could move to the package root — hygiene, not required by this change.🤖 Generated with Claude Code