Skip to content

node: ESM resolver error-code parity — ERR_PACKAGE_PATH_NOT_EXPORTED and friends (+4 tests) - #35711

Draft
cirospaciari wants to merge 16 commits into
claude/node-uncaught-printer-parityfrom
claude/node-esm-error-codes
Draft

cirospaciari wants to merge 16 commits into
claude/node-uncaught-printer-parityfrom
claude/node-esm-error-codes

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 25, 2026 •

Copy link
Copy Markdown
Member

Stacked on #35527 (claude/node-uncaught-printer-parity) — that branch owns ResolveMessage's Node-shape surface (name/toString/stack); this PR adds the error-code layer on top. The first commits here are that branch; this PR's diff is the commits after 4319cb37c4.

What this does

Bun's resolver internally distinguishes every Node ESM failure case (Status::PackagePathNotExported, InvalidPackageTarget, InvalidModuleSpecifier, …) but collapsed them all to MatchStatus::NotFound before the JS error was built, so every failure surfaced as a generic "Cannot find module/package" with ERR_MODULE_NOT_FOUND. This PR threads the real failure reason through to the JS error surface, producing Node v26.3.0's exact codes and message strings:

  • ERR_PACKAGE_PATH_NOT_EXPORTED — Package subpath './x' is not defined by "exports" in <pkg>/package.json[ imported from <base>] / No "exports" main defined in …
  • ERR_PACKAGE_IMPORT_NOT_DEFINED (TypeError) — Package import specifier "#x" is not defined in package <pkg>/package.json imported from <base>
  • ERR_INVALID_PACKAGE_TARGET — Invalid "exports|imports" [main ]target "<t>" defined [for '<key>' ]in the package config …[; targets must start with "./"]
  • ERR_INVALID_MODULE_SPECIFIER (TypeError) — pattern mismatches, encoded / \ separators, invalid package names (Node's parsePackageName rules), and #/#/ imports specifiers
  • ERR_INVALID_PACKAGE_CONFIG — unparseable candidate package.json (Invalid package config <path> while importing "<spec>" from <base>. for ESM, trailing-period form for require)
  • MODULE_NOT_FOUND vs ERR_MODULE_NOT_FOUND — exports-resolved targets that don't exist report the resolved filesystem path (Cannot find module '/abs/…/no-such-file.js'), spelled the historic way for require() (no Require stack for this shape, matching Node's createEsmNotFoundErr)
  • ERR_UNSUPPORTED_ESM_URL_SCHEME — import('http://…') previously resolved and died with ENOENT reading "http://…"; now fails at resolve time with Node's message (incl. the http/https vs other-scheme supported-list wording split)
  • ERR_UNKNOWN_MODULE_FORMAT (RangeError) — import('data:text/plain,…') previously executed the payload as JavaScript; data: URLs whose MIME type has no module format now reject like Node

How

Advisory capture, zero resolution-behavior change. The resolver records the first Node-shaped failure (message head + failing key/target context from a new Resolution.detail) in a new Resolver.node_module_error side channel. Nothing in the resolution flow branches on it; the bundler never reads it. The runtime's two resolve hooks clear it before resolving and read it only after a failed resolve, splicing in the referrer clause (whose wording depends on the error kind and import/require) and tagging the Msg with new bun_ast::Error variants. ResolveMessage maps the tags to code / name (Error vs TypeError vs RangeError) / bracketed display name, passes the preformatted text through, and the uncaught printer appends Node's {\n code: 'ERR_…'\n} block for tagged errors.

This keeps Bun's deliberate leniencies intact: resolution that succeeds where Node would throw (invalid package name that exists on disk, "#fs": "node:fs" builtin imports targets per #4972, directory imports, JSON imports without with { type: 'json' }) still succeeds — only the error identity of failures changes.

The exact fixing line for the "collapse": handle_esm_resolution's top guard in src/resolver/resolver.rs now calls capture_esm_resolution_failure before returning MatchStatus::NotFound.

Tests

Vendored upstream (byte-identical to v26.3.0; each fails on the unfixed build, passes now, canary-verified to execute):

  • test/es-module/test-require-module-conditional-exports.js
  • test/es-module/test-esm-invalid-pjson.js
  • test/es-module/test-esm-pkgname.mjs
  • test/es-module/test-esm-invalid-data-urls.js

Plus a 4-test matrix in test/js/bun/resolve/resolve-error.test.ts snapshotting {name, code, message} for 30 import/require failure scenarios — every line oracle-diffed against node v26.3.0 output on the identical fixture tree (the only deliberate divergences: e.constructor.name is still ResolveMessage, and data:application/json imports stay allowed without an import attribute, Bun policy).

