node/lib/internal/modules/package_map.js
Maël Nison 2f2b81095b
loader: implement package maps
Signed-off-by: Mael Nison <nison.mael@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/62239
Reviewed-By: Aviv Keller <me@aviv.sh>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
2026-06-12 16:02:01 +00:00

309 lines
8.9 KiB
JavaScript

'use strict';
const {
JSONParse,
ObjectEntries,
SafeMap,
StringPrototypeIndexOf,
StringPrototypeSlice,
} = primordials;
const assert = require('internal/assert');
const { emitExperimentalWarning, getLazy, setOwnProperty } = require('internal/util');
const { dirname, resolve: pathResolve } = require('path');
const { fileURLToPath, pathToFileURL, URL } = require('internal/url');
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;
/**
* @typedef {object} PackageMapEntry
* @property {string} path - Absolute decoded path to package on disk
* @property {Map<string, string>} dependencies - Map from bare specifier to package key
*/
class PackageMap {
/** @type {string} */
#configPath;
/** @type {URL} */
#baseURL;
/** @type {Map<string, PackageMapEntry>} */
#packages;
/** @type {Map<string, string>} */
#pathToKey;
/** @type {Map<string, string|null>} */
#pathToKeyCache;
/**
* @param {string} configPath
* @param {object} data
*/
constructor(configPath, data) {
this.#configPath = configPath;
this.#baseURL = new URL('./', pathToFileURL(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');
}
const entries = ObjectEntries(data.packages);
for (let i = 0; i < entries.length; i++) {
const { 0: key, 1: entry } = entries[i];
const { url } = entry;
switch (typeof url) {
case 'string':
if (url === '') {
throw new ERR_PACKAGE_MAP_INVALID(this.#configPath, `package "${key}" has an empty "url" field`);
}
break;
case 'undefined':
throw new ERR_PACKAGE_MAP_INVALID(this.#configPath, `package "${key}" is missing "url" field`);
default:
throw new ERR_PACKAGE_MAP_INVALID(this.#configPath, `package "${key}" has invalid "url" field: expected string, got ${typeof url}`);
}
let packageURL;
try {
packageURL = new URL(entry.url, this.#baseURL);
} catch (err) {
const error = new ERR_PACKAGE_MAP_INVALID(
this.#configPath,
`package "${key}" has invalid url "${entry.url}"`,
);
setOwnProperty(error, 'cause', err);
throw error;
}
if (packageURL.protocol !== 'file:') {
throw new ERR_PACKAGE_MAP_INVALID(
this.#configPath,
`package "${key}" has unsupported URL scheme in url "${entry.url}"; only file: URLs and relative URLs are currently supported`,
);
}
let absolutePath;
try {
absolutePath = fileURLToPath(packageURL);
} catch (err) {
const error = new ERR_PACKAGE_MAP_INVALID(
this.#configPath,
`package "${key}" has invalid file url "${entry.url}"`,
);
setOwnProperty(error, 'cause', err);
throw error;
}
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.
const existingKey = this.#pathToKey.get(absolutePath);
if (existingKey != null) {
throw new ERR_PACKAGE_MAP_INVALID(
this.#configPath,
`packages "${existingKey}" and "${key}" have the same url "${entry.url}"; 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);
} catch (err) {
const error = new ERR_PACKAGE_MAP_INVALID(configPath, 'failed to read');
setOwnProperty(error, 'cause', err);
throw error;
}
// Resolve symlinks so that stored paths match the realpath'd module paths
// that Node.js uses during resolution.
configPath = fs.realpathSync(configPath);
let data;
try {
data = JSONParse(content);
} catch (err) {
const error = new ERR_PACKAGE_MAP_INVALID(configPath, `invalid JSON`);
setOwnProperty(error, 'cause', err);
throw error;
}
return new PackageMap(configPath, data);
}
/**
* 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('Package maps');
try {
packageMap = loadPackageMap(pathResolve(packageMapPath));
} catch (err) {
// Fallback to an empty package map to avoid repeatedly trying
// to load the broken package map file. Subsequent resolutions
// will still fail to resolve due to the package map being empty.
packageMap = new PackageMap(packageMapPath, { packages: {} });
throw err;
}
return packageMap;
}
/**
* Check if the package map is enabled.
* @returns {boolean}
*/
function hasPackageMap() {
return !!getPackageMapPath();
}
/**
* 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,
};