Skip to content

fix: strip CJS-bridge artifact in ESM resolve to unblock Node 24 require(esm) under tsImport()#802

Open
ryanbonial wants to merge 1 commit into
privatenumber:masterfrom
ryanbonial:fix/cjs-bridge-artifact-esm-resolve
Open

fix: strip CJS-bridge artifact in ESM resolve to unblock Node 24 require(esm) under tsImport()#802
ryanbonial wants to merge 1 commit into
privatenumber:masterfrom
ryanbonial:fix/cjs-bridge-artifact-esm-resolve

Conversation

@ryanbonial

Copy link
Copy Markdown

Summary

tsImport() fails on Node 24 with ERR_MODULE_NOT_FOUND when a CommonJS-context .ts file imports an ESM dependency. The same code works on Node 22.

Closes #801
Repro: https://github.com/ryanbonial/tsx-pkg-utils-node24-repro

Observed in the wild while building Sanity packages that consume @sanity/pkg-utils, whose loadConfig() calls tsImport(pathToFileURL(configFile).toString(), import.meta.url) for a project package.config.ts that imports from @sanity/pkg-utils.

Why

  1. The .ts lives in a CJS context, so it routes through the CJS bridge.
  2. src/cjs/api/module-resolve-filename/preserve-query.ts appends ?namespace=<id> to the resolved file path for per-tsImport() Module._cache isolation.
  3. esbuild compiles import {x} from 'esm-pkg' into require('esm-pkg').
  4. On Node 22, require(esm) stays inside CJS code paths and the ?namespace= query never leaks into ESM resolve.
  5. On Node 24, require(esm) dispatches through the module customization hooks. pathToFileURL() encodes the ? as %3F inside the URL pathname. The ESM resolve hook does not recognize this as a tsx ESM URL (no tsx-commonjs-virtual-query=1 marker) and forwards it to nextResolve, which stats a literal index.js?namespace=<id> path and throws.

What

Add stripCjsBridgeArtifact() in src/esm/hook/resolve.ts and call it on specifier and context.parentURL at the entry of both createResolve and createResolveSync. It detects a file: URL whose pathname contains a percent-encoded ? and which lacks the tsx-commonjs-virtual-query=1 marker, then truncates the pathname back to the real file. The CJS bridge keeps its own Module._cache keys keyed off the namespace, so nothing else needs the query at this point.

Tests

New test in tests/specs/api.ts: tsImport() > requires a CJS dependency from CJS-context .ts under namespace.

Fails on master across the Node matrix and passes with this change. The test uses require() of a .cjs file (not require(esm)) because that path exercises the exact same %3Fnamespace= artifact on every supported Node version, giving stronger regression coverage than a Node-24-only ESM-from-CJS case.

Full suite (pnpm test) green on the local .nvmrc Node 24.15.0.

Risk

Scoped to file: URLs whose pathname already contains a percent-encoded ?. Real tsx ESM URLs use real ? query strings (not encoded), so they take the existing fast-path return. URLs with the explicit tsx-commonjs-virtual-query=1 marker bypass the strip entirely.

Made with Cursor

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.

tsImport() fails with ERR_MODULE_NOT_FOUND on Node 24 when a CommonJS-context .ts file imports an ESM dependency

1 participant