Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,28 @@ added:
Enable experimental support for the network inspection with Chrome DevTools.

### `--experimental-package-map=<path>`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental
Enable experimental package map resolution. The `path` argument specifies the
location of a JSON configuration file that defines package resolution mappings.

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

When enabled, bare specifier resolution consults the package map before
falling back to standard `node_modules` resolution. This allows explicit
control over which packages can import which dependencies.

See [Package maps][] for details on the configuration file format and
resolution algorithm.

### `--experimental-print-required-tla`

<!-- YAML
Expand Down Expand Up @@ -3604,6 +3626,7 @@ one is included in the list below.
* `--experimental-json-modules`
* `--experimental-loader`
* `--experimental-modules`
* `--experimental-package-map`
* `--experimental-print-required-tla`
* `--experimental-quic`
* `--experimental-require-module`
Expand Down Expand Up @@ -4197,6 +4220,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[Navigator API]: globals.md#navigator
[Node.js issue tracker]: https://github.com/nodejs/node/issues
[OSSL_PROVIDER-legacy]: https://www.openssl.org/docs/man3.0/man7/OSSL_PROVIDER-legacy.html
[Package maps]: packages.md#package-maps
[Permission Model]: permissions.md#permission-model
[REPL]: repl.md
[ScriptCoverage]: https://chromedevtools.github.io/devtools-protocol/tot/Profiler#type-ScriptCoverage
Expand Down
72 changes: 72 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,77 @@ A given value is out of the accepted range.
The `package.json` [`"imports"`][] field does not define the given internal
package specifier mapping.

<a id="ERR_PACKAGE_MAP_ACCESS_DENIED"></a>

### `ERR_PACKAGE_MAP_ACCESS_DENIED`

<!-- YAML
added: REPLACEME
-->

A package attempted to import another package that exists in the [package map][]
but is not listed in its `dependencies` array.

```mjs
// package-map.json declares "app" with dependencies: ["utils"]
// but "app" tries to import "secret-lib" which exists in the map

// In app/index.js
import secret from 'secret-lib'; // Throws ERR_PACKAGE_MAP_ACCESS_DENIED
```

To fix this error, add the required package to the importing package's
`dependencies` array in the package map configuration file.

<a id="ERR_PACKAGE_MAP_INVALID"></a>

### `ERR_PACKAGE_MAP_INVALID`

<!-- YAML
added: REPLACEME
-->

The [package map][] configuration file is invalid. This can occur when:

* The file does not exist at the specified path.
* The file contains invalid JSON.
* The file is missing the required `packages` object.
* A package entry is missing the required `path` field.

```console
$ node --experimental-package-map=./missing.json app.js
Error [ERR_PACKAGE_MAP_INVALID]: Invalid package map at "./missing.json": file not found
```

<a id="ERR_PACKAGE_MAP_KEY_NOT_FOUND"></a>

### `ERR_PACKAGE_MAP_KEY_NOT_FOUND`

<!-- YAML
added: REPLACEME
-->

A package's `dependencies` array in the [package map][] references a key that
is not defined in the `packages` object.

```json
{
"packages": {
"app": {
"name": "app",
"path": "./app",
"dependencies": ["nonexistent"]
}
}
}
```

In this example, `"nonexistent"` is referenced in `dependencies` but not
defined in `packages`, which will throw this error.

To fix this error, ensure all keys referenced in `dependencies` arrays are
defined in the `packages` object.

<a id="ERR_PACKAGE_PATH_NOT_EXPORTED"></a>

### `ERR_PACKAGE_PATH_NOT_EXPORTED`
Expand Down Expand Up @@ -4463,6 +4534,7 @@ An error occurred trying to allocate memory. This should never happen.
[domains]: domain.md
[event emitter-based]: events.md#class-eventemitter
[file descriptors]: https://en.wikipedia.org/wiki/File_descriptor
[package map]: packages.md#package-maps
[relative URL]: https://url.spec.whatwg.org/#relative-url-string
[self-reference a package using its name]: packages.md#self-referencing-a-package-using-its-name
[special scheme]: https://url.spec.whatwg.org/#special-scheme
Expand Down
8 changes: 8 additions & 0 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,12 @@ The default loader has the following properties
* Fails on unknown extensions for `file:` loading
(supports only `.cjs`, `.js`, and `.mjs`)

When the [`--experimental-package-map`][] flag is enabled, bare specifier
resolution first consults the package map configuration. If the importing
module is within a mapped package and the specifier matches a declared
dependency, the package map resolution takes precedence. See [Package maps][]
for details.

### Resolution algorithm

