Skip to content

loader: implement package maps#62239

Open
arcanis wants to merge 18 commits intonodejs:mainfrom
arcanis:mael/package-maps
Open

loader: implement package maps#62239
arcanis wants to merge 18 commits intonodejs:mainfrom
arcanis:mael/package-maps

Conversation

@arcanis
Copy link
Contributor

@arcanis arcanis commented Mar 13, 2026

This PR adds a new --experimental-package-map=<path> flag letting Node.js resolve packages using a static JSON file instead of walking node_modules directories.

node --experimental-package-map=./package-map.json app.js

Why?

The node_modules resolution algorithm predates npm and its clear definition of the concept of packages. It works well enough and is widely supported, but has known issues:

  • Phantom dependencies - packages can accidentally import things they don't declare, because hoisting makes transitive dependencies visible

  • Peer dependency resolution is broken in monorepos - if website-v1 uses react@18 and website-v2 uses react@19, and both use a shared component-lib with React as a peer dep, there's no node_modules layout that resolves correctly. The shared lib always gets whichever React was hoisted.

  • Hoisting is lossy - runtimes can't tell if an import is legitimate or accidental

  • Resolution requires I/O - you have to hit the filesystem to resolve packages

Package managers have tried workarounds (pnpm symlinks, Yarn PnP), but are either limited by what the filesystem itself can offer (like symlinks) or by their complexity and lack of standardization (like Yarn PnP). This PR offers a mechanism for such tools to solve the problems listed above in tandem with Node.js.

How it works

A package-map.json declares packages, their locations (relative to the package map), and what each can import:

{
  "packages": {
    "my-app": {
      "path": "./src",
      "dependencies": {
        "lodash": "lodash",
        "react": "react"
      }
    },
    "lodash": {
      "path": "./node_modules/lodash"
    },
    "react": {
      "path": "./node_modules/react"
    }
  }
}

When resolving a bare specifier:

  1. Find which package contains the importing file (ideally by keeping track of package IDs during resolution, but for now by checking paths)
  2. Look up the specifier in that package's dependencies
  3. If found, resolve to the target's path
  4. If not found but exists elsewhere in the map → ERR_PACKAGE_MAP_ACCESS_DENIED
  5. If not in the map at all → MODULE_NOT_FOUND

Compatibility

An important aspect of the package maps feature that separates it from competing options like Yarn PnP is its builtin compatibility with node_modules installs. Package managers can generate both node_modules folders AND package-map.json files, with the later referencing paths from the former.

Tools that know how to leverage package-map.json can then use this pattern for both static package resolution and strict dependency checks (with optional fallbacks to hoisting if they just wish to use the package map information to emit warnings rather than strict errors), whereas tools that don't will fallback to the classical node_modules resolution.

Differences with import maps

Issue #49443 requested to implement import maps. In practice these aren't a good fit for runtimes like Node.js for reasons described here and which can be summarized as: import maps take full ownership of the resolution pipeline by spec, thus preventing implementing additional runtime-specific behaviours such as exports or imports fields.

This PR comes as close from implementing import maps as possible but with a very light difference in design making it possible to stay compatible with other Node.js resolution features.

Why not a loader?

The ecosystem now has to deal with a variety of third-party resolvers, most of them not implementing the loader API for many different reasons: too complex, turing-complete, or dependent on a JS runtime.

After I've been following this path for more than six years I can confidently say that loaders would work for Node.js itself but wouldn't be standard enough to be included in at least some of those popular third-party tools.

Questions

  • The current implementation makes package maps strict: if they find an issue, they throw and refuse the resolution. Should we instead delegate to the default resolution unless an additional --experimental-strict-package-maps is set? Or via a strict field in package-map.json.

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/config
  • @nodejs/loaders

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Mar 13, 2026
@zkochan
Copy link

zkochan commented Mar 13, 2026

I like the idea, it would greatly reduce the amount of filesystem operations that pnpm has to do in order to create an isolated node_modules layout using symlinks.

