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 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. + +```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, 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. + + + +### `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. +* Two package entries have the same `path` value. + +```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` object in the [package map][] references a package +key that is not defined in the `packages` object. + +```json +{ + "packages": { + "app": { + "path": "./app", + "dependencies": { + "foo": "nonexistent" + } + } + } +} +``` + +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 package keys referenced in `dependencies` values +are defined in the `packages` object. + ### `ERR_PACKAGE_PATH_NOT_EXPORTED` @@ -4463,6 +4534,7 @@ An error occurred trying to allocate memory. This should never happen. [domains]: domain.md [event emitter-based]: events.md#class-eventemitter [file descriptors]: https://en.wikipedia.org/wiki/File_descriptor +[package map]: packages.md#package-maps [relative URL]: https://url.spec.whatwg.org/#relative-url-string [self-reference a package using its name]: packages.md#self-referencing-a-package-using-its-name [special scheme]: https://url.spec.whatwg.org/#special-scheme diff --git a/doc/api/esm.md b/doc/api/esm.md index 5396acfd53d65c..2f4c9c934eee94 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 @@ -1297,12 +1303,14 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][]. [Loading ECMAScript modules using `require()`]: modules.md#loading-ecmascript-modules-using-require [Module customization hooks]: module.md#customization-hooks [Node.js Module Resolution And Loading Algorithm]: #resolution-algorithm-specification +[Package maps]: packages.md#package-maps [Source Phase Imports]: https://github.com/tc39/proposal-source-phase-imports [Terminology]: #terminology [URL]: https://url.spec.whatwg.org/ [WebAssembly JS String Builtins Proposal]: https://github.com/WebAssembly/js-string-builtins [`"exports"`]: packages.md#exports [`"type"`]: packages.md#type +[`--experimental-package-map`]: cli.md#--experimental-package-mappath [`--input-type`]: cli.md#--input-typetype [`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data [`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export diff --git a/doc/api/modules.md b/doc/api/modules.md index 84252b9819d2b4..482a49d721e00f 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: @@ -1269,8 +1273,10 @@ This section was moved to [Determining module system]: packages.md#determining-module-system [ECMAScript Modules]: esm.md [GLOBAL_FOLDERS]: #loading-from-the-global-folders +[Package maps]: packages.md#package-maps [`"main"`]: packages.md#main [`"type"`]: packages.md#type +[`--experimental-package-map`]: cli.md#--experimental-package-mappath [`--trace-require-module`]: cli.md#--trace-require-modulemode [`ERR_REQUIRE_ASYNC_MODULE`]: errors.md#err_require_async_module [`ERR_UNSUPPORTED_DIR_IMPORT`]: errors.md#err_unsupported_dir_import diff --git a/doc/api/packages.md b/doc/api/packages.md index d98fe357538a9a..efe912ba92555a 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -947,6 +947,169 @@ $ 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": { + "path": "./packages/app", + "dependencies": { + "@myorg/utils": "utils", + "@myorg/ui-lib": "ui-lib" + } + }, + "utils": { + "path": "./packages/utils" + }, + "ui-lib": { + "path": "./packages/ui-lib", + "dependencies": { + "@myorg/utils": "utils" + } + } + } +} +``` + +Each package entry has the following fields: + +* `path` {string} **Required.** Relative path from the configuration file to + 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 + object. + +### 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, 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 specifier is not in `dependencies`, a + `MODULE_NOT_FOUND` error is thrown. + +### 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. +Because `dependencies` maps bare specifiers to package keys, two packages +can map the same specifier to different targets: + +```json +{ + "packages": { + "app": { + "path": "./app", + "dependencies": { + "component": "component-v2" + } + }, + "legacy": { + "path": "./legacy", + "dependencies": { + "component": "component-v1" + } + }, + "component-v1": { + "path": "./vendor/component-1.0.0" + }, + "component-v2": { + "path": "./vendor/component-2.0.0" + } + } +} +``` + +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'; +``` + +### Interaction with other resolution + +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: + +* Relative paths (`./` or `../`). +* Absolute paths or URLs. +* Node.js builtin modules (`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 +1340,10 @@ 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_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/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..7b78b918f32719 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_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}`; +}, 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..d3bc6fd96b6d19 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,54 @@ 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 {Module} parent - 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); + + 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; + + 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 +1439,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 +1512,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 +1532,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..fd0cd5d4815bbb --- /dev/null +++ b/lib/internal/modules/package_map.js @@ -0,0 +1,267 @@ +'use strict'; + +const { + JSONParse, + ObjectEntries, + SafeMap, + StringPrototypeIndexOf, + StringPrototypeSlice, +} = primordials; + +const assert = require('internal/assert'); +const { getLazy } = require('internal/util'); +const { resolve: pathResolve, dirname } = require('path'); + +const getPackageMapPath = getLazy(() => + require('internal/options').getOptionValue('--experimental-package-map'), +); +const fs = require('fs'); + +const { + ERR_PACKAGE_MAP_EXTERNAL_FILE, + 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 {Map} dependencies - Map from bare specifier to package key + */ + +class PackageMap { + /** @type {string} */ + #configPath; + /** @type {string} */ + #basePath; + /** @type {Map} */ + #packages; + /** @type {Map} */ + #pathToKey; + /** @type {Map} */ + #pathToKeyCache; + + /** + * @param {string} configPath + * @param {object} data + */ + constructor(configPath, data) { + this.#configPath = configPath; + this.#basePath = dirname(configPath); + this.#packages = new SafeMap(); + this.#pathToKey = new SafeMap(); + this.#pathToKeyCache = 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 SafeMap(ObjectEntries(entry.dependencies ?? {})), + }); + + // 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`, + ); + } + + this.#pathToKey.set(absolutePath, 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 (true) { + const key = this.#pathToKey.get(checkPath); + if (key !== undefined) { + 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 + * 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} + */ + resolve(specifier, parentPath) { + const parentKey = this.#getKeyForPath(parentPath); + + if (parentKey === null) { + throw new ERR_PACKAGE_MAP_EXTERNAL_FILE(specifier, parentPath, this.#configPath); + } + + const { packageName, subpath } = parsePackageName(specifier); + const parentEntry = this.#packages.get(parentKey); + + const targetKey = parentEntry.dependencies.get(packageName); + if (targetKey === undefined) { + // Package not in parent's dependencies - let the caller throw the appropriate error + return undefined; + } + + const targetEntry = this.#packages.get(targetKey); + if (!targetEntry) { + throw new ERR_PACKAGE_MAP_KEY_NOT_FOUND(targetKey, this.#configPath); + } + + return { packagePath: targetEntry.path, subpath }; + } +} + +/** + * 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..8c9f2fa0b370ec --- /dev/null +++ b/test/es-module/test-esm-package-map.mjs @@ -0,0 +1,300 @@ +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'); + +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/); + }); + }); + + // =========== Fallback Behavior =========== + + describe('resolution boundaries', () => { + 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('throws 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 + + assert.notStrictEqual(code, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_EXTERNAL_FILE/); + }); + }); + + // =========== 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/); + }); + + 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 =========== + + 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/); + }); + }); + + // =========== 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/); + }); + }); + + // =========== 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', () => { + 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('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 with ERR_MODULE_NOT_FOUND. + 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_MODULE_NOT_FOUND/); + }); + }); +}); 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/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/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..260543aab9eb40 --- /dev/null +++ b/test/fixtures/package-map/nested-project/package-map-external-deps.json @@ -0,0 +1,12 @@ +{ + "packages": { + "app": { + "path": "./src", + "dependencies": {"dep-a": "dep-a"} + }, + "dep-a": { + "path": "../dep-a", + "dependencies": {} + } + } +} 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 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-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-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-longest-path.json b/test/fixtures/package-map/package-map-longest-path.json new file mode 100644 index 00000000000000..463783a77ee379 --- /dev/null +++ b/test/fixtures/package-map/package-map-longest-path.json @@ -0,0 +1,16 @@ +{ + "packages": { + "root": { + "path": "./root", + "dependencies": {"inner": "inner"} + }, + "inner": { + "path": "./root/node_modules/inner", + "dependencies": {"dep-a": "dep-a"} + }, + "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 new file mode 100644 index 00000000000000..f6b262c15627c1 --- /dev/null +++ b/test/fixtures/package-map/package-map-missing-dep.json @@ -0,0 +1,8 @@ +{ + "packages": { + "root": { + "path": "./root", + "dependencies": {"nonexistent": "nonexistent-key"} + } + } +} 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..c4a9c62e56aa0a --- /dev/null +++ b/test/fixtures/package-map/package-map-path-prefix.json @@ -0,0 +1,12 @@ +{ + "packages": { + "pkg": { + "path": "./pkg", + "dependencies": {} + }, + "pkg-other": { + "path": "./pkg-other", + "dependencies": {"pkg": "pkg"} + } + } +} 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..f4362a94bd8a26 --- /dev/null +++ b/test/fixtures/package-map/package-map-version-fork.json @@ -0,0 +1,22 @@ +{ + "packages": { + "root": { + "path": "./root", + "dependencies": {"app-18": "app-18", "app-19": "app-19"} + }, + "app-18": { + "path": "./app-18", + "dependencies": {"react": "react@18"} + }, + "app-19": { + "path": "./app-19", + "dependencies": {"react": "react@19"} + }, + "react@18": { + "path": "./react-18" + }, + "react@19": { + "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..7acececd7ffbab --- /dev/null +++ b/test/fixtures/package-map/package-map.json @@ -0,0 +1,20 @@ +{ + "packages": { + "root": { + "path": "./root", + "dependencies": {"dep-a": "dep-a", "dep-b": "dep-b"} + }, + "dep-a": { + "path": "./dep-a", + "dependencies": {} + }, + "dep-b": { + "path": "./dep-b", + "dependencies": {"dep-a": "dep-a"} + }, + "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/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/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..b593e6bad35fb9 --- /dev/null +++ b/test/parallel/test-package-map-cli.js @@ -0,0 +1,103 @@ +'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 onlyIfNodeOptionsSupport = { + skip: process.config.variables.node_without_node_options, +}; + +describe('--experimental-package-map CLI behavior', () => { + + it('works via NODE_OPTIONS', onlyIfNodeOptionsSupport, () => { + 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=${JSON.stringify(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..15474ba40e8a9c --- /dev/null +++ b/test/parallel/test-require-package-map.js @@ -0,0 +1,260 @@ +'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('resolution boundaries', () => { + 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('throws when parent not in map', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--experimental-package-map', packageMapPath, + '-e', + `require('dep-a');`, + ], { + cwd: '/tmp', + encoding: 'utf8', + }); + + assert.notStrictEqual(status, 0); + assert.match(stderr, /ERR_PACKAGE_MAP_EXTERNAL_FILE/); + }); + }); + + 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/); + }); + + 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', () => { + 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/); + }); + }); + + 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('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'); + + 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('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 with MODULE_NOT_FOUND. + 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, /Cannot find module/); + }); + }); +});