From f6a4015fa4e7b6ec01cdb9608078e28d4f1416df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Fri, 13 Mar 2026 11:31:39 +0100 Subject: [PATCH 1/7] loader: implement package maps --- doc/api/cli.md | 24 ++ doc/api/errors.md | 72 ++++ doc/api/esm.md | 8 + doc/api/modules.md | 6 + doc/api/packages.md | 167 +++++++++ doc/node.1 | 5 + lib/internal/errors.js | 9 + lib/internal/modules/cjs/loader.js | 77 ++++- lib/internal/modules/esm/resolve.js | 32 +- lib/internal/modules/package_map.js | 323 ++++++++++++++++++ src/node_options.cc | 4 + src/node_options.h | 1 + test/es-module/test-esm-package-map.mjs | 249 ++++++++++++++ .../package-map/component-lib/index.js | 3 + .../package-map/component-lib/package.json | 7 + test/fixtures/package-map/dep-a/index.cjs | 4 + test/fixtures/package-map/dep-a/index.js | 2 + test/fixtures/package-map/dep-a/lib/util.js | 1 + test/fixtures/package-map/dep-a/package.json | 11 + test/fixtures/package-map/dep-b/index.js | 2 + test/fixtures/package-map/dep-b/package.json | 7 + .../package-map/nameless-pkg/index.js | 1 + test/fixtures/package-map/not-a-dep/index.js | 1 + .../package-map/not-a-dep/package.json | 7 + .../package-map-invalid-schema.json | 3 + .../package-map-invalid-syntax.json | 1 + .../package-map/package-map-missing-dep.json | 9 + .../package-map/package-map-nameless.json | 13 + .../package-map/package-map-path-prefix.json | 14 + .../package-map/package-map-peer-deps.json | 32 ++ test/fixtures/package-map/package-map.json | 24 ++ test/fixtures/package-map/pkg-other/index.js | 1 + .../package-map/pkg-other/package.json | 7 + test/fixtures/package-map/pkg/index.cjs | 3 + test/fixtures/package-map/pkg/index.js | 1 + test/fixtures/package-map/pkg/package.json | 10 + test/fixtures/package-map/react-18/index.js | 2 + .../package-map/react-18/package.json | 8 + test/fixtures/package-map/react-19/index.js | 2 + .../package-map/react-19/package.json | 8 + test/fixtures/package-map/root/index.js | 1 + test/fixtures/package-map/root/package.json | 7 + test/parallel/test-bootstrap-modules.js | 1 + test/parallel/test-package-map-cli.js | 99 ++++++ test/parallel/test-require-package-map.js | 185 ++++++++++ 45 files changed, 1436 insertions(+), 18 deletions(-) create mode 100644 lib/internal/modules/package_map.js create mode 100644 test/es-module/test-esm-package-map.mjs create mode 100644 test/fixtures/package-map/component-lib/index.js create mode 100644 test/fixtures/package-map/component-lib/package.json create mode 100644 test/fixtures/package-map/dep-a/index.cjs create mode 100644 test/fixtures/package-map/dep-a/index.js create mode 100644 test/fixtures/package-map/dep-a/lib/util.js create mode 100644 test/fixtures/package-map/dep-a/package.json create mode 100644 test/fixtures/package-map/dep-b/index.js create mode 100644 test/fixtures/package-map/dep-b/package.json create mode 100644 test/fixtures/package-map/nameless-pkg/index.js create mode 100644 test/fixtures/package-map/not-a-dep/index.js create mode 100644 test/fixtures/package-map/not-a-dep/package.json create mode 100644 test/fixtures/package-map/package-map-invalid-schema.json create mode 100644 test/fixtures/package-map/package-map-invalid-syntax.json create mode 100644 test/fixtures/package-map/package-map-missing-dep.json create mode 100644 test/fixtures/package-map/package-map-nameless.json create mode 100644 test/fixtures/package-map/package-map-path-prefix.json create mode 100644 test/fixtures/package-map/package-map-peer-deps.json create mode 100644 test/fixtures/package-map/package-map.json create mode 100644 test/fixtures/package-map/pkg-other/index.js create mode 100644 test/fixtures/package-map/pkg-other/package.json create mode 100644 test/fixtures/package-map/pkg/index.cjs create mode 100644 test/fixtures/package-map/pkg/index.js create mode 100644 test/fixtures/package-map/pkg/package.json create mode 100644 test/fixtures/package-map/react-18/index.js create mode 100644 test/fixtures/package-map/react-18/package.json create mode 100644 test/fixtures/package-map/react-19/index.js create mode 100644 test/fixtures/package-map/react-19/package.json create mode 100644 test/fixtures/package-map/root/index.js create mode 100644 test/fixtures/package-map/root/package.json create mode 100644 test/parallel/test-package-map-cli.js create mode 100644 test/parallel/test-require-package-map.js diff --git a/doc/api/cli.md b/doc/api/cli.md index 9105c77da832c1..a96ee717f78553 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1219,6 +1219,28 @@ added: Enable experimental support for the network inspection with Chrome DevTools. +### `--experimental-package-map=` + + + +> 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` + +A package attempted to import another package that exists in the [package map][] +but is not listed in its `dependencies` array. + +```js +// 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. + + + +### `ERR_PACKAGE_MAP_INVALID` + + + +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 +``` + + + +### `ERR_PACKAGE_MAP_KEY_NOT_FOUND` + + + +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. + ### `ERR_PACKAGE_PATH_NOT_EXPORTED` @@ -4433,6 +4504,7 @@ An error occurred trying to allocate memory. This should never happen. [`new URL(input)`]: url.md#new-urlinput-base [`new URLPattern(input)`]: url.md#new-urlpatternstring-baseurl-options [`new URLSearchParams(iterable)`]: url.md#new-urlsearchparamsiterable +[package map]: packages.md#package-maps [`package.json`]: packages.md#nodejs-packagejson-field-definitions [`postMessage()`]: worker_threads.md#portpostmessagevalue-transferlist [`postMessageToThread()`]: worker_threads.md#worker_threadspostmessagetothreadthreadid-value-transferlist-timeout diff --git a/doc/api/esm.md b/doc/api/esm.md index 5396acfd53d65c..8466aea706c6a7 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -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 @@ -1303,6 +1309,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][]. [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 @@ -1324,6 +1331,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][]. [custom https loader]: module.md#import-from-https [import.meta.resolve]: #importmetaresolvespecifier [merve]: https://github.com/anonrig/merve/tree/v1.0.0 +[Package maps]: packages.md#package-maps [percent-encoded]: url.md#percent-encoding-in-urls [special scheme]: https://url.spec.whatwg.org/#special-scheme [status code]: process.md#exit-codes diff --git a/doc/api/modules.md b/doc/api/modules.md index 84252b9819d2b4..83227f9be9a13b 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -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: @@ -1271,6 +1275,7 @@ This section was moved to [GLOBAL_FOLDERS]: #loading-from-the-global-folders [`"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 @@ -1295,5 +1300,6 @@ This section was moved to [module namespace object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#module_namespace_object [module resolution]: #all-together [native addons]: addons.md +[Package maps]: packages.md#package-maps [subpath exports]: packages.md#subpath-exports [subpath imports]: packages.md#subpath-imports diff --git a/doc/api/packages.md b/doc/api/packages.md index d98fe357538a9a..6fbd37d627f416 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -947,6 +947,171 @@ $ node other.js See [the package examples repository][] for details. +## Package maps + + + +> 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. +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 @@ -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 diff --git a/doc/node.1 b/doc/node.1 index e88c005731b40f..9107b09cc8267a 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -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 @@ -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 diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 7c4728627731fe..65976f54b61ef3 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -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 ? diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 827655bedb65bf..04320d9ecc41ed 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -184,6 +184,11 @@ const packageJsonReader = require('internal/modules/package_json_reader'); const { getOptionValue, getEmbedderOptions } = require('internal/options'); const shouldReportRequiredModules = getLazy(() => process.env.WATCH_REPORT_DEPENDENCIES); +const { + hasPackageMap, + packageMapResolve, +} = require('internal/modules/package_map'); + const { vm_dynamic_import_default_internal, } = internalBinding('symbols'); @@ -644,14 +649,47 @@ function trySelf(parentPath, request, conditions) { return false; } + return resolveExpansion(expansion, pkg.path, pkg.data, parentPath, conditions); +} + +/** + * Try to resolve using package map (if enabled via --experimental-package-map). + * @param {string} request - The bare specifier + * @param {string} parentPath - File path of the parent module + * @param {Set} conditions - Export conditions + * @returns {string|undefined} + */ +function tryPackageMapResolveCJS(request, parent, conditions) { + if (!hasPackageMap()) { return undefined; } + + const parentPath = parent?.filename ?? process.cwd() + path.sep; + const mapped = packageMapResolve(request, parentPath); + + // Not in package map - fall back to standard resolution + if (mapped === undefined) { return undefined; } + + const { packagePath, subpath } = mapped; + + const packageJSONPath = path.resolve(packagePath, 'package.json'); + const pkg = packageJsonReader.read(packageJSONPath); + if (pkg.exports !== undefined) { + return resolveExpansion(subpath, packageJSONPath, pkg, parentPath, conditions); + } + + // No exports - try standard path resolution within the package + return Module._findPath(subpath, [packagePath], false, conditions); +} + +function resolveExpansion(expansion, pkgJsonPath, pkgData, parentPath, conditions) { + const pkgPath = path.dirname(pkgJsonPath); try { const { packageExportsResolve } = require('internal/modules/esm/resolve'); return finalizeEsmResolution(packageExportsResolve( - pathToFileURL(pkg.path), expansion, pkg.data, - pathToFileURL(parentPath), conditions), parentPath, pkg.path); + pathToFileURL(pkgJsonPath), expansion, pkgData, + pathToFileURL(parentPath), conditions), parentPath, pkgPath); } catch (e) { if (e.code === 'ERR_MODULE_NOT_FOUND') { - throw createEsmNotFoundErr(request, pkg.path); + throw createEsmNotFoundErr(expansion, pkgPath); } throw e; } @@ -1394,6 +1432,14 @@ Module._resolveFilename = function(request, parent, isMain, options) { } const conditions = (options?.conditions) || getCjsConditions(); + // Try package map resolution for bare specifiers (if --experimental-package-map is set) + if (!isRelative(request) && !path.isAbsolute(request)) { + const resolved = tryPackageMapResolveCJS(request, parent, conditions); + if (resolved !== undefined) { + return resolved; + } + } + let paths; if (typeof options === 'object' && options !== null) { @@ -1459,6 +1505,19 @@ Module._resolveFilename = function(request, parent, isMain, options) { // Look up the filename first, since that's the cache key. const filename = Module._findPath(request, paths, isMain, conditions); if (filename) { return filename; } + + const requireStack = getRequireStack(parent); + const message = getRequireStackMessage(request, requireStack); + + // eslint-disable-next-line no-restricted-syntax + const err = new Error(message); + err.code = 'MODULE_NOT_FOUND'; + err.requireStack = requireStack; + + throw err; +}; + +function getRequireStack(parent) { const requireStack = []; for (let cursor = parent; cursor; @@ -1466,17 +1525,17 @@ Module._resolveFilename = function(request, parent, isMain, options) { cursor = cursor[kFirstModuleParent]) { ArrayPrototypePush(requireStack, cursor.filename || cursor.id); } + return requireStack; +} + +function getRequireStackMessage(request, requireStack) { let message = `Cannot find module '${request}'`; if (requireStack.length > 0) { message = message + '\nRequire stack:\n- ' + ArrayPrototypeJoin(requireStack, '\n- '); } - // eslint-disable-next-line no-restricted-syntax - const err = new Error(message); - err.code = 'MODULE_NOT_FOUND'; - err.requireStack = requireStack; - throw err; -}; + return message; +} /** * Finishes resolving an ES module specifier into an absolute file path. diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index d253a3ff67280c..9c8e8f5a241b04 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -55,6 +55,7 @@ const internalFsBinding = internalBinding('fs'); * @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig */ +const { hasPackageMap, packageMapResolve } = require('internal/modules/package_map'); const emittedPackageWarnings = new SafeSet(); @@ -747,7 +748,6 @@ function packageImportsResolve(name, base, conditions) { throw importNotDefined(name, packageJSONUrl, base); } - /** * Resolves a package specifier to a URL. * @param {string} specifier - The package specifier to resolve. @@ -761,21 +761,35 @@ function packageResolve(specifier, base, conditions) { return new URL('node:' + specifier); } - const { packageJSONUrl, packageJSONPath, packageSubpath } = packageJsonReader.getPackageJSONURL(specifier, base); + let packageJSONUrl, packageJSONPath, packageSubpath; + + if (hasPackageMap()) { + // Package map is enabled - use it exclusively + const parentPath = fileURLToPath(base); + const mapped = packageMapResolve(specifier, parentPath); + if (mapped === undefined) { + throw new ERR_MODULE_NOT_FOUND(specifier, fileURLToPath(base), null); + } + const { packagePath, subpath } = mapped; + packageJSONPath = packagePath + '/package.json'; + packageJSONUrl = pathToFileURL(packageJSONPath); + packageSubpath = subpath; + } else { + // Standard node_modules resolution + ({ packageJSONUrl, packageJSONPath, packageSubpath } = + packageJsonReader.getPackageJSONURL(specifier, base)); + } - const packageConfig = packageJsonReader.read(packageJSONPath, { __proto__: null, specifier, base, isESM: true }); + const packageConfig = packageJsonReader.read(packageJSONPath, { + __proto__: null, specifier, base, isESM: true, + }); - // Package match. if (packageConfig.exports != null) { return packageExportsResolve( packageJSONUrl, packageSubpath, packageConfig, base, conditions); } if (packageSubpath === '.') { - return legacyMainResolve( - packageJSONUrl, - packageConfig, - base, - ); + return legacyMainResolve(packageJSONUrl, packageConfig, base); } return new URL(packageSubpath, packageJSONUrl); diff --git a/lib/internal/modules/package_map.js b/lib/internal/modules/package_map.js new file mode 100644 index 00000000000000..3982abd028f62a --- /dev/null +++ b/lib/internal/modules/package_map.js @@ -0,0 +1,323 @@ +'use strict'; + +const { + JSONParse, + ObjectEntries, + SafeMap, + SafeSet, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, +} = primordials; + +const assert = require('internal/assert'); +const { getLazy } = require('internal/util'); +const { resolve: pathResolve, dirname, sep } = require('path'); + +const getPackageMapPath = getLazy(() => + require('internal/options').getOptionValue('--experimental-package-map'), +); +const fs = require('fs'); +const { fileURLToPath } = require('internal/url'); + +const { + ERR_PACKAGE_MAP_ACCESS_DENIED, + ERR_PACKAGE_MAP_INVALID, + ERR_PACKAGE_MAP_KEY_NOT_FOUND, +} = require('internal/errors').codes; + +// Singleton - initialized once on first call +let packageMap; +let packageMapPath; +let emittedWarning = false; + +/** + * @typedef {object} PackageMapEntry + * @property {string} path - Absolute path to package on disk + * @property {Set} dependencies - Set of package keys this package can access + * @property {string|undefined} name - Package name (undefined = nameless package) + */ + +class PackageMap { + /** @type {string} */ + #configPath; + /** @type {string} */ + #basePath; + /** @type {Map} */ + #packages; + /** @type {Map>} */ + #nameToKeys; + /** @type {Map>} */ + #pathToKeys; + /** @type {Map} */ + #pathToKeyCache; + /** @type {Map} */ + #resolveCache; + + /** + * @param {string} configPath + * @param {object} data + */ + constructor(configPath, data) { + this.#configPath = configPath; + this.#basePath = dirname(configPath); + this.#packages = new SafeMap(); + this.#nameToKeys = new SafeMap(); + this.#pathToKeys = new SafeMap(); + this.#pathToKeyCache = new SafeMap(); + this.#resolveCache = new SafeMap(); + + this.#parse(data); + } + + /** + * @param {object} data + */ + #parse(data) { + if (!data.packages || typeof data.packages !== 'object') { + throw new ERR_PACKAGE_MAP_INVALID(this.#configPath, 'missing "packages" object'); + } + + for (const { 0: key, 1: entry } of ObjectEntries(data.packages)) { + if (!entry.path) { + throw new ERR_PACKAGE_MAP_INVALID(this.#configPath, `package "${key}" is missing "path" field`); + } + + const absolutePath = pathResolve(this.#basePath, entry.path); + + this.#packages.set(key, { + path: absolutePath, + dependencies: new SafeSet(entry.dependencies ?? []), + name: entry.name, // undefined for nameless packages + }); + + // Index by name (only named packages) + if (entry.name !== undefined) { + if (!this.#nameToKeys.has(entry.name)) { + this.#nameToKeys.set(entry.name, new SafeSet()); + } + this.#nameToKeys.get(entry.name).add(key); + } + + // Index by path + if (!this.#pathToKeys.has(absolutePath)) { + this.#pathToKeys.set(absolutePath, new SafeSet()); + } + this.#pathToKeys.get(absolutePath).add(key); + } + } + + /** + * Find which package key contains the given file path. + * @param {string} filePath + * @returns {string|null} + */ + #getKeyForPath(filePath) { + const cached = this.#pathToKeyCache.get(filePath); + if (cached !== undefined) return cached; + + // Walk up to find containing package + let checkPath = filePath; + while (isPathContainedIn(checkPath, this.#basePath)) { + const keys = this.#pathToKeys.get(checkPath); + if (keys && keys.size > 0) { + const key = keys.values().next().value; + this.#pathToKeyCache.set(filePath, key); + return key; + } + const parent = dirname(checkPath); + if (parent === checkPath) break; + checkPath = parent; + } + + this.#pathToKeyCache.set(filePath, null); + return null; + } + + /** + * Main resolution method. + * Returns the package path and subpath for the specifier, or undefined if + * resolution should fall back to standard resolution. + * @param {string} specifier + * @param {string} parentPath - File path of the importing module + * @returns {{packagePath: string, subpath: string}|undefined} + */ + resolve(specifier, parentPath) { + const parentKey = this.#getKeyForPath(parentPath); + + // Parent not in map - fall back to standard resolution + if (parentKey === null) return undefined; + + // Check cache + const cacheKey = `${parentKey}\0${specifier}`; + if (this.#resolveCache.has(cacheKey)) { + return this.#resolveCache.get(cacheKey); + } + + const result = this.#resolveUncached(specifier, parentKey); + this.#resolveCache.set(cacheKey, result); + return result; + } + + /** + * @param {string} specifier + * @param {string} parentKey + * @returns {{packagePath: string, subpath: string}|undefined} + */ + #resolveUncached(specifier, parentKey) { + const { packageName, subpath } = parsePackageName(specifier); + const parentEntry = this.#packages.get(parentKey); + + // Find matching dependency by name + let targetKey = null; + for (const depKey of parentEntry.dependencies) { + const depEntry = this.#packages.get(depKey); + if (!depEntry) { + throw new ERR_PACKAGE_MAP_KEY_NOT_FOUND(depKey, this.#configPath); + } + if (depEntry.name === packageName) { + targetKey = depKey; + break; + } + } + + if (targetKey === null) { + // Check if package exists anywhere in map but isn't accessible + if (this.#nameToKeys.has(packageName)) { + throw new ERR_PACKAGE_MAP_ACCESS_DENIED( + specifier, + parentKey, + this.#configPath, + ); + } + // Package not in map at all - fall back to standard resolution + return undefined; + } + + const targetEntry = this.#packages.get(targetKey); + return { packagePath: targetEntry.path, subpath }; + } +} + +/** + * Check if a file path is contained within a folder path. + * Handles edge cases like /foo/bar not matching /foo/barbaz. + * @param {string} file - The file path to check + * @param {string} folder - The folder path to check against + * @returns {boolean} + */ +function isPathContainedIn(file, folder) { + return file === folder || StringPrototypeStartsWith(file, folder + sep); +} + +/** + * Parse a package specifier into name and subpath. + * @param {string} specifier + * @returns {{packageName: string, subpath: string}} + */ +function parsePackageName(specifier) { + const isScoped = specifier[0] === '@'; + let slashIndex = StringPrototypeIndexOf(specifier, '/'); + + if (isScoped) { + if (slashIndex === -1) { + // Invalid: @scope without package name, treat whole thing as name + return { packageName: specifier, subpath: '.' }; + } + slashIndex = StringPrototypeIndexOf(specifier, '/', slashIndex + 1); + } + + if (slashIndex === -1) { + return { packageName: specifier, subpath: '.' }; + } + + return { + packageName: StringPrototypeSlice(specifier, 0, slashIndex), + subpath: '.' + StringPrototypeSlice(specifier, slashIndex), + }; +} + +/** + * Load and parse the package map from a config path. + * @param {string} configPath + * @returns {PackageMap} + */ +function loadPackageMap(configPath) { + let content; + try { + content = fs.readFileSync(configPath, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') { + throw new ERR_PACKAGE_MAP_INVALID(configPath, 'file not found'); + } + throw new ERR_PACKAGE_MAP_INVALID(configPath, err.message); + } + + let data; + try { + data = JSONParse(content); + } catch (err) { + throw new ERR_PACKAGE_MAP_INVALID(configPath, `invalid JSON: ${err.message}`); + } + + return new PackageMap(configPath, data); +} + +/** + * Emit experimental warning on first use. + */ +function emitExperimentalWarning() { + if (!emittedWarning) { + emittedWarning = true; + process.emitWarning( + 'Package map resolution is an experimental feature and might change at any time', + 'ExperimentalWarning', + ); + } +} + +/** + * Get the singleton package map, initializing on first call. + * @returns {PackageMap|null} + */ +function getPackageMap() { + if (packageMap !== undefined) return packageMap; + + packageMapPath = getPackageMapPath(); + if (!packageMapPath) { + packageMap = null; + return null; + } + + emitExperimentalWarning(); + packageMap = loadPackageMap(pathResolve(packageMapPath)); + return packageMap; +} + +/** + * Check if the package map is enabled. + * @returns {boolean} + */ +function hasPackageMap() { + return getPackageMap() !== null; +} + +/** + * Resolve a package specifier using the package map. + * Returns the package path and subpath, or undefined if resolution should + * fall back to standard resolution. + * + * @param {string} specifier - The bare specifier (e.g., "lodash", "react/jsx-runtime") + * @param {string} parentPath - File path of the importing module + * @returns {{packagePath: string, subpath: string}|undefined} + */ +function packageMapResolve(specifier, parentPath) { + const map = getPackageMap(); + assert(map !== null, 'Package map is not enabled'); + return map.resolve(specifier, parentPath); +} + +module.exports = { + hasPackageMap, + packageMapResolve, +}; diff --git a/src/node_options.cc b/src/node_options.cc index d48641ae3ffe07..82f66a96f707e7 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -886,6 +886,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { AddOption("--experimental-default-config-file", "set config file from default config file", &EnvironmentOptions::experimental_default_config_file); + AddOption("--experimental-package-map", + "use the specified file for package map resolution", + &EnvironmentOptions::experimental_package_map_path, + kAllowedInEnvvar); AddOption("--test", "launch test runner on startup", &EnvironmentOptions::test_runner, diff --git a/src/node_options.h b/src/node_options.h index 2f0adb5ae491ec..870a25e2569192 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -270,6 +270,7 @@ class EnvironmentOptions : public Options { bool report_exclude_network = false; std::string experimental_config_file_path; bool experimental_default_config_file = false; + std::string experimental_package_map_path; inline DebugOptions* get_debug_options() { return &debug_options_; } inline const DebugOptions& debug_options() const { return debug_options_; } diff --git a/test/es-module/test-esm-package-map.mjs b/test/es-module/test-esm-package-map.mjs new file mode 100644 index 00000000000000..476be048cae60c --- /dev/null +++ b/test/es-module/test-esm-package-map.mjs @@ -0,0 +1,249 @@ +import '../common/index.mjs'; +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; +import { execPath } from 'node:process'; +import { describe, it } from 'node:test'; + +const packageMapPath = fixtures.path('package-map/package-map.json'); +const fixturesPath = fixtures.path('package-map'); + +describe('ESM: --experimental-package-map', () => { + + // =========== Basic Resolution =========== + + describe('basic resolution', () => { + it('resolves a direct dependency', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import dep from 'dep-a'; console.log(dep);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /dep-a-value/); + }); + + it('resolves a subpath export', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import util from 'dep-a/lib/util'; console.log(util);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /dep-a-util/); + }); + + it('resolves transitive dependency from allowed package', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + // dep-b can access dep-a + `import depB from 'dep-b'; console.log(depB);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /dep-b using dep-a-value/); + }); + }); + + // =========== Access Control =========== + + describe('dependency access control', () => { + it('throws ERR_PACKAGE_MAP_ACCESS_DENIED for undeclared dependency', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import x from 'not-a-dep';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + assert.match(stderr, /not-a-dep/); + assert.match(stderr, /root/); // parent package name + }); + + it('includes package key in error for nameless packages', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-nameless.json'), + '--input-type=module', + '--eval', + `import x from 'forbidden';`, + ], { cwd: fixtures.path('package-map/nameless-pkg') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + // Should show key since package is nameless + assert.match(stderr, /nameless/); + }); + }); + + // =========== Fallback Behavior =========== + + describe('fallback to standard resolution', () => { + it('falls back for builtin modules', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import fs from 'node:fs'; console.log(typeof fs.readFileSync);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /function/); + }); + + it('falls back when parent not in map', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import dep from 'dep-a'; console.log(dep);`, + ], { cwd: '/tmp' }); // Not in any mapped package + + // Should fall back to standard resolution (which will fail) + assert.notStrictEqual(code, 0); + assert.match(stderr, /Cannot find package/); + }); + }); + + // =========== Error Handling =========== + + describe('error handling', () => { + it('throws ERR_PACKAGE_MAP_INVALID for invalid JSON', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-invalid-syntax.json'), + '--input-type=module', + '--eval', `import x from 'dep-a';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + }); + + it('throws ERR_PACKAGE_MAP_INVALID for missing packages field', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-invalid-schema.json'), + '--input-type=module', + '--eval', `import x from 'dep-a';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + assert.match(stderr, /packages/); + }); + + it('throws ERR_PACKAGE_MAP_KEY_NOT_FOUND for undefined dependency key', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-missing-dep.json'), + '--input-type=module', + '--eval', + `import x from 'nonexistent';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_KEY_NOT_FOUND/); + }); + + it('throws for non-existent map file', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', '/nonexistent/package-map.json', + '--input-type=module', + '--eval', `import x from 'dep-a';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + assert.match(stderr, /not found/); + }); + }); + + // =========== Zero Overhead When Disabled =========== + + describe('disabled by default', () => { + it('has no impact when flag not set', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--input-type=module', + '--eval', + `import fs from 'node:fs'; console.log('ok');`, + ]); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /ok/); + }); + }); + + // =========== Exports Integration =========== + + describe('package.json exports integration', () => { + it('respects conditional exports (import)', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import { format } from 'dep-a'; console.log(format);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /esm/); // Should get ESM export + }); + + it('respects pattern exports', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import util from 'dep-a/lib/util'; console.log(util);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /dep-a-util/); + }); + }); + + // =========== Experimental Warning =========== + + describe('experimental warning', () => { + it('emits experimental warning on first use', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', packageMapPath, + '--input-type=module', + '--eval', + `import dep from 'dep-a';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0); + assert.match(stderr, /ExperimentalWarning/); + assert.match(stderr, /Package map/i); + }); + }); + + // =========== Path Containment =========== + + describe('path containment edge cases', () => { + it('does not match pkg-other as being inside pkg', async () => { + // Regression test: pkg-other (at ./pkg-other) should not be + // incorrectly matched as inside pkg (at ./pkg) just because + // the string "./pkg-other" starts with "./pkg" + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-path-prefix.json'), + '--input-type=module', + '--eval', + `import pkg from 'pkg'; console.log(pkg);`, + ], { cwd: fixtures.path('package-map/pkg-other') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /pkg-value/); + }); + }); +}); diff --git a/test/fixtures/package-map/component-lib/index.js b/test/fixtures/package-map/component-lib/index.js new file mode 100644 index 00000000000000..04ff6c0e58ce12 --- /dev/null +++ b/test/fixtures/package-map/component-lib/index.js @@ -0,0 +1,3 @@ +import react from 'react'; +export const reactVersion = react.version; +export default `component-lib using react ${react.version}`; diff --git a/test/fixtures/package-map/component-lib/package.json b/test/fixtures/package-map/component-lib/package.json new file mode 100644 index 00000000000000..a7979a4c8b8790 --- /dev/null +++ b/test/fixtures/package-map/component-lib/package.json @@ -0,0 +1,7 @@ +{ + "name": "component-lib", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/dep-a/index.cjs b/test/fixtures/package-map/dep-a/index.cjs new file mode 100644 index 00000000000000..5ea2c877b4518f --- /dev/null +++ b/test/fixtures/package-map/dep-a/index.cjs @@ -0,0 +1,4 @@ +module.exports = { + default: 'dep-a-value', + format: 'cjs', +}; diff --git a/test/fixtures/package-map/dep-a/index.js b/test/fixtures/package-map/dep-a/index.js new file mode 100644 index 00000000000000..fbf87b7bd89e86 --- /dev/null +++ b/test/fixtures/package-map/dep-a/index.js @@ -0,0 +1,2 @@ +export default 'dep-a-value'; +export const format = 'esm'; diff --git a/test/fixtures/package-map/dep-a/lib/util.js b/test/fixtures/package-map/dep-a/lib/util.js new file mode 100644 index 00000000000000..2df99d06a5edfc --- /dev/null +++ b/test/fixtures/package-map/dep-a/lib/util.js @@ -0,0 +1 @@ +export default 'dep-a-util'; diff --git a/test/fixtures/package-map/dep-a/package.json b/test/fixtures/package-map/dep-a/package.json new file mode 100644 index 00000000000000..8524aefbc42cc1 --- /dev/null +++ b/test/fixtures/package-map/dep-a/package.json @@ -0,0 +1,11 @@ +{ + "name": "dep-a", + "type": "module", + "exports": { + ".": { + "import": "./index.js", + "require": "./index.cjs" + }, + "./lib/*": "./lib/*.js" + } +} diff --git a/test/fixtures/package-map/dep-b/index.js b/test/fixtures/package-map/dep-b/index.js new file mode 100644 index 00000000000000..dbf708175ca513 --- /dev/null +++ b/test/fixtures/package-map/dep-b/index.js @@ -0,0 +1,2 @@ +import depA from 'dep-a'; +export default `dep-b using ${depA}`; diff --git a/test/fixtures/package-map/dep-b/package.json b/test/fixtures/package-map/dep-b/package.json new file mode 100644 index 00000000000000..202a9c9e02632f --- /dev/null +++ b/test/fixtures/package-map/dep-b/package.json @@ -0,0 +1,7 @@ +{ + "name": "dep-b", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/nameless-pkg/index.js b/test/fixtures/package-map/nameless-pkg/index.js new file mode 100644 index 00000000000000..3ff9aaa0d9919b --- /dev/null +++ b/test/fixtures/package-map/nameless-pkg/index.js @@ -0,0 +1 @@ +export default 'nameless-package'; diff --git a/test/fixtures/package-map/not-a-dep/index.js b/test/fixtures/package-map/not-a-dep/index.js new file mode 100644 index 00000000000000..2efd548a878e17 --- /dev/null +++ b/test/fixtures/package-map/not-a-dep/index.js @@ -0,0 +1 @@ +export default 'not-a-dep-value'; diff --git a/test/fixtures/package-map/not-a-dep/package.json b/test/fixtures/package-map/not-a-dep/package.json new file mode 100644 index 00000000000000..949eeb9d44ccb9 --- /dev/null +++ b/test/fixtures/package-map/not-a-dep/package.json @@ -0,0 +1,7 @@ +{ + "name": "not-a-dep", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/package-map-invalid-schema.json b/test/fixtures/package-map/package-map-invalid-schema.json new file mode 100644 index 00000000000000..ef2b57b2af227b --- /dev/null +++ b/test/fixtures/package-map/package-map-invalid-schema.json @@ -0,0 +1,3 @@ +{ + "not-packages": {} +} diff --git a/test/fixtures/package-map/package-map-invalid-syntax.json b/test/fixtures/package-map/package-map-invalid-syntax.json new file mode 100644 index 00000000000000..923351fa7a9a22 --- /dev/null +++ b/test/fixtures/package-map/package-map-invalid-syntax.json @@ -0,0 +1 @@ +{ invalid json here diff --git a/test/fixtures/package-map/package-map-missing-dep.json b/test/fixtures/package-map/package-map-missing-dep.json new file mode 100644 index 00000000000000..b8a6b044813fb2 --- /dev/null +++ b/test/fixtures/package-map/package-map-missing-dep.json @@ -0,0 +1,9 @@ +{ + "packages": { + "root": { + "name": "root", + "path": "./root", + "dependencies": ["nonexistent-key"] + } + } +} diff --git a/test/fixtures/package-map/package-map-nameless.json b/test/fixtures/package-map/package-map-nameless.json new file mode 100644 index 00000000000000..bb3a59341073ff --- /dev/null +++ b/test/fixtures/package-map/package-map-nameless.json @@ -0,0 +1,13 @@ +{ + "packages": { + "nameless": { + "path": "./nameless-pkg", + "dependencies": [] + }, + "forbidden": { + "name": "forbidden", + "path": "./not-a-dep", + "dependencies": [] + } + } +} diff --git a/test/fixtures/package-map/package-map-path-prefix.json b/test/fixtures/package-map/package-map-path-prefix.json new file mode 100644 index 00000000000000..0dc80c90290217 --- /dev/null +++ b/test/fixtures/package-map/package-map-path-prefix.json @@ -0,0 +1,14 @@ +{ + "packages": { + "pkg": { + "name": "pkg", + "path": "./pkg", + "dependencies": [] + }, + "pkg-other": { + "name": "pkg-other", + "path": "./pkg-other", + "dependencies": ["pkg"] + } + } +} diff --git a/test/fixtures/package-map/package-map-peer-deps.json b/test/fixtures/package-map/package-map-peer-deps.json new file mode 100644 index 00000000000000..ac7121f6781b01 --- /dev/null +++ b/test/fixtures/package-map/package-map-peer-deps.json @@ -0,0 +1,32 @@ +{ + "packages": { + "app-v1": { + "name": "app", + "path": "./root", + "dependencies": ["component-lib@react-18", "react@18"] + }, + "app-v2": { + "name": "app", + "path": "./root", + "dependencies": ["component-lib@react-19", "react@19"] + }, + "component-lib@react-18": { + "name": "component-lib", + "path": "./component-lib", + "dependencies": ["react@18"] + }, + "component-lib@react-19": { + "name": "component-lib", + "path": "./component-lib", + "dependencies": ["react@19"] + }, + "react@18": { + "name": "react", + "path": "./react-18" + }, + "react@19": { + "name": "react", + "path": "./react-19" + } + } +} diff --git a/test/fixtures/package-map/package-map.json b/test/fixtures/package-map/package-map.json new file mode 100644 index 00000000000000..60ec4562c0be36 --- /dev/null +++ b/test/fixtures/package-map/package-map.json @@ -0,0 +1,24 @@ +{ + "packages": { + "root": { + "name": "root", + "path": "./root", + "dependencies": ["dep-a", "dep-b"] + }, + "dep-a": { + "name": "dep-a", + "path": "./dep-a", + "dependencies": [] + }, + "dep-b": { + "name": "dep-b", + "path": "./dep-b", + "dependencies": ["dep-a"] + }, + "not-a-dep": { + "name": "not-a-dep", + "path": "./not-a-dep", + "dependencies": [] + } + } +} diff --git a/test/fixtures/package-map/pkg-other/index.js b/test/fixtures/package-map/pkg-other/index.js new file mode 100644 index 00000000000000..1be09a05eb32fd --- /dev/null +++ b/test/fixtures/package-map/pkg-other/index.js @@ -0,0 +1 @@ +export default 'pkg-other-value'; diff --git a/test/fixtures/package-map/pkg-other/package.json b/test/fixtures/package-map/pkg-other/package.json new file mode 100644 index 00000000000000..c57d95ced92bbb --- /dev/null +++ b/test/fixtures/package-map/pkg-other/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-other", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/pkg/index.cjs b/test/fixtures/package-map/pkg/index.cjs new file mode 100644 index 00000000000000..bcffba52d11ad1 --- /dev/null +++ b/test/fixtures/package-map/pkg/index.cjs @@ -0,0 +1,3 @@ +module.exports = { + default: 'pkg-value', +}; diff --git a/test/fixtures/package-map/pkg/index.js b/test/fixtures/package-map/pkg/index.js new file mode 100644 index 00000000000000..724d42ba238bda --- /dev/null +++ b/test/fixtures/package-map/pkg/index.js @@ -0,0 +1 @@ +export default 'pkg-value'; diff --git a/test/fixtures/package-map/pkg/package.json b/test/fixtures/package-map/pkg/package.json new file mode 100644 index 00000000000000..4fe8611fb07e14 --- /dev/null +++ b/test/fixtures/package-map/pkg/package.json @@ -0,0 +1,10 @@ +{ + "name": "pkg", + "type": "module", + "exports": { + ".": { + "import": "./index.js", + "require": "./index.cjs" + } + } +} diff --git a/test/fixtures/package-map/react-18/index.js b/test/fixtures/package-map/react-18/index.js new file mode 100644 index 00000000000000..e1755ca26e5599 --- /dev/null +++ b/test/fixtures/package-map/react-18/index.js @@ -0,0 +1,2 @@ +export const version = '18'; +export default { version: '18' }; diff --git a/test/fixtures/package-map/react-18/package.json b/test/fixtures/package-map/react-18/package.json new file mode 100644 index 00000000000000..582453d8fb64d4 --- /dev/null +++ b/test/fixtures/package-map/react-18/package.json @@ -0,0 +1,8 @@ +{ + "name": "react", + "version": "18.0.0", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/react-19/index.js b/test/fixtures/package-map/react-19/index.js new file mode 100644 index 00000000000000..51e8c76192ba56 --- /dev/null +++ b/test/fixtures/package-map/react-19/index.js @@ -0,0 +1,2 @@ +export const version = '19'; +export default { version: '19' }; diff --git a/test/fixtures/package-map/react-19/package.json b/test/fixtures/package-map/react-19/package.json new file mode 100644 index 00000000000000..963f26da6e0906 --- /dev/null +++ b/test/fixtures/package-map/react-19/package.json @@ -0,0 +1,8 @@ +{ + "name": "react", + "version": "19.0.0", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/root/index.js b/test/fixtures/package-map/root/index.js new file mode 100644 index 00000000000000..73b42e2d839ccd --- /dev/null +++ b/test/fixtures/package-map/root/index.js @@ -0,0 +1 @@ +export default 'root-package'; diff --git a/test/fixtures/package-map/root/package.json b/test/fixtures/package-map/root/package.json new file mode 100644 index 00000000000000..1b5c89e70538a2 --- /dev/null +++ b/test/fixtures/package-map/root/package.json @@ -0,0 +1,7 @@ +{ + "name": "root", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index 92bf3be1f612ff..2987bab9cfb5ce 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -104,6 +104,7 @@ expected.beforePreExec = new Set([ 'NativeModule internal/modules/package_json_reader', 'Internal Binding module_wrap', 'NativeModule internal/modules/cjs/loader', + 'NativeModule internal/modules/package_map', 'NativeModule diagnostics_channel', 'Internal Binding diagnostics_channel', 'Internal Binding wasm_web_api', diff --git a/test/parallel/test-package-map-cli.js b/test/parallel/test-package-map-cli.js new file mode 100644 index 00000000000000..8707adddb8cb2f --- /dev/null +++ b/test/parallel/test-package-map-cli.js @@ -0,0 +1,99 @@ +'use strict'; + +require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { describe, it } = require('node:test'); + +describe('--experimental-package-map CLI behavior', () => { + + it('works via NODE_OPTIONS', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '-e', + `const dep = require('dep-a'); console.log(dep);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + env: { + ...process.env, + NODE_OPTIONS: `--experimental-package-map=${fixtures.path('package-map/package-map.json')}`, + }, + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-a-value/); + }); + + it('emits experimental warning on first use', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', fixtures.path('package-map/package-map.json'), + '-e', + `require('dep-a');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0); + assert.match(stderr, /ExperimentalWarning/); + assert.match(stderr, /[Pp]ackage map/); + }); + + it('accepts relative path', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', '../package-map.json', + '-e', + `const dep = require('dep-a'); console.log(dep.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + // Relative path ../package-map.json resolved from cwd (root/) + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-a-value/); + }); + + it('accepts absolute path', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', fixtures.path('package-map/package-map.json'), + '-e', + `console.log('ok');`, + ], { + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + }); + + it('does not emit warning when flag not set', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '-e', + `console.log('ok');`, + ], { + encoding: 'utf8', + }); + + assert.strictEqual(status, 0); + assert.doesNotMatch(stderr, /ExperimentalWarning/); + assert.doesNotMatch(stderr, /[Pp]ackage map/); + }); + + it('can be combined with other flags', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', fixtures.path('package-map/package-map.json'), + '--no-warnings', + '-e', + `const dep = require('dep-a'); console.log(dep);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-a-value/); + // Warning should be suppressed + assert.doesNotMatch(stderr, /ExperimentalWarning/); + }); +}); diff --git a/test/parallel/test-require-package-map.js b/test/parallel/test-require-package-map.js new file mode 100644 index 00000000000000..26f7fab9c32b2f --- /dev/null +++ b/test/parallel/test-require-package-map.js @@ -0,0 +1,185 @@ +'use strict'; + +require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { describe, it } = require('node:test'); + +const packageMapPath = fixtures.path('package-map/package-map.json'); + +describe('CJS: --experimental-package-map', () => { + + describe('basic resolution', () => { + it('resolves require() through package map', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `const dep = require('dep-a'); console.log(dep.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-a-value/); + }); + + it('resolves subpath require()', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `const util = require('dep-a/lib/util'); console.log(util.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-a-util/); + }); + + it('resolves transitive dependencies', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `const depB = require('dep-b'); console.log(depB.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-b using dep-a-value/); + }); + }); + + describe('dependency access control', () => { + it('throws for undeclared dependency', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `require('not-a-dep');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.notStrictEqual(status, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + }); + }); + + describe('fallback behavior', () => { + it('falls back for builtin modules', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `const fs = require('fs'); console.log(typeof fs.readFileSync);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /function/); + }); + + it('falls back when parent not in map', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `require('dep-a');`, + ], { + cwd: '/tmp', + encoding: 'utf8', + }); + + // Should fall back to standard resolution (which will fail) + assert.notStrictEqual(status, 0); + assert.match(stderr, /Cannot find module/); + }); + }); + + describe('error handling', () => { + it('throws for invalid JSON', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-invalid-syntax.json'), + '-e', + `require('dep-a');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.notStrictEqual(status, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + }); + + it('throws for missing packages field', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-invalid-schema.json'), + '-e', + `require('dep-a');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.notStrictEqual(status, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + }); + }); + + describe('conditional exports', () => { + it('respects require condition in exports', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `const dep = require('dep-a'); console.log(dep.format);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /cjs/); // Should get CJS export + }); + }); + + describe('disabled by default', () => { + it('has no impact when flag not set', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '-e', + `const fs = require('fs'); console.log('ok');`, + ], { + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /ok/); + }); + }); + + describe('path containment edge cases', () => { + it('does not match pkg-other as being inside pkg', () => { + // Regression test: pkg-other (at ./pkg-other) should not be + // incorrectly matched as inside pkg (at ./pkg) just because + // the string "./pkg-other" starts with "./pkg" + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-path-prefix.json'), + '-e', + `const pkg = require('pkg'); console.log(pkg.default);`, + ], { + cwd: fixtures.path('package-map/pkg-other'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /pkg-value/); + }); + }); +}); From 08a9fdf93e7666c4f2ef6337768987aea4c6f9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Fri, 13 Mar 2026 14:58:01 +0100 Subject: [PATCH 2/7] Fixes linting --- doc/api/errors.md | 4 ++-- doc/api/esm.md | 2 +- doc/api/modules.md | 2 +- lib/internal/modules/cjs/loader.js | 2 +- lib/internal/modules/package_map.js | 12 +++++------- test/es-module/test-esm-package-map.mjs | 2 -- 6 files changed, 10 insertions(+), 14 deletions(-) diff --git a/doc/api/errors.md b/doc/api/errors.md index d3f92303c86715..3b2b61c9324da8 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -2507,7 +2507,7 @@ added: REPLACEME A package attempted to import another package that exists in the [package map][] but is not listed in its `dependencies` array. -```js +```mjs // package-map.json declares "app" with dependencies: ["utils"] // but "app" tries to import "secret-lib" which exists in the map @@ -4504,7 +4504,6 @@ An error occurred trying to allocate memory. This should never happen. [`new URL(input)`]: url.md#new-urlinput-base [`new URLPattern(input)`]: url.md#new-urlpatternstring-baseurl-options [`new URLSearchParams(iterable)`]: url.md#new-urlsearchparamsiterable -[package map]: packages.md#package-maps [`package.json`]: packages.md#nodejs-packagejson-field-definitions [`postMessage()`]: worker_threads.md#portpostmessagevalue-transferlist [`postMessageToThread()`]: worker_threads.md#worker_threadspostmessagetothreadthreadid-value-transferlist-timeout @@ -4535,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 diff --git a/doc/api/esm.md b/doc/api/esm.md index 8466aea706c6a7..2f4c9c934eee94 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -1303,6 +1303,7 @@ 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/ @@ -1331,7 +1332,6 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][]. [custom https loader]: module.md#import-from-https [import.meta.resolve]: #importmetaresolvespecifier [merve]: https://github.com/anonrig/merve/tree/v1.0.0 -[Package maps]: packages.md#package-maps [percent-encoded]: url.md#percent-encoding-in-urls [special scheme]: https://url.spec.whatwg.org/#special-scheme [status code]: process.md#exit-codes diff --git a/doc/api/modules.md b/doc/api/modules.md index 83227f9be9a13b..482a49d721e00f 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -1273,6 +1273,7 @@ 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 @@ -1300,6 +1301,5 @@ This section was moved to [module namespace object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#module_namespace_object [module resolution]: #all-together [native addons]: addons.md -[Package maps]: packages.md#package-maps [subpath exports]: packages.md#subpath-exports [subpath imports]: packages.md#subpath-imports diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 04320d9ecc41ed..57b2165e35fc34 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -655,7 +655,7 @@ function trySelf(parentPath, request, conditions) { /** * Try to resolve using package map (if enabled via --experimental-package-map). * @param {string} request - The bare specifier - * @param {string} parentPath - File path of the parent module + * @param {Module} parent - The parent module * @param {Set} conditions - Export conditions * @returns {string|undefined} */ diff --git a/lib/internal/modules/package_map.js b/lib/internal/modules/package_map.js index 3982abd028f62a..d97734cff7920c 100644 --- a/lib/internal/modules/package_map.js +++ b/lib/internal/modules/package_map.js @@ -18,7 +18,6 @@ const getPackageMapPath = getLazy(() => require('internal/options').getOptionValue('--experimental-package-map'), ); const fs = require('fs'); -const { fileURLToPath } = require('internal/url'); const { ERR_PACKAGE_MAP_ACCESS_DENIED, @@ -88,7 +87,7 @@ class PackageMap { this.#packages.set(key, { path: absolutePath, dependencies: new SafeSet(entry.dependencies ?? []), - name: entry.name, // undefined for nameless packages + name: entry.name, // Undefined for nameless packages }); // Index by name (only named packages) @@ -114,7 +113,7 @@ class PackageMap { */ #getKeyForPath(filePath) { const cached = this.#pathToKeyCache.get(filePath); - if (cached !== undefined) return cached; + if (cached !== undefined) { return cached; } // Walk up to find containing package let checkPath = filePath; @@ -126,7 +125,7 @@ class PackageMap { return key; } const parent = dirname(checkPath); - if (parent === checkPath) break; + if (parent === checkPath) { break; } checkPath = parent; } @@ -146,7 +145,7 @@ class PackageMap { const parentKey = this.#getKeyForPath(parentPath); // Parent not in map - fall back to standard resolution - if (parentKey === null) return undefined; + if (parentKey === null) { return undefined; } // Check cache const cacheKey = `${parentKey}\0${specifier}`; @@ -281,7 +280,7 @@ function emitExperimentalWarning() { * @returns {PackageMap|null} */ function getPackageMap() { - if (packageMap !== undefined) return packageMap; + if (packageMap !== undefined) { return packageMap; } packageMapPath = getPackageMapPath(); if (!packageMapPath) { @@ -306,7 +305,6 @@ function hasPackageMap() { * Resolve a package specifier using the package map. * Returns the package path and subpath, or undefined if resolution should * fall back to standard resolution. - * * @param {string} specifier - The bare specifier (e.g., "lodash", "react/jsx-runtime") * @param {string} parentPath - File path of the importing module * @returns {{packagePath: string, subpath: string}|undefined} diff --git a/test/es-module/test-esm-package-map.mjs b/test/es-module/test-esm-package-map.mjs index 476be048cae60c..5aa65250b399af 100644 --- a/test/es-module/test-esm-package-map.mjs +++ b/test/es-module/test-esm-package-map.mjs @@ -1,4 +1,3 @@ -import '../common/index.mjs'; import { spawnPromisified } from '../common/index.mjs'; import * as fixtures from '../common/fixtures.mjs'; import assert from 'node:assert'; @@ -6,7 +5,6 @@ import { execPath } from 'node:process'; import { describe, it } from 'node:test'; const packageMapPath = fixtures.path('package-map/package-map.json'); -const fixturesPath = fixtures.path('package-map'); describe('ESM: --experimental-package-map', () => { From 19b9e1b7ff196c6fb4483a79a97cf08a06acb1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Fri, 13 Mar 2026 15:49:05 +0100 Subject: [PATCH 3/7] Fixes tests wrt NODE_OPTIONS --- test/parallel/test-package-map-cli.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-package-map-cli.js b/test/parallel/test-package-map-cli.js index 8707adddb8cb2f..b593e6bad35fb9 100644 --- a/test/parallel/test-package-map-cli.js +++ b/test/parallel/test-package-map-cli.js @@ -6,9 +6,13 @@ const assert = require('node:assert'); const { spawnSync } = require('node:child_process'); const { describe, it } = require('node:test'); +const onlyIfNodeOptionsSupport = { + skip: process.config.variables.node_without_node_options, +}; + describe('--experimental-package-map CLI behavior', () => { - it('works via NODE_OPTIONS', () => { + it('works via NODE_OPTIONS', onlyIfNodeOptionsSupport, () => { const { status, stdout, stderr } = spawnSync(process.execPath, [ '-e', `const dep = require('dep-a'); console.log(dep);`, @@ -17,7 +21,7 @@ describe('--experimental-package-map CLI behavior', () => { encoding: 'utf8', env: { ...process.env, - NODE_OPTIONS: `--experimental-package-map=${fixtures.path('package-map/package-map.json')}`, + NODE_OPTIONS: `--experimental-package-map=${JSON.stringify(fixtures.path('package-map/package-map.json'))}`, }, }); From 74bac976fbb6340210668ae944b8a484ec4acd83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Sun, 15 Mar 2026 12:06:57 +0100 Subject: [PATCH 4/7] Adds tests --- doc/api/packages.md | 23 ++++---- lib/internal/modules/cjs/loader.js | 11 +++- lib/internal/modules/package_map.js | 20 ++----- test/es-module/test-esm-package-map.mjs | 59 +++++++++++++++++-- .../package-map-external-deps.json | 14 +++++ .../package-map/package-map-longest-path.json | 19 ++++++ .../root/node_modules/inner/index.cjs | 2 + .../root/node_modules/inner/index.js | 2 + .../root/node_modules/inner/package.json | 10 ++++ test/parallel/test-require-package-map.js | 59 ++++++++++++++++++- 10 files changed, 182 insertions(+), 37 deletions(-) create mode 100644 test/fixtures/package-map/nested-project/package-map-external-deps.json create mode 100644 test/fixtures/package-map/package-map-longest-path.json create mode 100644 test/fixtures/package-map/root/node_modules/inner/index.cjs create mode 100644 test/fixtures/package-map/root/node_modules/inner/index.js create mode 100644 test/fixtures/package-map/root/node_modules/inner/package.json diff --git a/doc/api/packages.md b/doc/api/packages.md index 6fbd37d627f416..5ac7f009acc4ef 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -1020,15 +1020,15 @@ 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. +2. If the importing file is not within any mapped package, a + `MODULE_NOT_FOUND` error is thrown. 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. +6. If the package does not exist in the map at all, a + `MODULE_NOT_FOUND` error is thrown. ### Subpath resolution @@ -1094,16 +1094,15 @@ const utils = require('@myorg/utils'); import utils from '@myorg/utils'; ``` -### Fallback behavior +### Interaction with other resolution -Package maps do not replace `node_modules` resolution entirely. Resolution -falls back to standard behavior when: +Package maps only apply to bare specifiers that are not Node.js builtin +modules. The following cases are not affected by package maps and continue +to use standard resolution: -* 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.). +* Relative paths (`./` or `../`). +* Absolute paths or URLs. +* Node.js builtin modules (`node:fs`, etc.). ### Limitations diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 57b2165e35fc34..d3bc6fd96b6d19 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -665,8 +665,15 @@ function tryPackageMapResolveCJS(request, parent, conditions) { const parentPath = parent?.filename ?? process.cwd() + path.sep; const mapped = packageMapResolve(request, parentPath); - // Not in package map - fall back to standard resolution - if (mapped === undefined) { return undefined; } + if (mapped === undefined) { + const requireStack = getRequireStack(parent); + const message = getRequireStackMessage(request, requireStack); + // eslint-disable-next-line no-restricted-syntax + const err = new Error(message); + err.code = 'MODULE_NOT_FOUND'; + err.requireStack = requireStack; + throw err; + } const { packagePath, subpath } = mapped; diff --git a/lib/internal/modules/package_map.js b/lib/internal/modules/package_map.js index d97734cff7920c..8be9b31f8b3170 100644 --- a/lib/internal/modules/package_map.js +++ b/lib/internal/modules/package_map.js @@ -7,12 +7,11 @@ const { SafeSet, StringPrototypeIndexOf, StringPrototypeSlice, - StringPrototypeStartsWith, } = primordials; const assert = require('internal/assert'); const { getLazy } = require('internal/util'); -const { resolve: pathResolve, dirname, sep } = require('path'); +const { resolve: pathResolve, dirname } = require('path'); const getPackageMapPath = getLazy(() => require('internal/options').getOptionValue('--experimental-package-map'), @@ -117,7 +116,7 @@ class PackageMap { // Walk up to find containing package let checkPath = filePath; - while (isPathContainedIn(checkPath, this.#basePath)) { + while (true) { const keys = this.#pathToKeys.get(checkPath); if (keys && keys.size > 0) { const key = keys.values().next().value; @@ -144,7 +143,7 @@ class PackageMap { resolve(specifier, parentPath) { const parentKey = this.#getKeyForPath(parentPath); - // Parent not in map - fall back to standard resolution + // Parent not in map - let the caller throw the appropriate error if (parentKey === null) { return undefined; } // Check cache @@ -189,7 +188,7 @@ class PackageMap { this.#configPath, ); } - // Package not in map at all - fall back to standard resolution + // Package not in map at all - let the caller throw the appropriate error return undefined; } @@ -198,17 +197,6 @@ class PackageMap { } } -/** - * Check if a file path is contained within a folder path. - * Handles edge cases like /foo/bar not matching /foo/barbaz. - * @param {string} file - The file path to check - * @param {string} folder - The folder path to check against - * @returns {boolean} - */ -function isPathContainedIn(file, folder) { - return file === folder || StringPrototypeStartsWith(file, folder + sep); -} - /** * Parse a package specifier into name and subpath. * @param {string} specifier diff --git a/test/es-module/test-esm-package-map.mjs b/test/es-module/test-esm-package-map.mjs index 5aa65250b399af..e5842e66f73d4c 100644 --- a/test/es-module/test-esm-package-map.mjs +++ b/test/es-module/test-esm-package-map.mjs @@ -84,7 +84,7 @@ describe('ESM: --experimental-package-map', () => { // =========== Fallback Behavior =========== - describe('fallback to standard resolution', () => { + describe('resolution boundaries', () => { it('falls back for builtin modules', async () => { const { code, stdout, stderr } = await spawnPromisified(execPath, [ '--experimental-package-map', packageMapPath, @@ -97,7 +97,7 @@ describe('ESM: --experimental-package-map', () => { assert.match(stdout, /function/); }); - it('falls back when parent not in map', async () => { + it('throws when parent not in map', async () => { const { code, stderr } = await spawnPromisified(execPath, [ '--experimental-package-map', packageMapPath, '--input-type=module', @@ -105,9 +105,8 @@ describe('ESM: --experimental-package-map', () => { `import dep from 'dep-a'; console.log(dep);`, ], { cwd: '/tmp' }); // Not in any mapped package - // Should fall back to standard resolution (which will fail) assert.notStrictEqual(code, 0); - assert.match(stderr, /Cannot find package/); + assert.match(stderr, /ERR_MODULE_NOT_FOUND/); }); }); @@ -244,4 +243,56 @@ describe('ESM: --experimental-package-map', () => { assert.match(stdout, /pkg-value/); }); }); + + // =========== External Package Paths =========== + + describe('external package paths', () => { + it('resolves packages outside the package map directory via relative paths', async () => { + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/nested-project/package-map-external-deps.json'), + '--input-type=module', + '--eval', + `import dep from 'dep-a'; console.log(dep);`, + ], { cwd: fixtures.path('package-map/nested-project/src') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /dep-a-value/); + }); + }); + + // =========== Longest Path Wins =========== + + describe('longest path wins', () => { + const longestPathMap = fixtures.path('package-map/package-map-longest-path.json'); + + it('resolves nested package using its own dependencies, not the parent', async () => { + // Inner lives at ./root/node_modules/inner which is inside root's + // path (./root). The longest matching path should win, so code in + // inner should resolve dep-a (inner's dep), not be treated as root. + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', longestPathMap, + '--input-type=module', + '--eval', + `import inner from 'inner'; console.log(inner);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /inner using dep-a-value/); + }); + + it('denies access to nested package deps from parent package', async () => { + // Root does not list dep-a in its dependencies, so importing it + // from root should fail even though inner (nested inside root) can. + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', longestPathMap, + '--input-type=module', + '--eval', + `import dep from 'dep-a';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + }); + }); }); diff --git a/test/fixtures/package-map/nested-project/package-map-external-deps.json b/test/fixtures/package-map/nested-project/package-map-external-deps.json new file mode 100644 index 00000000000000..8244584ea83677 --- /dev/null +++ b/test/fixtures/package-map/nested-project/package-map-external-deps.json @@ -0,0 +1,14 @@ +{ + "packages": { + "app": { + "name": "app", + "path": "./src", + "dependencies": ["dep-a"] + }, + "dep-a": { + "name": "dep-a", + "path": "../dep-a", + "dependencies": [] + } + } +} diff --git a/test/fixtures/package-map/package-map-longest-path.json b/test/fixtures/package-map/package-map-longest-path.json new file mode 100644 index 00000000000000..db4e0acfe50cbc --- /dev/null +++ b/test/fixtures/package-map/package-map-longest-path.json @@ -0,0 +1,19 @@ +{ + "packages": { + "root": { + "name": "root", + "path": "./root", + "dependencies": ["inner"] + }, + "inner": { + "name": "inner", + "path": "./root/node_modules/inner", + "dependencies": ["dep-a"] + }, + "dep-a": { + "name": "dep-a", + "path": "./dep-a", + "dependencies": [] + } + } +} diff --git a/test/fixtures/package-map/root/node_modules/inner/index.cjs b/test/fixtures/package-map/root/node_modules/inner/index.cjs new file mode 100644 index 00000000000000..9427565615b1f3 --- /dev/null +++ b/test/fixtures/package-map/root/node_modules/inner/index.cjs @@ -0,0 +1,2 @@ +const depA = require('dep-a'); +module.exports = { default: `inner using ${depA.default}` }; diff --git a/test/fixtures/package-map/root/node_modules/inner/index.js b/test/fixtures/package-map/root/node_modules/inner/index.js new file mode 100644 index 00000000000000..eb08e4d2e4a803 --- /dev/null +++ b/test/fixtures/package-map/root/node_modules/inner/index.js @@ -0,0 +1,2 @@ +import depA from 'dep-a'; +export default `inner using ${depA}`; diff --git a/test/fixtures/package-map/root/node_modules/inner/package.json b/test/fixtures/package-map/root/node_modules/inner/package.json new file mode 100644 index 00000000000000..d974f68263bfdc --- /dev/null +++ b/test/fixtures/package-map/root/node_modules/inner/package.json @@ -0,0 +1,10 @@ +{ + "name": "inner", + "type": "module", + "exports": { + ".": { + "import": "./index.js", + "require": "./index.cjs" + } + } +} diff --git a/test/parallel/test-require-package-map.js b/test/parallel/test-require-package-map.js index 26f7fab9c32b2f..f23eb8951eb640 100644 --- a/test/parallel/test-require-package-map.js +++ b/test/parallel/test-require-package-map.js @@ -70,7 +70,7 @@ describe('CJS: --experimental-package-map', () => { }); }); - describe('fallback behavior', () => { + describe('resolution boundaries', () => { it('falls back for builtin modules', () => { const { status, stdout, stderr } = spawnSync(process.execPath, [ '--experimental-package-map', packageMapPath, @@ -85,7 +85,7 @@ describe('CJS: --experimental-package-map', () => { assert.match(stdout, /function/); }); - it('falls back when parent not in map', () => { + it('throws when parent not in map', () => { const { status, stderr } = spawnSync(process.execPath, [ '--experimental-package-map', packageMapPath, '-e', @@ -95,7 +95,6 @@ describe('CJS: --experimental-package-map', () => { encoding: 'utf8', }); - // Should fall back to standard resolution (which will fail) assert.notStrictEqual(status, 0); assert.match(stderr, /Cannot find module/); }); @@ -182,4 +181,58 @@ describe('CJS: --experimental-package-map', () => { assert.match(stdout, /pkg-value/); }); }); + + describe('external package paths', () => { + it('resolves packages outside the package map directory via relative paths', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', + fixtures.path('package-map/nested-project/package-map-external-deps.json'), + '-e', + `const dep = require('dep-a'); console.log(dep.default);`, + ], { + cwd: fixtures.path('package-map/nested-project/src'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /dep-a-value/); + }); + }); + + describe('longest path wins', () => { + const longestPathMap = fixtures.path('package-map/package-map-longest-path.json'); + + it('resolves nested package using its own dependencies, not the parent', () => { + // Inner lives at ./root/node_modules/inner which is inside root's + // path (./root). The longest matching path should win, so code in + // inner should resolve dep-a (inner's dep), not be treated as root. + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', longestPathMap, + '-e', + `const inner = require('inner'); console.log(inner.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /inner using dep-a-value/); + }); + + it('denies access to nested package deps from parent package', () => { + // Root does not list dep-a in its dependencies, so requiring it + // from root should fail even though inner (nested inside root) can. + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', longestPathMap, + '-e', + `require('dep-a');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.notStrictEqual(status, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + }); + }); }); From bc12241256af567ad0a3227f65aac6f09c8beff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Sun, 15 Mar 2026 16:03:32 +0100 Subject: [PATCH 5/7] Documents the dependencies field as a Map, where key is bare identifier and value is package ID --- doc/api/errors.md | 20 +++++---- doc/api/packages.md | 43 +++++++++++-------- lib/internal/modules/package_map.js | 23 ++++------ test/es-module/test-esm-package-map.mjs | 21 +++++++++ test/fixtures/package-map/app-18/index.js | 2 + test/fixtures/package-map/app-18/package.json | 7 +++ test/fixtures/package-map/app-19/index.js | 2 + test/fixtures/package-map/app-19/package.json | 7 +++ .../package-map-external-deps.json | 4 +- .../package-map/package-map-longest-path.json | 6 +-- .../package-map/package-map-missing-dep.json | 2 +- .../package-map/package-map-nameless.json | 4 +- .../package-map/package-map-path-prefix.json | 4 +- .../package-map/package-map-peer-deps.json | 8 ++-- .../package-map/package-map-version-fork.json | 27 ++++++++++++ test/fixtures/package-map/package-map.json | 8 ++-- test/parallel/test-require-package-map.js | 21 +++++++++ 17 files changed, 150 insertions(+), 59 deletions(-) create mode 100644 test/fixtures/package-map/app-18/index.js create mode 100644 test/fixtures/package-map/app-18/package.json create mode 100644 test/fixtures/package-map/app-19/index.js create mode 100644 test/fixtures/package-map/app-19/package.json create mode 100644 test/fixtures/package-map/package-map-version-fork.json diff --git a/doc/api/errors.md b/doc/api/errors.md index 3b2b61c9324da8..e34d89d096e3c4 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -2505,10 +2505,10 @@ added: REPLACEME --> A package attempted to import another package that exists in the [package map][] -but is not listed in its `dependencies` array. +but is not listed in its `dependencies` object. ```mjs -// package-map.json declares "app" with dependencies: ["utils"] +// package-map.json declares "app" with dependencies: {"utils": "utils"} // but "app" tries to import "secret-lib" which exists in the map // In app/index.js @@ -2516,7 +2516,7 @@ 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. +`dependencies` object in the package map configuration file. @@ -2546,8 +2546,8 @@ Error [ERR_PACKAGE_MAP_INVALID]: Invalid package map at "./missing.json": file n added: REPLACEME --> -A package's `dependencies` array in the [package map][] references a key that -is not defined in the `packages` object. +A package's `dependencies` object in the [package map][] references a package +key that is not defined in the `packages` object. ```json { @@ -2555,17 +2555,19 @@ is not defined in the `packages` object. "app": { "name": "app", "path": "./app", - "dependencies": ["nonexistent"] + "dependencies": { + "foo": "nonexistent" + } } } } ``` -In this example, `"nonexistent"` is referenced in `dependencies` but not +In this example, `"nonexistent"` is referenced as a dependency target 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. +To fix this error, ensure all package keys referenced in `dependencies` values +are defined in the `packages` object. diff --git a/doc/api/packages.md b/doc/api/packages.md index 5ac7f009acc4ef..b5db272d017f66 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -989,17 +989,21 @@ Each key in `packages` is a unique identifier for a package entry: "app": { "name": "my-app", "path": "./packages/app", - "dependencies": ["utils", "ui-lib"] + "dependencies": { + "@myorg/utils": "utils", + "@myorg/ui-lib": "ui-lib" + } }, "utils": { "name": "@myorg/utils", - "path": "./packages/utils", - "dependencies": [] + "path": "./packages/utils" }, "ui-lib": { "name": "@myorg/ui-lib", "path": "./packages/ui-lib", - "dependencies": ["utils"] + "dependencies": { + "@myorg/utils": "utils" + } } } } @@ -1011,8 +1015,10 @@ Each package entry has the following fields: 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. +* `dependencies` {Object} An object mapping bare specifiers to package keys. + Each key is the import name used in source code, and each value is the + corresponding package key in the `packages` object. Defaults to an empty + object. ### Resolution algorithm @@ -1022,9 +1028,9 @@ When a bare specifier is encountered: if the file path is within any package's `path`. 2. If the importing file is not within any mapped package, a `MODULE_NOT_FOUND` error is thrown. -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`. +3. Node.js looks up the specifier's package name in the importing package's + `dependencies` object to find the corresponding package key. +4. If found, the specifier resolves to the target package'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, a @@ -1046,8 +1052,9 @@ 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: +Different packages can depend on different versions of the same package. +Because `dependencies` maps bare specifiers to package keys, two packages +can map the same specifier to different targets: ```json { @@ -1055,22 +1062,24 @@ using distinct keys: "app": { "name": "app", "path": "./app", - "dependencies": ["component-v2"] + "dependencies": { + "component": "component-v2" + } }, "legacy": { "name": "legacy", "path": "./legacy", - "dependencies": ["component-v1"] + "dependencies": { + "component": "component-v1" + } }, "component-v1": { "name": "component", - "path": "./vendor/component-1.0.0", - "dependencies": [] + "path": "./vendor/component-1.0.0" }, "component-v2": { "name": "component", - "path": "./vendor/component-2.0.0", - "dependencies": [] + "path": "./vendor/component-2.0.0" } } } diff --git a/lib/internal/modules/package_map.js b/lib/internal/modules/package_map.js index 8be9b31f8b3170..5b68120cdd4433 100644 --- a/lib/internal/modules/package_map.js +++ b/lib/internal/modules/package_map.js @@ -32,7 +32,7 @@ let emittedWarning = false; /** * @typedef {object} PackageMapEntry * @property {string} path - Absolute path to package on disk - * @property {Set} dependencies - Set of package keys this package can access + * @property {Map} dependencies - Map from bare specifier to package key * @property {string|undefined} name - Package name (undefined = nameless package) */ @@ -85,7 +85,7 @@ class PackageMap { this.#packages.set(key, { path: absolutePath, - dependencies: new SafeSet(entry.dependencies ?? []), + dependencies: new SafeMap(ObjectEntries(entry.dependencies ?? {})), name: entry.name, // Undefined for nameless packages }); @@ -166,20 +166,9 @@ class PackageMap { const { packageName, subpath } = parsePackageName(specifier); const parentEntry = this.#packages.get(parentKey); - // Find matching dependency by name - let targetKey = null; - for (const depKey of parentEntry.dependencies) { - const depEntry = this.#packages.get(depKey); - if (!depEntry) { - throw new ERR_PACKAGE_MAP_KEY_NOT_FOUND(depKey, this.#configPath); - } - if (depEntry.name === packageName) { - targetKey = depKey; - break; - } - } + const targetKey = parentEntry.dependencies.get(packageName); - if (targetKey === null) { + if (targetKey === undefined) { // Check if package exists anywhere in map but isn't accessible if (this.#nameToKeys.has(packageName)) { throw new ERR_PACKAGE_MAP_ACCESS_DENIED( @@ -193,6 +182,10 @@ class PackageMap { } const targetEntry = this.#packages.get(targetKey); + if (!targetEntry) { + throw new ERR_PACKAGE_MAP_KEY_NOT_FOUND(targetKey, this.#configPath); + } + return { packagePath: targetEntry.path, subpath }; } } diff --git a/test/es-module/test-esm-package-map.mjs b/test/es-module/test-esm-package-map.mjs index e5842e66f73d4c..fb4f7cbb1bb681 100644 --- a/test/es-module/test-esm-package-map.mjs +++ b/test/es-module/test-esm-package-map.mjs @@ -261,6 +261,27 @@ describe('ESM: --experimental-package-map', () => { }); }); + // =========== Same Request, Different Versions =========== + + describe('same request resolves to different versions', () => { + const versionForkMap = fixtures.path('package-map/package-map-version-fork.json'); + + it('resolves the same bare specifier to different packages depending on the importer', async () => { + // app-18 and app-19 both import 'react', but the package map wires + // them to react@18 and react@19 respectively. + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', versionForkMap, + '--input-type=module', + '--eval', + `import app18 from 'app-18'; import app19 from 'app-19'; console.log(app18); console.log(app19);`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.strictEqual(code, 0, stderr); + assert.match(stdout, /app-18 using react 18/); + assert.match(stdout, /app-19 using react 19/); + }); + }); + // =========== Longest Path Wins =========== describe('longest path wins', () => { diff --git a/test/fixtures/package-map/app-18/index.js b/test/fixtures/package-map/app-18/index.js new file mode 100644 index 00000000000000..ce0c2d5250c34c --- /dev/null +++ b/test/fixtures/package-map/app-18/index.js @@ -0,0 +1,2 @@ +import react from 'react'; +export default `app-18 using react ${react.version}`; diff --git a/test/fixtures/package-map/app-18/package.json b/test/fixtures/package-map/app-18/package.json new file mode 100644 index 00000000000000..7b1bb0e4a74c97 --- /dev/null +++ b/test/fixtures/package-map/app-18/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-18", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/app-19/index.js b/test/fixtures/package-map/app-19/index.js new file mode 100644 index 00000000000000..2f6ebe522f7d76 --- /dev/null +++ b/test/fixtures/package-map/app-19/index.js @@ -0,0 +1,2 @@ +import react from 'react'; +export default `app-19 using react ${react.version}`; diff --git a/test/fixtures/package-map/app-19/package.json b/test/fixtures/package-map/app-19/package.json new file mode 100644 index 00000000000000..e5a7266f36e4e3 --- /dev/null +++ b/test/fixtures/package-map/app-19/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-19", + "type": "module", + "exports": { + ".": "./index.js" + } +} diff --git a/test/fixtures/package-map/nested-project/package-map-external-deps.json b/test/fixtures/package-map/nested-project/package-map-external-deps.json index 8244584ea83677..95762cbb470fe0 100644 --- a/test/fixtures/package-map/nested-project/package-map-external-deps.json +++ b/test/fixtures/package-map/nested-project/package-map-external-deps.json @@ -3,12 +3,12 @@ "app": { "name": "app", "path": "./src", - "dependencies": ["dep-a"] + "dependencies": {"dep-a": "dep-a"} }, "dep-a": { "name": "dep-a", "path": "../dep-a", - "dependencies": [] + "dependencies": {} } } } diff --git a/test/fixtures/package-map/package-map-longest-path.json b/test/fixtures/package-map/package-map-longest-path.json index db4e0acfe50cbc..67e034a3c0ddcb 100644 --- a/test/fixtures/package-map/package-map-longest-path.json +++ b/test/fixtures/package-map/package-map-longest-path.json @@ -3,17 +3,17 @@ "root": { "name": "root", "path": "./root", - "dependencies": ["inner"] + "dependencies": {"inner": "inner"} }, "inner": { "name": "inner", "path": "./root/node_modules/inner", - "dependencies": ["dep-a"] + "dependencies": {"dep-a": "dep-a"} }, "dep-a": { "name": "dep-a", "path": "./dep-a", - "dependencies": [] + "dependencies": {} } } } diff --git a/test/fixtures/package-map/package-map-missing-dep.json b/test/fixtures/package-map/package-map-missing-dep.json index b8a6b044813fb2..5f1281252cfdda 100644 --- a/test/fixtures/package-map/package-map-missing-dep.json +++ b/test/fixtures/package-map/package-map-missing-dep.json @@ -3,7 +3,7 @@ "root": { "name": "root", "path": "./root", - "dependencies": ["nonexistent-key"] + "dependencies": {"nonexistent": "nonexistent-key"} } } } diff --git a/test/fixtures/package-map/package-map-nameless.json b/test/fixtures/package-map/package-map-nameless.json index bb3a59341073ff..3066f0a5eaf247 100644 --- a/test/fixtures/package-map/package-map-nameless.json +++ b/test/fixtures/package-map/package-map-nameless.json @@ -2,12 +2,12 @@ "packages": { "nameless": { "path": "./nameless-pkg", - "dependencies": [] + "dependencies": {} }, "forbidden": { "name": "forbidden", "path": "./not-a-dep", - "dependencies": [] + "dependencies": {} } } } diff --git a/test/fixtures/package-map/package-map-path-prefix.json b/test/fixtures/package-map/package-map-path-prefix.json index 0dc80c90290217..6627b108fb8bf0 100644 --- a/test/fixtures/package-map/package-map-path-prefix.json +++ b/test/fixtures/package-map/package-map-path-prefix.json @@ -3,12 +3,12 @@ "pkg": { "name": "pkg", "path": "./pkg", - "dependencies": [] + "dependencies": {} }, "pkg-other": { "name": "pkg-other", "path": "./pkg-other", - "dependencies": ["pkg"] + "dependencies": {"pkg": "pkg"} } } } diff --git a/test/fixtures/package-map/package-map-peer-deps.json b/test/fixtures/package-map/package-map-peer-deps.json index ac7121f6781b01..73dd1b1865924c 100644 --- a/test/fixtures/package-map/package-map-peer-deps.json +++ b/test/fixtures/package-map/package-map-peer-deps.json @@ -3,22 +3,22 @@ "app-v1": { "name": "app", "path": "./root", - "dependencies": ["component-lib@react-18", "react@18"] + "dependencies": {"component-lib": "component-lib@react-18", "react": "react@18"} }, "app-v2": { "name": "app", "path": "./root", - "dependencies": ["component-lib@react-19", "react@19"] + "dependencies": {"component-lib": "component-lib@react-19", "react": "react@19"} }, "component-lib@react-18": { "name": "component-lib", "path": "./component-lib", - "dependencies": ["react@18"] + "dependencies": {"react": "react@18"} }, "component-lib@react-19": { "name": "component-lib", "path": "./component-lib", - "dependencies": ["react@19"] + "dependencies": {"react": "react@19"} }, "react@18": { "name": "react", diff --git a/test/fixtures/package-map/package-map-version-fork.json b/test/fixtures/package-map/package-map-version-fork.json new file mode 100644 index 00000000000000..f6e9b23cb7839d --- /dev/null +++ b/test/fixtures/package-map/package-map-version-fork.json @@ -0,0 +1,27 @@ +{ + "packages": { + "root": { + "name": "root", + "path": "./root", + "dependencies": {"app-18": "app-18", "app-19": "app-19"} + }, + "app-18": { + "name": "app-18", + "path": "./app-18", + "dependencies": {"react": "react@18"} + }, + "app-19": { + "name": "app-19", + "path": "./app-19", + "dependencies": {"react": "react@19"} + }, + "react@18": { + "name": "react", + "path": "./react-18" + }, + "react@19": { + "name": "react", + "path": "./react-19" + } + } +} diff --git a/test/fixtures/package-map/package-map.json b/test/fixtures/package-map/package-map.json index 60ec4562c0be36..e06baff133cfd5 100644 --- a/test/fixtures/package-map/package-map.json +++ b/test/fixtures/package-map/package-map.json @@ -3,22 +3,22 @@ "root": { "name": "root", "path": "./root", - "dependencies": ["dep-a", "dep-b"] + "dependencies": {"dep-a": "dep-a", "dep-b": "dep-b"} }, "dep-a": { "name": "dep-a", "path": "./dep-a", - "dependencies": [] + "dependencies": {} }, "dep-b": { "name": "dep-b", "path": "./dep-b", - "dependencies": ["dep-a"] + "dependencies": {"dep-a": "dep-a"} }, "not-a-dep": { "name": "not-a-dep", "path": "./not-a-dep", - "dependencies": [] + "dependencies": {} } } } diff --git a/test/parallel/test-require-package-map.js b/test/parallel/test-require-package-map.js index f23eb8951eb640..fb19ca4de5f877 100644 --- a/test/parallel/test-require-package-map.js +++ b/test/parallel/test-require-package-map.js @@ -199,6 +199,27 @@ describe('CJS: --experimental-package-map', () => { }); }); + describe('same request resolves to different versions', () => { + const versionForkMap = fixtures.path('package-map/package-map-version-fork.json'); + + it('resolves the same bare specifier to different packages depending on the importer', () => { + // app-18 and app-19 both require 'react', but the package map wires + // them to react@18 and react@19 respectively. + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', versionForkMap, + '-e', + `const app18 = require('app-18'); const app19 = require('app-19'); console.log(app18.default); console.log(app19.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /app-18 using react 18/); + assert.match(stdout, /app-19 using react 19/); + }); + }); + describe('longest path wins', () => { const longestPathMap = fixtures.path('package-map/package-map-longest-path.json'); From 58adeb407103672dc60fa1ee93149ad790e7ac78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Sun, 15 Mar 2026 17:41:54 +0100 Subject: [PATCH 6/7] Adds gitkeep to avoid ENOENT in tests --- test/fixtures/package-map/nested-project/src/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/fixtures/package-map/nested-project/src/.gitkeep diff --git a/test/fixtures/package-map/nested-project/src/.gitkeep b/test/fixtures/package-map/nested-project/src/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 From 72ed592c1f79e32dd3634d8d395c9d1a8acc88c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Sun, 15 Mar 2026 18:20:24 +0100 Subject: [PATCH 7/7] Tweaks (remove name, validate duplicated paths) --- doc/api/errors.md | 24 +++--- doc/api/packages.md | 23 ++---- lib/internal/errors.js | 4 +- lib/internal/modules/package_map.js | 77 +++++-------------- test/es-module/test-esm-package-map.mjs | 55 +++++-------- .../package-map-external-deps.json | 2 - .../package-map-duplicate-path.json | 12 +++ .../package-map/package-map-longest-path.json | 3 - .../package-map/package-map-missing-dep.json | 1 - .../package-map/package-map-nameless.json | 13 ---- .../package-map/package-map-path-prefix.json | 2 - .../package-map/package-map-peer-deps.json | 32 -------- .../package-map/package-map-version-fork.json | 5 -- test/fixtures/package-map/package-map.json | 4 - test/parallel/test-require-package-map.js | 41 +++++----- 15 files changed, 92 insertions(+), 206 deletions(-) create mode 100644 test/fixtures/package-map/package-map-duplicate-path.json delete mode 100644 test/fixtures/package-map/package-map-nameless.json delete mode 100644 test/fixtures/package-map/package-map-peer-deps.json diff --git a/doc/api/errors.md b/doc/api/errors.md index e34d89d096e3c4..9059b5ca33f0b1 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -2496,27 +2496,25 @@ A given value is out of the accepted range. The `package.json` [`"imports"`][] field does not define the given internal package specifier mapping. - + -### `ERR_PACKAGE_MAP_ACCESS_DENIED` +### `ERR_PACKAGE_MAP_EXTERNAL_FILE` -A package attempted to import another package that exists in the [package map][] -but is not listed in its `dependencies` object. +A module attempted to resolve a bare specifier using the [package map][], but +the importing file is not located within any package defined in the map. -```mjs -// package-map.json declares "app" with dependencies: {"utils": "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 +```console +$ node --experimental-package-map=./package-map.json /tmp/script.js +Error [ERR_PACKAGE_MAP_EXTERNAL_FILE]: Cannot resolve "dep-a" from "/tmp/script.js": file is not within any package defined in /path/to/package-map.json ``` -To fix this error, add the required package to the importing package's -`dependencies` object in the package map configuration file. +To fix this error, ensure the importing file is inside one of the package +directories listed in the package map, or add a new package entry whose `path` +covers the importing file. @@ -2532,6 +2530,7 @@ The [package map][] configuration file is invalid. This can occur when: * The file contains invalid JSON. * The file is missing the required `packages` object. * A package entry is missing the required `path` field. +* Two package entries have the same `path` value. ```console $ node --experimental-package-map=./missing.json app.js @@ -2553,7 +2552,6 @@ key that is not defined in the `packages` object. { "packages": { "app": { - "name": "app", "path": "./app", "dependencies": { "foo": "nonexistent" diff --git a/doc/api/packages.md b/doc/api/packages.md index b5db272d017f66..efe912ba92555a 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -987,7 +987,6 @@ Each key in `packages` is a unique identifier for a package entry: { "packages": { "app": { - "name": "my-app", "path": "./packages/app", "dependencies": { "@myorg/utils": "utils", @@ -995,11 +994,9 @@ Each key in `packages` is a unique identifier for a package entry: } }, "utils": { - "name": "@myorg/utils", "path": "./packages/utils" }, "ui-lib": { - "name": "@myorg/ui-lib", "path": "./packages/ui-lib", "dependencies": { "@myorg/utils": "utils" @@ -1012,9 +1009,8 @@ Each key in `packages` is a unique identifier for a package entry: 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. + the package directory. Each path must be unique across all packages in the + map; duplicate paths will throw an [`ERR_PACKAGE_MAP_INVALID`][] error. * `dependencies` {Object} An object mapping bare specifiers to package keys. Each key is the import name used in source code, and each value is the corresponding package key in the `packages` object. Defaults to an empty @@ -1026,14 +1022,12 @@ 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, a - `MODULE_NOT_FOUND` error is thrown. +2. If the importing file is not within any mapped package, an + [`ERR_PACKAGE_MAP_EXTERNAL_FILE`][] error is thrown. 3. Node.js looks up the specifier's package name in the importing package's `dependencies` object to find the corresponding package key. 4. If found, the specifier resolves to the target package'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, a +5. If the specifier is not in `dependencies`, a `MODULE_NOT_FOUND` error is thrown. ### Subpath resolution @@ -1060,25 +1054,21 @@ can map the same specifier to different targets: { "packages": { "app": { - "name": "app", "path": "./app", "dependencies": { "component": "component-v2" } }, "legacy": { - "name": "legacy", "path": "./legacy", "dependencies": { "component": "component-v1" } }, "component-v1": { - "name": "component", "path": "./vendor/component-1.0.0" }, "component-v2": { - "name": "component", "path": "./vendor/component-2.0.0" } } @@ -1352,7 +1342,8 @@ This field defines [subpath imports][] for the current package. [`--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_MAP_EXTERNAL_FILE`]: errors.md#err_package_map_external_file +[`ERR_PACKAGE_MAP_INVALID`]: errors.md#err_package_map_invalid [`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 diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 65976f54b61ef3..7b78b918f32719 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1661,8 +1661,8 @@ 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}`; +E('ERR_PACKAGE_MAP_EXTERNAL_FILE', (specifier, parentPath, configPath) => { + return `Cannot resolve "${specifier}" from "${parentPath}": file is not within any package defined in ${configPath}`; }, Error); E('ERR_PACKAGE_MAP_INVALID', (configPath, reason) => { return `Invalid package-map.json at "${configPath}": ${reason}`; diff --git a/lib/internal/modules/package_map.js b/lib/internal/modules/package_map.js index 5b68120cdd4433..fd0cd5d4815bbb 100644 --- a/lib/internal/modules/package_map.js +++ b/lib/internal/modules/package_map.js @@ -4,7 +4,6 @@ const { JSONParse, ObjectEntries, SafeMap, - SafeSet, StringPrototypeIndexOf, StringPrototypeSlice, } = primordials; @@ -19,7 +18,7 @@ const getPackageMapPath = getLazy(() => const fs = require('fs'); const { - ERR_PACKAGE_MAP_ACCESS_DENIED, + ERR_PACKAGE_MAP_EXTERNAL_FILE, ERR_PACKAGE_MAP_INVALID, ERR_PACKAGE_MAP_KEY_NOT_FOUND, } = require('internal/errors').codes; @@ -33,7 +32,6 @@ let emittedWarning = false; * @typedef {object} PackageMapEntry * @property {string} path - Absolute path to package on disk * @property {Map} dependencies - Map from bare specifier to package key - * @property {string|undefined} name - Package name (undefined = nameless package) */ class PackageMap { @@ -43,14 +41,10 @@ class PackageMap { #basePath; /** @type {Map} */ #packages; - /** @type {Map>} */ - #nameToKeys; - /** @type {Map>} */ - #pathToKeys; + /** @type {Map} */ + #pathToKey; /** @type {Map} */ #pathToKeyCache; - /** @type {Map} */ - #resolveCache; /** * @param {string} configPath @@ -60,10 +54,8 @@ class PackageMap { this.#configPath = configPath; this.#basePath = dirname(configPath); this.#packages = new SafeMap(); - this.#nameToKeys = new SafeMap(); - this.#pathToKeys = new SafeMap(); + this.#pathToKey = new SafeMap(); this.#pathToKeyCache = new SafeMap(); - this.#resolveCache = new SafeMap(); this.#parse(data); } @@ -86,22 +78,19 @@ class PackageMap { this.#packages.set(key, { path: absolutePath, dependencies: new SafeMap(ObjectEntries(entry.dependencies ?? {})), - name: entry.name, // Undefined for nameless packages }); - // Index by name (only named packages) - if (entry.name !== undefined) { - if (!this.#nameToKeys.has(entry.name)) { - this.#nameToKeys.set(entry.name, new SafeSet()); - } - this.#nameToKeys.get(entry.name).add(key); + // TODO(arcanis): Duplicates should be tolerated, but it requires changing module IDs + // to include the package ID whenever a package map is used. + if (this.#pathToKey.has(absolutePath)) { + const existingKey = this.#pathToKey.get(absolutePath); + throw new ERR_PACKAGE_MAP_INVALID( + this.#configPath, + `packages "${existingKey}" and "${key}" have the same path "${entry.path}"; this will be supported in the future`, + ); } - // Index by path - if (!this.#pathToKeys.has(absolutePath)) { - this.#pathToKeys.set(absolutePath, new SafeSet()); - } - this.#pathToKeys.get(absolutePath).add(key); + this.#pathToKey.set(absolutePath, key); } } @@ -117,9 +106,8 @@ class PackageMap { // Walk up to find containing package let checkPath = filePath; while (true) { - const keys = this.#pathToKeys.get(checkPath); - if (keys && keys.size > 0) { - const key = keys.values().next().value; + const key = this.#pathToKey.get(checkPath); + if (key !== undefined) { this.#pathToKeyCache.set(filePath, key); return key; } @@ -135,7 +123,9 @@ class PackageMap { /** * Main resolution method. * Returns the package path and subpath for the specifier, or undefined if - * resolution should fall back to standard resolution. + * the specifier is not in the parent package's dependencies. + * Throws ERR_PACKAGE_MAP_EXTERNAL_FILE if parentPath is not within any + * mapped package. * @param {string} specifier * @param {string} parentPath - File path of the importing module * @returns {{packagePath: string, subpath: string}|undefined} @@ -143,41 +133,16 @@ class PackageMap { resolve(specifier, parentPath) { const parentKey = this.#getKeyForPath(parentPath); - // Parent not in map - let the caller throw the appropriate error - if (parentKey === null) { return undefined; } - - // Check cache - const cacheKey = `${parentKey}\0${specifier}`; - if (this.#resolveCache.has(cacheKey)) { - return this.#resolveCache.get(cacheKey); + if (parentKey === null) { + throw new ERR_PACKAGE_MAP_EXTERNAL_FILE(specifier, parentPath, this.#configPath); } - const result = this.#resolveUncached(specifier, parentKey); - this.#resolveCache.set(cacheKey, result); - return result; - } - - /** - * @param {string} specifier - * @param {string} parentKey - * @returns {{packagePath: string, subpath: string}|undefined} - */ - #resolveUncached(specifier, parentKey) { const { packageName, subpath } = parsePackageName(specifier); const parentEntry = this.#packages.get(parentKey); const targetKey = parentEntry.dependencies.get(packageName); - if (targetKey === undefined) { - // Check if package exists anywhere in map but isn't accessible - if (this.#nameToKeys.has(packageName)) { - throw new ERR_PACKAGE_MAP_ACCESS_DENIED( - specifier, - parentKey, - this.#configPath, - ); - } - // Package not in map at all - let the caller throw the appropriate error + // Package not in parent's dependencies - let the caller throw the appropriate error return undefined; } diff --git a/test/es-module/test-esm-package-map.mjs b/test/es-module/test-esm-package-map.mjs index fb4f7cbb1bb681..8c9f2fa0b370ec 100644 --- a/test/es-module/test-esm-package-map.mjs +++ b/test/es-module/test-esm-package-map.mjs @@ -49,39 +49,6 @@ describe('ESM: --experimental-package-map', () => { }); }); - // =========== Access Control =========== - - describe('dependency access control', () => { - it('throws ERR_PACKAGE_MAP_ACCESS_DENIED for undeclared dependency', async () => { - const { code, stderr } = await spawnPromisified(execPath, [ - '--experimental-package-map', packageMapPath, - '--input-type=module', - '--eval', - `import x from 'not-a-dep';`, - ], { cwd: fixtures.path('package-map/root') }); - - assert.notStrictEqual(code, 0); - assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); - assert.match(stderr, /not-a-dep/); - assert.match(stderr, /root/); // parent package name - }); - - it('includes package key in error for nameless packages', async () => { - const { code, stderr } = await spawnPromisified(execPath, [ - '--experimental-package-map', - fixtures.path('package-map/package-map-nameless.json'), - '--input-type=module', - '--eval', - `import x from 'forbidden';`, - ], { cwd: fixtures.path('package-map/nameless-pkg') }); - - assert.notStrictEqual(code, 0); - assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); - // Should show key since package is nameless - assert.match(stderr, /nameless/); - }); - }); - // =========== Fallback Behavior =========== describe('resolution boundaries', () => { @@ -106,7 +73,7 @@ describe('ESM: --experimental-package-map', () => { ], { cwd: '/tmp' }); // Not in any mapped package assert.notStrictEqual(code, 0); - assert.match(stderr, /ERR_MODULE_NOT_FOUND/); + assert.match(stderr, /ERR_PACKAGE_MAP_EXTERNAL_FILE/); }); }); @@ -162,6 +129,20 @@ describe('ESM: --experimental-package-map', () => { assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); assert.match(stderr, /not found/); }); + + it('throws ERR_PACKAGE_MAP_INVALID for duplicate package paths', async () => { + const { code, stderr } = await spawnPromisified(execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-duplicate-path.json'), + '--input-type=module', + '--eval', `import x from 'dep-a';`, + ], { cwd: fixtures.path('package-map/root') }); + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + assert.match(stderr, /pkg-a/); + assert.match(stderr, /pkg-b/); + }); }); // =========== Zero Overhead When Disabled =========== @@ -302,9 +283,9 @@ describe('ESM: --experimental-package-map', () => { assert.match(stdout, /inner using dep-a-value/); }); - it('denies access to nested package deps from parent package', async () => { + it('falls back for undeclared dependency in nested package parent', async () => { // Root does not list dep-a in its dependencies, so importing it - // from root should fail even though inner (nested inside root) can. + // from root should fail with ERR_MODULE_NOT_FOUND. const { code, stderr } = await spawnPromisified(execPath, [ '--experimental-package-map', longestPathMap, '--input-type=module', @@ -313,7 +294,7 @@ describe('ESM: --experimental-package-map', () => { ], { cwd: fixtures.path('package-map/root') }); assert.notStrictEqual(code, 0); - assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + assert.match(stderr, /ERR_MODULE_NOT_FOUND/); }); }); }); diff --git a/test/fixtures/package-map/nested-project/package-map-external-deps.json b/test/fixtures/package-map/nested-project/package-map-external-deps.json index 95762cbb470fe0..260543aab9eb40 100644 --- a/test/fixtures/package-map/nested-project/package-map-external-deps.json +++ b/test/fixtures/package-map/nested-project/package-map-external-deps.json @@ -1,12 +1,10 @@ { "packages": { "app": { - "name": "app", "path": "./src", "dependencies": {"dep-a": "dep-a"} }, "dep-a": { - "name": "dep-a", "path": "../dep-a", "dependencies": {} } diff --git a/test/fixtures/package-map/package-map-duplicate-path.json b/test/fixtures/package-map/package-map-duplicate-path.json new file mode 100644 index 00000000000000..5ac451af0188fa --- /dev/null +++ b/test/fixtures/package-map/package-map-duplicate-path.json @@ -0,0 +1,12 @@ +{ + "packages": { + "pkg-a": { + "path": "./root", + "dependencies": {} + }, + "pkg-b": { + "path": "./root", + "dependencies": {} + } + } +} diff --git a/test/fixtures/package-map/package-map-longest-path.json b/test/fixtures/package-map/package-map-longest-path.json index 67e034a3c0ddcb..463783a77ee379 100644 --- a/test/fixtures/package-map/package-map-longest-path.json +++ b/test/fixtures/package-map/package-map-longest-path.json @@ -1,17 +1,14 @@ { "packages": { "root": { - "name": "root", "path": "./root", "dependencies": {"inner": "inner"} }, "inner": { - "name": "inner", "path": "./root/node_modules/inner", "dependencies": {"dep-a": "dep-a"} }, "dep-a": { - "name": "dep-a", "path": "./dep-a", "dependencies": {} } diff --git a/test/fixtures/package-map/package-map-missing-dep.json b/test/fixtures/package-map/package-map-missing-dep.json index 5f1281252cfdda..f6b262c15627c1 100644 --- a/test/fixtures/package-map/package-map-missing-dep.json +++ b/test/fixtures/package-map/package-map-missing-dep.json @@ -1,7 +1,6 @@ { "packages": { "root": { - "name": "root", "path": "./root", "dependencies": {"nonexistent": "nonexistent-key"} } diff --git a/test/fixtures/package-map/package-map-nameless.json b/test/fixtures/package-map/package-map-nameless.json deleted file mode 100644 index 3066f0a5eaf247..00000000000000 --- a/test/fixtures/package-map/package-map-nameless.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "packages": { - "nameless": { - "path": "./nameless-pkg", - "dependencies": {} - }, - "forbidden": { - "name": "forbidden", - "path": "./not-a-dep", - "dependencies": {} - } - } -} diff --git a/test/fixtures/package-map/package-map-path-prefix.json b/test/fixtures/package-map/package-map-path-prefix.json index 6627b108fb8bf0..c4a9c62e56aa0a 100644 --- a/test/fixtures/package-map/package-map-path-prefix.json +++ b/test/fixtures/package-map/package-map-path-prefix.json @@ -1,12 +1,10 @@ { "packages": { "pkg": { - "name": "pkg", "path": "./pkg", "dependencies": {} }, "pkg-other": { - "name": "pkg-other", "path": "./pkg-other", "dependencies": {"pkg": "pkg"} } diff --git a/test/fixtures/package-map/package-map-peer-deps.json b/test/fixtures/package-map/package-map-peer-deps.json deleted file mode 100644 index 73dd1b1865924c..00000000000000 --- a/test/fixtures/package-map/package-map-peer-deps.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "packages": { - "app-v1": { - "name": "app", - "path": "./root", - "dependencies": {"component-lib": "component-lib@react-18", "react": "react@18"} - }, - "app-v2": { - "name": "app", - "path": "./root", - "dependencies": {"component-lib": "component-lib@react-19", "react": "react@19"} - }, - "component-lib@react-18": { - "name": "component-lib", - "path": "./component-lib", - "dependencies": {"react": "react@18"} - }, - "component-lib@react-19": { - "name": "component-lib", - "path": "./component-lib", - "dependencies": {"react": "react@19"} - }, - "react@18": { - "name": "react", - "path": "./react-18" - }, - "react@19": { - "name": "react", - "path": "./react-19" - } - } -} diff --git a/test/fixtures/package-map/package-map-version-fork.json b/test/fixtures/package-map/package-map-version-fork.json index f6e9b23cb7839d..f4362a94bd8a26 100644 --- a/test/fixtures/package-map/package-map-version-fork.json +++ b/test/fixtures/package-map/package-map-version-fork.json @@ -1,26 +1,21 @@ { "packages": { "root": { - "name": "root", "path": "./root", "dependencies": {"app-18": "app-18", "app-19": "app-19"} }, "app-18": { - "name": "app-18", "path": "./app-18", "dependencies": {"react": "react@18"} }, "app-19": { - "name": "app-19", "path": "./app-19", "dependencies": {"react": "react@19"} }, "react@18": { - "name": "react", "path": "./react-18" }, "react@19": { - "name": "react", "path": "./react-19" } } diff --git a/test/fixtures/package-map/package-map.json b/test/fixtures/package-map/package-map.json index e06baff133cfd5..7acececd7ffbab 100644 --- a/test/fixtures/package-map/package-map.json +++ b/test/fixtures/package-map/package-map.json @@ -1,22 +1,18 @@ { "packages": { "root": { - "name": "root", "path": "./root", "dependencies": {"dep-a": "dep-a", "dep-b": "dep-b"} }, "dep-a": { - "name": "dep-a", "path": "./dep-a", "dependencies": {} }, "dep-b": { - "name": "dep-b", "path": "./dep-b", "dependencies": {"dep-a": "dep-a"} }, "not-a-dep": { - "name": "not-a-dep", "path": "./not-a-dep", "dependencies": {} } diff --git a/test/parallel/test-require-package-map.js b/test/parallel/test-require-package-map.js index fb19ca4de5f877..15474ba40e8a9c 100644 --- a/test/parallel/test-require-package-map.js +++ b/test/parallel/test-require-package-map.js @@ -54,22 +54,6 @@ describe('CJS: --experimental-package-map', () => { }); }); - describe('dependency access control', () => { - it('throws for undeclared dependency', () => { - const { status, stderr } = spawnSync(process.execPath, [ - '--experimental-package-map', packageMapPath, - '-e', - `require('not-a-dep');`, - ], { - cwd: fixtures.path('package-map/root'), - encoding: 'utf8', - }); - - assert.notStrictEqual(status, 0); - assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); - }); - }); - describe('resolution boundaries', () => { it('falls back for builtin modules', () => { const { status, stdout, stderr } = spawnSync(process.execPath, [ @@ -96,7 +80,7 @@ describe('CJS: --experimental-package-map', () => { }); assert.notStrictEqual(status, 0); - assert.match(stderr, /Cannot find module/); + assert.match(stderr, /ERR_PACKAGE_MAP_EXTERNAL_FILE/); }); }); @@ -130,6 +114,23 @@ describe('CJS: --experimental-package-map', () => { assert.notStrictEqual(status, 0); assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); }); + + it('throws for duplicate package paths', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', + fixtures.path('package-map/package-map-duplicate-path.json'), + '-e', + `require('dep-a');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.notStrictEqual(status, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + assert.match(stderr, /pkg-a/); + assert.match(stderr, /pkg-b/); + }); }); describe('conditional exports', () => { @@ -240,9 +241,9 @@ describe('CJS: --experimental-package-map', () => { assert.match(stdout, /inner using dep-a-value/); }); - it('denies access to nested package deps from parent package', () => { + it('falls back for undeclared dependency in nested package parent', () => { // Root does not list dep-a in its dependencies, so requiring it - // from root should fail even though inner (nested inside root) can. + // from root should fail with MODULE_NOT_FOUND. const { status, stderr } = spawnSync(process.execPath, [ '--experimental-package-map', longestPathMap, '-e', @@ -253,7 +254,7 @@ describe('CJS: --experimental-package-map', () => { }); assert.notStrictEqual(status, 0); - assert.match(stderr, /ERR_PACKAGE_MAP_ACCESS_DENIED/); + assert.match(stderr, /Cannot find module/); }); }); });