scripts/runner.node.mjs registers js/node/test/es-module/ (same one-line hunk as #35697/#35536; merges clean).

Known divergences (documented, matrix tests that need them are not vendored here)

  • Number/boolean exports targets render without the value in ERR_INVALID_PACKAGE_TARGET (Bun's exports parse doesn't keep the raw token; Node prints "1234")
  • err.url own property on ERR_MODULE_NOT_FOUND / ERR_UNSUPPORTED_DIR_IMPORT not yet exposed
  • Structurally-invalid exports maps that Bun's parser accepts (numeric keys) still report PATH_NOT_EXPORTED

Follow-up in second commit (review of the full resolve suite)

  • Bun.resolveSync / import.meta.resolveSync keep returning http://-style specifiers as-is (documented behavior; Node's import.meta.resolve also returns them) — the fail-fast scheme/data: check lives only on the module loader's resolve entry (VirtualMachine::resolve)
  • exports targets that overflow Bun's path-length limit after wildcard expansion keep the generic MODULE_NOT_FOUND shape (Node has no such limit and reports not-found for the expanded path; tagging them INVALID_MODULE_SPECIFIER would diverge)
  • test/js/bun/resolve/resolve.test.ts encoded-separator expectations updated to the Node contract this PR implements (TypeError ERR_INVALID_MODULE_SPECIFIER, oracle-verified; the invariant — rejection — is unchanged). The undecodable-%% case stays ERR_INVALID_MODULE_SPECIFIER where Node throws a code-less URIError (comment in test).

Regression runs (debug build): test/js/bun/resolve suite — remaining failures are pre-existing (3 runuser: Permission denied root-environment failures, and the two load-same-js-file-a-lot timeouts reproduce identically on a clean build of the base commit, ~7ms/import debug on both). test/js/node/module (93 pass), require-extensions, import-meta, custom-condition/esModule-annotation suites green. bun build output for exports failures unchanged (bundler never reads the capture).

The resolver collapsed every exports/imports-map failure, invalid package
name, and malformed package.json to a generic module-not-found before the
JS error was built, so Node's ERR_PACKAGE_PATH_NOT_EXPORTED,
ERR_PACKAGE_IMPORT_NOT_DEFINED, ERR_INVALID_PACKAGE_TARGET,
ERR_INVALID_PACKAGE_CONFIG, and ERR_INVALID_MODULE_SPECIFIER never
surfaced.

- resolver: capture the first Node-shaped failure (status + failing map
  key/target detail) in an advisory side channel that never changes the
  resolution outcome; only the runtime resolve hooks read it, after a
  failed resolve
- ResolveMessage: new preformatted error tags map to Node's exact code,
  error-class name, and bracketed display name; message text passes
  through untouched
- fail fast on specifiers the default ESM loader can never load:
  unsupported URL schemes (ERR_UNSUPPORTED_ESM_URL_SCHEME) and data: MIME
  types without a module format (ERR_UNKNOWN_MODULE_FORMAT)
- uncaught tagged errors print Node's trailing { code: '...' } block
- vendor four upstream test/es-module tests this unlocks and register the
  es-module directory with the CI runner
@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 11:09 PM PT - Aug 21st, 2026

❌ @robobun, your commit 0cf9e01 has 3 failures in Build #103296 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35711

That installs a local version of the PR into your bun-35711 executable, so you can run:

bun-35711 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. resolver: throw ERR_INVALID_PACKAGE_CONFIG for malformed package.json #33890 - Both implement ERR_INVALID_PACKAGE_CONFIG for malformed package.json; resolver: throw ERR_INVALID_PACKAGE_CONFIG for malformed package.json #33890 focuses on that single error code while this PR subsumes it as part of broader ESM error-code parity

🤖 Generated with Claude Code

…erflows keep the generic not-found shape

- the scheme/data: fail-fast check moves from the shared resolve core to
  the module loader's entry so Bun.resolveSync and import.meta.resolve
  still return http:// specifiers unchanged (Node's import.meta.resolve
  does the same)
- exports targets that only overflow Bun's path-length limit after
  wildcard expansion keep reporting MODULE_NOT_FOUND (Node has no such
  limit and reports not-found for the expanded path)
- update the percent-encoded-separator expectations in resolve.test.ts to
  the Node contract (TypeError ERR_INVALID_MODULE_SPECIFIER)
@cirospaciari
cirospaciari force-pushed the claude/node-esm-error-codes branch from 50c8469 to 4cb2618 Compare July 25, 2026 13:35
cirospaciari and others added 14 commits July 25, 2026 13:39
…ity' into claude/node-esm-error-codes

# Conflicts:
#	src/jsc/VirtualMachine.rs
#	src/resolver/package_json.rs
#	src/runtime/jsc_hooks.rs
…clippy lints

- src/jsc/bindings/BunHeapProfiler.h: restored (deleted by #36500 on main,
  still needed for $newCppFunction in src/js/node/v8.ts -> GeneratedJS2Native.h)
- src/jsc/web_worker.rs: parent_ref binding was removed by the &self-only
  refactor merge; use the surrounding unsafe { (*parent).field } pattern
- clippy: question_mark/manual_contains in ResolveMessage.rs,
  redundant_slicing in package_json.rs, unnecessary_lazy_evaluations in
  BunHeapProfiler.rs
…ity' into claude/node-esm-error-codes