I also suggested arcanis to possibly go one layer deeper and allow to map the individual files of packages. This would allow to map node_modules directly from a content-addressable store (that consists of package files). Of course, that would increase the size of the file several times but it would also make installation even faster.

@codecov
Copy link

codecov bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 99.22879% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.71%. Comparing base (66a687f) to head (9c8d726).
⚠️ Report is 59 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/modules/cjs/loader.js 96.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #62239      +/-   ##
==========================================
+ Coverage   89.66%   89.71%   +0.04%     
==========================================
  Files         676      677       +1     
  Lines      206462   207080     +618     
  Branches    39533    39656     +123     
==========================================
+ Hits       185128   185781     +653     
+ Misses      13461    13432      -29     
+ Partials     7873     7867       -6     
Files with missing lines Coverage Δ
lib/internal/errors.js 97.63% <100.00%> (+0.01%) ⬆️
lib/internal/modules/esm/resolve.js 99.05% <100.00%> (+0.10%) ⬆️
lib/internal/modules/package_map.js 100.00% <100.00%> (ø)
src/node_options.cc 76.47% <100.00%> (+0.02%) ⬆️
src/node_options.h 97.93% <ø> (ø)
lib/internal/modules/cjs/loader.js 98.24% <96.00%> (+0.09%) ⬆️

... and 53 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@aduh95
Copy link
Contributor

aduh95 commented Mar 14, 2026

This seems quite close to the importmap HTML feature, but using a different syntax. Have you considered reusing the same syntax, or at least a compatible JSON structure?

@arcanis
Copy link
Contributor Author

arcanis commented Mar 14, 2026

I did, but felt that the semantics were too different; import maps have two fields:

  • the imports field is a global resolution map, keyed by bare identifiers. It wouldn't work for packages as that field is a flat map of all packages in the project, and thus must be keyed by arbitrary package IDs to allow for multiple packages sharing the same name (ie multiple versions of a same package in the same dependency tree).

  • the scopes field is keyed by filesystem path. This is a problem because it precludes a same folder from having multiple package IDs each with their own dependency set, necessary to represent peer dependencies with workspaces.

Neither of those match the semantics we need, and reusing them just for their name but with different semantics would have been imo misleading for third-party resolver implementors.

@jasnell
Copy link
Member

jasnell commented Mar 14, 2026

Love it. I'll try to give a detailed review on the flight home today if the in flight wifi treats me kindly.

@bakkot
Copy link
Contributor

bakkot commented Mar 15, 2026

What happens if two packages define the exact same path? An error, presumably, given that the algorithm relies on being able to determine for each path which package it belongs to? Needs a test in any case.

@arcanis
Copy link
Contributor Author

arcanis commented Mar 15, 2026

What happens if two packages define the exact same path? An error, presumably, given that the algorithm relies on being able to determine for each path which package it belongs to? Needs a test in any case.

I'll follow-up with a separate improvement to key module instances per both their path and their package IDs (it's key to solve the peer dependency problem I mentioned in the PR description), but for this iteration only one package ID per path is supported. I updated the code to throw an error accordingly.

@arcanis
Copy link
Contributor Author

arcanis commented Mar 21, 2026

@guybedford @avivkeller can I get another review? once this land I'll start looking at a prototype for generating package maps in package managers, and the package ID follow-up

Co-authored-by: Antoine du Hamel <[email protected]>
`import dep from 'dep-a'; console.log(dep);`,
], { cwd: fixtures.path('package-map/root') });

assert.strictEqual(stderr, '');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of removing the test, you can pass --no-warnings

packages without symlinks or hoisting complexities.
* **Dependency isolation**: Prevent packages from accessing undeclared
dependencies (phantom dependencies).
* **Multiple versions**: Allow different packages to depend on different
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m confused; this is already the case by default.


In the example above both `lib-old` and `lib-new` use the same `./lib` folder to
store their sources, the only difference being in which version of `react` they'll
access when performing `require` calls or using `import`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work given the module cache? Wont the first one to load’s react version “win”?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants