feat: experimental support for Node.js package maps - #667
Conversation
Node.js v26.4.0 added package maps behind `--experimental-package-map`: a static table that says where each package lives and which package id every bare specifier of a given package maps to, so resolution never has to walk `node_modules`. Add a `packageMap` option taking the configuration file path (or a `file:` URL), or an already-parsed `packages` object alongside the `configFile` its relative urls resolve against. When set, `PackageMapPlugin` resolves bare specifiers through the importing package's `dependencies` table and hands the target package's location to the regular pipeline, so `exports`, `main` and extension resolution keep working unchanged. Relative and absolute requests and `node:` builtins are untouched, and the map is authoritative: an undeclared specifier is reported as unresolved rather than looked up in `node_modules`. A package map may point several package ids at one `url` — the same sources consumed with different dependency tables — so the importing package cannot always be derived from the file path. The package a request resolved into is exposed as `packageId` on the result and accepted back as `context.packageId`. Where that is missing and the answer would be a guess, resolution fails: `ERR_PACKAGE_MAP_EXTERNAL_FILE` for an importer outside every mapped package and `ERR_PACKAGE_MAP_AMBIGUOUS_PACKAGE` for a shared location. The whole surface is marked `@experimental`: package maps are stability 1 in Node.js and this implementation tracks that specification. Closes #653 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
🦋 Changeset detectedLatest commit: 78b7d87 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #667 +/- ##
==========================================
+ Coverage 98.14% 98.44% +0.29%
==========================================
Files 49 52 +3
Lines 10046 10749 +703
==========================================
+ Hits 9860 10582 +722
+ Misses 186 167 -19
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Merging this PR will regress 1 benchmark
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | extensions-many: 6-extension list (warm) |
2.2 ms | 2.8 ms | -22.08% |
| ⚡ | Memory | array-alias: @ -> [preferred, fallback] (warm) |
3.3 KB | 2 KB | +62.6% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing feat/package-map (78b7d87) with main (e913bc5)
The package map declarations were written by hand because the `tooling` dependency could not be installed here, and `lint:special` rejected them: `PackageMapOptions` sorts before `PackageMapPackage`, and the generator drops the description of a `@typedef` rather than emitting it above the interface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
Codecov reported 88.5% patch coverage: the validation branches, the Windows/UNC and percent-encoding branches of `pathToFileURL`, and the plugin's config-loading paths were all unexercised. Add a `pathToFileURL` test mirroring the `fileURLToPath` one (pinned literals, both platform branches, UNC errors, a round-trip), cover every `ERR_INVALID_PACKAGE_MAP` branch, and exercise the plugin's file-dependency recording, its log line for an undeclared specifier, the read-once queueing of concurrent first resolutions, `node:` specifiers, and both the read and the parse failure of the configuration file. The three new files are now at 100% line coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
|
One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via: Supported
Alternatively, if the co-author should not be included, remove the Please update your commit message(s) by doing |
main gained `file:` URL string support wherever a path is expected (#668), which the `packageMap` option picks up through `toPath`. Cover it the way the other path options are covered: a URL instance and a URL string as the config file, a URL instance as `options.configFile`, and `file:` URLs as package entry urls. Drops the narrower URL test from the package map suite, which this replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
bab4a8c to
6b5c2bf
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review. WalkthroughAdds experimental Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The previously reported legacy Node.js test failure is addressed by the existing compatibility polyfills, so no actionable merge risk remains. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning Some tools did not complete. Review the errors below. 🔧 ESLint
lib/PackageMapPlugin.jsESLint failed to execute (timeout). lib/ResolverFactory.jsESLint skipped: the matched ESLint configuration already failed (timeout). test/package-map.test.jsESLint skipped: the matched ESLint configuration already failed (timeout). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Advanced
Run ID: e19556a8-d3b7-4be3-ad56-eea4fde20903
📒 Files selected for processing (25)
.changeset/experimental-package-map-support.mdREADME.mdlib/PackageMapPlugin.jslib/Resolver.jslib/ResolverFactory.jslib/util/packageMap.jslib/util/pathToFileURL.jstest/file-url-options.test.jstest/fixtures/package-map/invalid-map.jsontest/fixtures/package-map/lib/index.jstest/fixtures/package-map/lib/package.jsontest/fixtures/package-map/outside/index.jstest/fixtures/package-map/package-map.jsontest/fixtures/package-map/packages/app/index.jstest/fixtures/package-map/packages/app/package.jsontest/fixtures/package-map/packages/utils/index.jstest/fixtures/package-map/packages/utils/package.jsontest/fixtures/package-map/packages/utils/sub.jstest/fixtures/package-map/vendor/component-v1/index.jstest/fixtures/package-map/vendor/component-v1/package.jsontest/fixtures/package-map/vendor/component-v2/index.jstest/fixtures/package-map/vendor/component-v2/package.jsontest/package-map.test.jstest/pathToFileURL.test.jstypes.d.ts
Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.
| const resolver = createResolver(); | ||
| const err = catchError(() => resolver(appDir, "m1")); | ||
|
|
||
| assert.match(err.message, /Can't resolve 'm1'/); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use assertion methods supported by Node.js 10.13.0.
The package declares Node.js >=10.13.0. assert.match and assert.doesNotMatch were added in Node.js 12.16.0 and 13.6.0. Node.js 10 therefore throws a TypeError at these calls. (nodejs.org)
Replace these methods with Node.js 10-compatible assertions such as assert.ok(pattern.test(value)).
Also applies to: 116-116, 168-168, 180-180, 192-192, 254-254, 264-264, 283-283, 297-297, 375-375, 395-395
There was a problem hiding this comment.
Not acting on this one — the premise doesn't hold for this repository.
test/_runner.js, which every test file here imports for describe/it, polyfills both methods at module load precisely for the legacy legs:
// `assert.match` and `assert.doesNotMatch` were added in Node.js 13.6. Legacy
// jest CI also runs on Node.js 10/12, so polyfill them when missing.
if (typeof assert.match !== "function") { … }
if (typeof assert.doesNotMatch !== "function") { … }It mutates the shared assert module object, so the patch is in place before any test body runs regardless of require order. Six existing suites already rely on this — exportsField, importsField, tsconfig-paths, description-file, file-url-options, resolve-context-stack.
The empirical check agrees: test (ubuntu-latest, 10.x) and test (macos-latest, 10.x) both passed on the commit this review looked at (6b5c2bf), running these exact assertions through jest on Node 10. Switching to assert.ok(pattern.test(value)) would lose the failure diff for no gain.
Generated by Claude Code
Four fixes from the automated review: - Run the package map ahead of `SelfReferencePlugin`. A package whose `package.json` has a `name` and `exports` could import itself through the self-reference shortcut without declaring the dependency, which contradicts the map being authoritative for bare specifiers. Node lists only relative, absolute and builtin specifiers as unaffected by package maps, so a self-reference goes through the dependency table too. - Make a relative `configFile` absolute against the working directory, like `tsconfig` does. It was previously handed to the URL parser as-is, which silently resolved package locations against the filesystem root. Where there is no working directory (the resolver also runs in browsers), say so instead. - Reject an empty package `url`. It resolved to the configuration file itself rather than to a package directory. - Keep an empty string as a package id: the id is any key of `packages`, so "not provided" has to be distinguished from falsy, or a propagated empty id would be reported as ambiguous instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
A configuration file reached through a symlink produced package locations spelled through that symlink, while the pipeline hands over importer paths with their symlinks already resolved. The two never matched, so a perfectly valid importer was rejected with ERR_PACKAGE_MAP_EXTERNAL_FILE. Resolve the configuration file to its real path when `symlinks` is on, and leave it as given when it is off, so both sides are always spelled alike. Canonicalizing unconditionally would have been wrong the other way round: it would move package locations for resolvers that deliberately keep symlinked paths. Tests cover both directions, guarded by the same privilege probe `test/symlink.test.js` uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
|
CodSpeed Performance Analysis is failing on The resolve pipeline is byte-identical. Outside the three new The failing case is the warm one: it builds one resolver outside the loop and resolves repeatedly, so per-resolve work dominates — and this PR adds none. Measured both commits locally, same machine, interleaved, with the repo's own
The branch is never slower. More to the point, the spread within each arm is about 24% — the same order as the 22% CodSpeed attributes to the change, which is what you would expect from a benchmark this short (sub-millisecond mean) on shared infrastructure. The report contradicts itself in the same run. Alongside the regression it reports a 62.6% memory improvement on On the environment mismatch, one contributing factor may be worth fixing regardless of this PR: Re-run already spent. The Benchmarks run for this head (35360874309) was first cancelled while queued 31 minutes without starting — account-wide runner starvation — and I re-ran it once; this result is that re-run. I am not re-running again. Nothing here is actionable in the code, so I am leaving the diff alone. If you would rather see the check green, the regression can be acknowledged in the CodSpeed dashboard — that is a maintainer action and not one I would take unasked. Generated by Claude Code |
|
Follow-up on the CodSpeed failure above — a control turned up that settles it better than my reasoning did. #673 changes three YAML files under
A workflow-YAML diff cannot make a benchmark allocate 36× more memory. And it is the same One benchmark swinging between -97% and +62% across three branches, one of which contains no executable change at all, is the measurement rather than the code. That is a stronger statement than the argument I made earlier from the diff and from local timings, so I wanted it on the record here rather than only on #673. Nothing changes on this PR as a result — still no code to fix, still not re-running (that was spent). Everything else on Generated by Claude Code |
Pushing a new commit to a pull request currently leaves the previous commit's jobs running to completion, even though nobody will read their results. `Test` alone is **28 jobs** per push — three operating systems × nine Node.js versions, plus `lint` — and `Cross-runtime` adds three more. PR #667 went through five pushes this afternoon, so roughly 130 job-runs were obsolete the moment they started. ## Change `Test` and `Cross-runtime` get the concurrency group `Benchmarks` already had: ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} ``` `github.workflow` is in the group key, so the workflows never cancel each other. On a `pull_request` event `github.ref` is `refs/pull/<n>/merge`, which is unique per pull request, so one PR's pushes never cancel another's. ## Why `main` is exempt The condition is the point of the change, not decoration. `Benchmarks` cancelled `main` runs until now, and this stops that too: - CodSpeed compares a pull request against the benchmark result of its **base commit**. Two merges in quick succession would cancel the first one's run and leave later comparisons without a baseline. - Codecov's project check compares against `main` coverage in the same way. - A cancelled `main` run hides a breakage that is already on the default branch. ## Left alone deliberately - **`Release`** — already has `concurrency: ${{ github.workflow }}-${{ github.ref }}`. The string form defaults `cancel-in-progress` to false, so releases queue instead of cancelling, which is what you want for something that publishes. - **`Dependabot`** — one short job that approves and enables auto-merge. Cancelling it halfway could leave a pull request without the auto-merge it was about to turn on, and there is nothing to save. ## Verification `actionlint` 1.7.7 passes on all five workflows. I checked that this means something by typo'ing the expression to `github.event_nam` and confirming it is caught: ``` .github/workflows/test.yml:16:27: property "event_nam" is not defined in object type {…} | 16 | cancel-in-progress: ${{ github.event_nam == 'pull_request' }} | ^~~~~~~~~~~~~~~~ ``` All five workflows also still parse as YAML and are Prettier-clean. No changeset: CI-only change, matching how previous `ci:` commits were handled here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF --- _Generated by [Claude Code](https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF)_ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Chores** - Improved automated workflow run management by grouping pull request runs by workflow and Git reference. - In-progress pull request runs are cancelled when a newer run starts, reducing redundant checks. - Pushes to the main branch and manually triggered runs now use independent run groups and are not cancelled, preserving benchmark baselines and ongoing validation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## enhanced-resolve@5.26.0 ### Minor Changes - Add experimental support for [Node.js package maps](https://nodejs.org/api/packages.html#package-maps) through a new `packageMap` option, which takes the path of the configuration file (or a `file:` `URL`) or an already-parsed `packages` object. When it is set, a bare specifier is resolved through the importing package's `dependencies` table and the target package's location is handed to the regular pipeline, instead of walking `node_modules`; relative and absolute requests and `node:` builtins are unaffected. Because several package entries may share one `url`, the package a request resolved into is exposed as `packageId` on the result and can be passed back in as `context.packageId` to resolve from that package unambiguously. Package maps are stability 1 (experimental) in Node.js, and this option tracks that specification and may change with it. (by [@alexander-akait](https://github.com/alexander-akait) in [#667](#667)) - Explain an `exports`/`imports` field whose conditions wrap subpaths, instead of failing with a message that points at the request. A field shaped like `{ "import": { ".": "./esm/index.js", "./*": "./esm/*.js" }, "require": "./build/bundle.js" }` is not supported by Node.js: the subpaths inside a condition are read as condition names, so they match nothing, and every request into the package failed as `"./foo" is not exported under the conditions [...]` — which reads as though the package forgot to export `./foo`. Such a failure now names the offending keys and shows the arrangement that works, with the subpaths at the top level and the conditions nested inside them. Resolution itself is unchanged: the diagnosis runs only on a request that has already failed, so nothing that resolves today starts failing, and a successful resolve does no extra work. Errors raised while processing either field also name the `package.json` they came from, which previously only appeared in the resolver log. (by [@alexander-akait](https://github.com/alexander-akait) in [#676](#676)) - Generate the published type declarations with TypeScript instead of `webpack/tooling`, which is no longer a dependency. Every name the package exported before is still exported, and `types.d.ts` is still the entry point, but the declarations themselves now live in `types/` and are emitted by `tsc` from the JSDoc in `lib/`. Two shapes follow the sources more closely than the previous generator did: the object form of `Plugin` no longer declares `this: Resolver` on `apply` (it is called as `plugin.apply(resolver)`, so `this` is the plugin), and the entries of `ResolveContext.stack` declare `name: string | undefined` rather than an optional `name`. Class fields that the old generator dropped, such as the cache backends on `CachedInputFileSystem`, are now part of the declarations. (by [@alexander-akait](https://github.com/alexander-akait) in [#675](#675)) ### Patch Changes - Size the ancestor path and segment arrays that `getPathsCached` keeps to what they actually hold: a `push`-built store keeps room for 17 entries while a path has a handful, and the cache holds these for the filesystem's lifetime. (by [@alexander-akait](https://github.com/alexander-akait) in [#681](#681)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Closes #653
Adds a
packageMapoption implementing Node.js package maps (added in v26.4.0 behind--experimental-package-map). The whole surface is marked@experimental, since the Node.js feature is stability 1 and this tracks its specification.API
Also accepts a
file:URL, or an already-parsed map alongside theconfigFileits relativeurlvalues resolve against:Semantics
Bare specifiers resolve through the importing package's
dependenciestable; the target package's location is then handed to the regular pipeline, soexports,main,mainFilesand extension resolution keep working unchanged. Relative/absolute requests andnode:builtins are untouched, per the spec's "Interaction with other resolution".The map is authoritative: a specifier the importing package does not declare is reported as unresolved rather than falling back to
node_modules.Package identity
The spec allows several package ids to share one
url— the same sources consumed with different dependency tables — so the importing package cannot always be derived from the file path. The package a request resolved into is exposed aspackageIdon the result and accepted back ascontext.packageId:Where the answer would otherwise be a guess, resolution fails rather than picking one, matching Node.js ("Node.js will throw an error rather than guess"):
ERR_PACKAGE_MAP_EXTERNAL_FILEERR_PACKAGE_MAP_AMBIGUOUS_PACKAGEERR_PACKAGE_MAP_UNKNOWN_PACKAGEurl, non-file:scheme, dangling dependency target)ERR_INVALID_PACKAGE_MAPOnly
ERR_PACKAGE_MAP_EXTERNAL_FILEis named by the Node.js docs; the other three are this package's, for conditions Node.js describes but does not give a code for. The map is static, so dangling dependency targets are rejected eagerly at parse time rather than on the request that happens to hit one.Implementation
lib/util/packageMap.jsfindPackageIdslocates an importer by path, most deeply nested package firstlib/PackageMapPlugin.jsraw-module→undescribed-resolve-in-package, reads the config lazily throughresolver.fileSystemand queues concurrent first requests so a cold start reads it oncelib/util/pathToFileURL.jsurl.pathToFileURL, needed to use the config file as the WHATWGURLbase like Node does; mirrors the existingfileURLToPathportlib/ResolverFactory.jsmodulespluginspathToFileURLoutput was diffed against Node's ownurl.pathToFileURLacross spaces,#,?, tabs, backslashes, non-ASCII and trailing separators — all identical.Inline
packagesrequire aconfigFileto resolve relative urls against: there is no working directory to fall back on, since the resolver runs in browsers too. That is enforced eagerly with a clear message.Tests
20 new tests in
test/package-map.test.jscovering resolution and sub-path forwarding, per-importer version selection, the no-node_modules-fallback rule, both option shapes, each of the four error codes,packageIdpropagation in and out (including the two-ids-one-urlcase from the spec's "Multiple packages for the same URL"), and the lookup-table edges (shared locations, nested packages, sibling directories sharing a prefix).Full suite: 1542 pass, 0 fail on Node, and green under Bun (1551) and Deno + jest (1551) via the cross-runtime workflow's own commands.
lint:code,lint:types,lint:types-test,fmt:check,lint:spellcheckandtest:browserall pass.lint:specialis the one check I could not run locally — it needs thetoolingdependency, which this sandbox's proxy blocks fromcodeload.github.com.types.d.tsis therefore hand-written to match the generator's conventions (alphabetical interface order, JSDoc-derived comments, named typedefs for the index-signature types so nothing has to be guessed). CI's lint job is the real check here; if it disagrees,npm run fix:specialwill settle it and I'll push the regenerated file.🤖 Generated with Claude Code
https://claude.ai/code/session_016aGHrb1YaEvaGwNELGEHjF
Generated by Claude Code
Summary by CodeRabbit
New Features
packageMapoption accepts configuration paths,file:URLs, or inline package data.node:requests continue using existing resolution behavior.Documentation