The algorithm to load an ES module specifier is given through the
Expand Down Expand Up @@ -1297,12 +1303,14 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][].
[Loading ECMAScript modules using `require()`]: modules.md#loading-ecmascript-modules-using-require
[Module customization hooks]: module.md#customization-hooks
[Node.js Module Resolution And Loading Algorithm]: #resolution-algorithm-specification
[Package maps]: packages.md#package-maps
[Source Phase Imports]: https://github.com/tc39/proposal-source-phase-imports
[Terminology]: #terminology
[URL]: https://url.spec.whatwg.org/
[WebAssembly JS String Builtins Proposal]: https://github.com/WebAssembly/js-string-builtins
[`"exports"`]: packages.md#exports
[`"type"`]: packages.md#type
[`--experimental-package-map`]: cli.md#--experimental-package-mappath
[`--input-type`]: cli.md#--input-typetype
[`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data
[`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export
Expand Down
6 changes: 6 additions & 0 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@ This feature can be detected by checking if
To get the exact filename that will be loaded when `require()` is called, use
the `require.resolve()` function.

When the [`--experimental-package-map`][] flag is enabled, bare specifier
resolution first consults the package map before searching `node_modules`
directories. See [Package maps][] for details.

Putting together all of the above, here is the high-level algorithm
in pseudocode of what `require()` does:

Expand Down Expand Up @@ -1269,8 +1273,10 @@ This section was moved to
[Determining module system]: packages.md#determining-module-system
[ECMAScript Modules]: esm.md
[GLOBAL_FOLDERS]: #loading-from-the-global-folders
[Package maps]: packages.md#package-maps
[`"main"`]: packages.md#main
[`"type"`]: packages.md#type
[`--experimental-package-map`]: cli.md#--experimental-package-mappath
[`--trace-require-module`]: cli.md#--trace-require-modulemode
[`ERR_REQUIRE_ASYNC_MODULE`]: errors.md#err_require_async_module
[`ERR_UNSUPPORTED_DIR_IMPORT`]: errors.md#err_unsupported_dir_import
Expand Down
167 changes: 167 additions & 0 deletions doc/api/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,171 @@ $ node other.js

See [the package examples repository][] for details.

## Package maps

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Package maps provide a mechanism to control package resolution without relying
on the `node_modules` folder structure. When enabled via the
[`--experimental-package-map`][] flag, Node.js uses a JSON configuration file
to determine how bare specifiers are resolved.

This feature is useful for:

* **Monorepos**: Define explicit dependency relationships between workspace
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
versions of the same dependency.

### Enabling package maps

Package maps are enabled by passing the `--experimental-package-map` flag
with a path to the configuration file:

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

### Configuration file format

The package map configuration file is a JSON file with a `packages` object.
Each key in `packages` is a unique identifier for a package entry:

```json
{
"packages": {
"app": {
"name": "my-app",
"path": "./packages/app",
"dependencies": ["utils", "ui-lib"]
},
"utils": {
"name": "@myorg/utils",
"path": "./packages/utils",
"dependencies": []
},
"ui-lib": {
"name": "@myorg/ui-lib",
"path": "./packages/ui-lib",
"dependencies": ["utils"]
}
}
}
```

Each package entry has the following fields:

* `path` {string} **Required.** Relative path from the configuration file to
the package directory.
* `name` {string} The package name used in import specifiers. If omitted, the
package cannot be imported by name but can still import its dependencies.
* `dependencies` {string\[]} Array of package keys that this package is allowed
to import. Defaults to an empty array.

### Resolution algorithm

When a bare specifier is encountered:

1. Node.js determines which package contains the importing file by checking
if the file path is within any package's `path`.
2. If the importing file is not within any mapped package, standard
`node_modules` resolution is used.
Copy link
Contributor

@bakkot bakkot Mar 15, 2026

Choose a reason for hiding this comment

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

This is surprising to me. Is there a way to make the project's root directory be "within a mapped package" without also including its node_modules? Or alternatively, can there be a way to list the dependencies of the project itself, which would apply to any files which are not within a mapped package?

That is, I would like to be able to have a project with JS in its root directory with that JS being governed by the package map (because I would like all bare specifier resolution in that project to be governed by the package map). You could do "root": { path: "." }, but then every file would be within root's path, including files in node_modules.

Possibly the check to see if a file is "within a package's path" should not includes files within a node_modules subdirectory of that path?

Copy link
Contributor Author

@arcanis arcanis Mar 15, 2026

Choose a reason for hiding this comment

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

The way it works is that the longest package path wins. So if you have a package mapped on . (which will usually be the case) you will also map its nested packages:

{
  "packages": {
    "root": {
      "path": "."
    },
    "react": {
      "path": "./node_modules/react"
    }
}

Paths in ./node_modules/react will be recognized as belonging to the React package, not the root.

3. Node.js searches the importing package's `dependencies` array for an entry
whose `name` matches the specifier's package name.
4. If found, the specifier resolves to that dependency's `path`.
5. If the package exists in the map but is not in `dependencies`, an
[`ERR_PACKAGE_MAP_ACCESS_DENIED`][] error is thrown.
6. If the package does not exist in the map at all, standard `node_modules`
resolution is used as a fallback.

### Subpath resolution

Package maps support importing subpaths. Given the configuration above:

```js
// In packages/app/index.js
import { helper } from '@myorg/utils'; // Resolves to ./packages/utils
import { format } from '@myorg/utils/format'; // Resolves to ./packages/utils/format
```

The subpath portion of the specifier is preserved and appended to the resolved
package path. The target package's `package.json` [`"exports"`][] field is
then used to resolve the final file path.

### Multiple package versions

Different packages can depend on different versions of the same package by
using distinct keys:

```json
{
"packages": {
"app": {
"name": "app",
"path": "./app",
"dependencies": ["component-v2"]
},
"legacy": {
"name": "legacy",
"path": "./legacy",
"dependencies": ["component-v1"]
},
"component-v1": {
"name": "component",
"path": "./vendor/component-1.0.0",
"dependencies": []
},
"component-v2": {
"name": "component",
"path": "./vendor/component-2.0.0",
"dependencies": []
}
}
}
```

Both `app` and `legacy` can `import 'component'`, but they resolve to
different paths based on their declared dependencies.

### CommonJS and ES modules

Package maps work with both CommonJS (`require()`) and ES modules (`import`).
The resolution behavior is identical for both module systems.

```cjs
// CommonJS
const utils = require('@myorg/utils');
```

```mjs
// ES modules
import utils from '@myorg/utils';
```

### Fallback behavior

Package maps do not replace `node_modules` resolution entirely. Resolution
falls back to standard behavior when:

* The importing file is not within any package defined in the map.
* The specifier's package name is not found in any package's `name` field.
* The specifier is a relative path (`./` or `../`).
* The specifier is an absolute path or URL.
* The specifier refers to a Node.js builtin module (`node:fs`, etc.).

### Limitations

* Package maps must be a single static file; dynamic configuration is not
supported.
* Circular dependency detection is not performed by the package map resolver.
* The package map file is loaded synchronously at startup.

## Node.js `package.json` field definitions

This section describes the fields used by the Node.js runtime. Other tools (such
Expand Down Expand Up @@ -1177,7 +1342,9 @@ This field defines [subpath imports][] for the current package.
[`"type"`]: #type
[`--conditions` / `-C` flag]: #resolving-user-conditions
[`--experimental-addon-modules`]: cli.md#--experimental-addon-modules
[`--experimental-package-map`]: cli.md#--experimental-package-mappath
[`--no-addons` flag]: cli.md#--no-addons
[`ERR_PACKAGE_MAP_ACCESS_DENIED`]: errors.md#err_package_map_access_denied
[`ERR_PACKAGE_PATH_NOT_EXPORTED`]: errors.md#err_package_path_not_exported
[`ERR_UNKNOWN_FILE_EXTENSION`]: errors.md#err_unknown_file_extension
[`package.json`]: #nodejs-packagejson-field-definitions
Expand Down
5 changes: 5 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,9 @@ This feature requires \fB--allow-worker\fR if used with the Permission Model.
.It Fl -experimental-network-inspection
Enable experimental support for the network inspection with Chrome DevTools.
.
.It Fl -experimental-package-map Ns = Ns Ar path
Enable experimental package map resolution using the specified configuration file.
.
.It Fl -experimental-print-required-tla
If the ES module being \fBrequire()\fR'd contains top-level \fBawait\fR, this flag
allows Node.js to evaluate the module, try to locate the
Expand Down Expand Up @@ -1865,6 +1868,8 @@ one is included in the list below.
.It
\fB--experimental-modules\fR
.It
\fB--experimental-package-map\fR
.It
\fB--experimental-print-required-tla\fR
.It
\fB--experimental-quic\fR
Expand Down
9 changes: 9 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,15 @@ E('ERR_PACKAGE_IMPORT_NOT_DEFINED', (specifier, packagePath, base) => {
return `Package import specifier "${specifier}" is not defined${packagePath ?
` in package ${packagePath}package.json` : ''} imported from ${base}`;
}, TypeError);
E('ERR_PACKAGE_MAP_ACCESS_DENIED', (specifier, fromKey, configPath) => {
return `Package "${specifier}" is not a declared dependency of "${fromKey}" in ${configPath}`;
}, Error);
E('ERR_PACKAGE_MAP_INVALID', (configPath, reason) => {
return `Invalid package-map.json at "${configPath}": ${reason}`;
}, SyntaxError);
E('ERR_PACKAGE_MAP_KEY_NOT_FOUND', (key, configPath) => {
return `Package key "${key}" referenced in dependencies but not defined in ${configPath}`;
}, Error);
E('ERR_PACKAGE_PATH_NOT_EXPORTED', (pkgPath, subpath, base = undefined) => {
if (subpath === '.')
return `No "exports" main defined in ${pkgPath}package.json${base ?
Expand Down
Loading
Loading