# Conflicts:
#	src/jsc/bindings/BunHeapProfiler.h
steipete added a commit to openclaw/bun that referenced this pull request Oct 3, 2026
Retain selected package read and parse failures, reproduce Node's shallow
metadata reader, and preserve lazy validation and bundler metadata semantics.
Target Node 24.21 for unreadable metadata; both 24.19 and 24.21 reject the
malformed dependency fixture. Cover resolution-only scopes, condition arrays,
parent error ordering, and CommonJS diagnostics.

Adapts oven-sh#33890 and oven-sh#35711. Uses the resolved-key
distinction documented by oven-sh#44473 to preserve CommonJS scope rules.

Co-authored-by: Ciro Spaciari MacBook <ciro@anthropic.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
steipete added a commit to openclaw/bun that referenced this pull request Oct 3, 2026
Runtime resolution currently discards selected malformed package metadata, allowing optional-dependency fallbacks that Node rejects. Retain package read/parse failures until resolution selects the package or its scope, then throw Node's error with the same code, class, path, and importer context. Explicit module extensions, nested scopes, unused conditions, and fields Node ignores keep their observed behavior.

Selected dependency reads materialize both package maps and preserve Node's SyntaxError diagnostics, including UTF-16 context. CommonJS self lookup preserves lazy getters; #imports reads imports first. JSON-encoded maps in string fields follow Node's reader.

The metadata reader follows Node's shallow package reader, rather than applying strict JSON validation to unused values. Runtime-specific escaped/duplicate-field semantics use a separate metadata view so Bun's bundler retains its existing field handling. ESM resolve-only lookups validate existing .js/.ts/extensionless scopes, with missing-file and explicit-format exemptions. The resolved-key distinction documented by oven-sh#44473 prevents reapplying ESM scope validation when Bun hands an already-resolved CommonJS file to its ESM loader; this does not port that PR's broader loader rewrite. Conditional target arrays continue after invalid/null alternatives and retain the final error. CommonJS missing-module diagnostics now retain parent filenames.

This deliberately targets **Node 24.21** for unreadable selected metadata: it throws `ERR_INVALID_PACKAGE_CONFIG`, whereas 24.19 treated read failures as absent metadata. The compatibility documentation records that choice. This version difference is separate from OpenClaw's malformed dependency fixture: both 24.19 and 24.21 throw `ERR_INVALID_PACKAGE_CONFIG` for import and require, whether the dependency body exists or not (eight checks).

Adapts the malformed-scope retention approach from oven-sh#33890 (closed, unmerged) and resolver error identity work from oven-sh#35711 (open). Neither is a complete upstream fix for current Node 24 package-reader behavior. Credit to @robobun and @cirospaciari.

Validation of final head `0a08f0c3218d10a886b160ebc60eeb97ac847190`:

- 944-case Node oracle: import/require across malformed JSON, field values, empty/missing/BOM metadata, explicit formats, nested scopes, and self references; the immutable final-head binary matches outcomes, codes, and normalized messages in all 944 cases. All 178 package-config errors have the expected Error name; Bun's existing ResolveMessage name remains unchanged for generic missing-module errors.
- Final regression control: 87 failures on unpatched fork main across 138 tests. The final head passes 737 targeted tests, with 1 existing skip and 1 todo. All 12 Rust targets and formatting pass. The original 944-case oracle, expanded 376-case package-map oracle and 14 getter-order cases all match Node; 1,850 additional JSON diagnostic cases match. Local and required branch Codex P2 reviews are scoped-clean; the branch review uses merge-base `486288f80d`.
- Final-head OpenClaw consumer comparison: conditions 40/44 → 42/44 (Node 44/44); interop 56/56 → 56/56; lazy-alias 24/24 → 24/24. The two remaining conditions failures are independently reproduced OpenClaw capture-adapter defects: missing retained symlink alias materialization and premature nested dependency capture during a compiler preview. No OpenClaw source changes. Final proof has zero skips and verifies the binary hash before and after execution.
- Existing Linux #79 proof remains valid: lifetime 8/8 in Node and fork main, with end/close in all four socket combinations. It was not rerun for this change.

The patch is rebased onto main `486288f80d` (#85), preserving the changelog append-only. Earlier surrounding execution also reproduced three unchanged `esModule-annotation.test.js` failures on the unpatched control; this PR does not claim that broader file is green.

Both fork build/test CI lanes passed in https://github.com/openclaw/bun/actions/runs/37096840734 for exact head `0a08f0c3218d10a886b160ebc60eeb97ac847190`. The live merge gate confirms every non-skipped check is successful.

Package-map resolution uses an explicit imports/exports context. Local workspace Clippy for Linux and the complete CI Rust lint workflow (Clippy, Mordant, Miri and vendored tests) pass on this exact head.

Upstream submission: oven-sh#44512. Its adaptation uses upstream’s existing resolved-key handling and omits fork-only module-hook integration.

Co-authored-by: Ciro Spaciari MacBook <ciro@anthropic.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>

This branch has not been deployed

No deployments
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.

2 participants