Files
node/lib/internal/modules/package_json_reader.js
Marco Ippolito 2052d0dcdf module: add --experimental-strip-private-modules
Signed-off-by: Marco Ippolito <marcoippolito54@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/63869
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com>
2026-10-19 13:03:51 +00:00

458 lines
15 KiB
JavaScript

'use strict';
const {
ArrayIsArray,
JSONParse,
ObjectDefineProperty,
RegExpPrototypeExec,
SafeMap,
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeStartsWith,
} = primordials;
const {
fileURLToPath,
isURL,
pathToFileURL,
URL,
} = require('internal/url');
const { canParse: URLCanParse } = internalBinding('url');
const {
codes: {
ERR_INVALID_MODULE_SPECIFIER,
ERR_MISSING_ARGS,
ERR_MODULE_NOT_FOUND,
},
} = require('internal/errors');
const { kEmptyObject, isWindows } = require('internal/util');
const permission = require('internal/process/permission');
const path = require('path');
const { validateString } = require('internal/validators');
const {
cleanForVfsPrefix,
loaderMethods,
} = require('internal/modules/helpers');
/**
* @typedef {import('typings/internalBinding/modules').DeserializedPackageConfig} DeserializedPackageConfig
* @typedef {import('typings/internalBinding/modules').PackageConfig} PackageConfig
* @typedef {import('typings/internalBinding/modules').SerializedPackageConfig} SerializedPackageConfig
*/
/**
* @param {URL['pathname']} path
* @param {SerializedPackageConfig} contents
* @returns {DeserializedPackageConfig}
*/
function deserializePackageJSON(path, contents) {
if (contents === undefined) {
return {
data: {
__proto__: null,
type: 'none', // Ignore unknown types for forwards compatibility
},
exists: false,
path,
};
}
const {
0: name,
1: main,
2: type,
3: plainImports,
4: plainExports,
5: optionalFilePath,
6: isPrivate,
} = contents;
const pjsonPath = optionalFilePath ?? path;
const data = {
__proto__: null,
...(name != null && { name }),
...(main != null && { main }),
...(type != null && { type }),
...(isPrivate != null && { private: isPrivate }),
};
if (plainExports !== null) {
ObjectDefineProperty(data, 'exports', {
__proto__: null,
configurable: true,
enumerable: true,
get() {
const value = requiresJSONParse(plainExports) ? JSONParse(plainExports) : plainExports;
ObjectDefineProperty(data, 'exports', { __proto__: null, enumerable: true, value });
return value;
},
});
}
if (plainImports !== null) {
ObjectDefineProperty(data, 'imports', {
__proto__: null,
configurable: true,
enumerable: true,
get() {
const value = requiresJSONParse(plainImports) ? JSONParse(plainImports) : plainImports;
ObjectDefineProperty(data, 'imports', { __proto__: null, enumerable: true, value });
return value;
},
});
}
return {
data,
exists: true,
path: pjsonPath,
};
}
// The imports and exports fields can be either undefined or a string.
// - If it's a string, it's either plain string or a stringified JSON string.
// - If it's a stringified JSON string, it starts with either '[' or '{'.
const requiresJSONParse = (value) => (value !== undefined && (value[0] === '[' || value[0] === '{'));
/**
* Reads a package.json file and returns the parsed contents.
* @param {string} jsonPath
* @param {{
* base?: URL | string,
* specifier?: URL | string,
* isESM?: boolean,
* }} options
* @returns {PackageConfig}
*/
function read(jsonPath, { base, specifier, isESM } = kEmptyObject) {
// This function will be called by both CJS and ESM, so we need to make sure
// non-null attributes are converted to strings.
const parsed = loaderMethods.readPackageJSON(
jsonPath,
isESM,
base == null ? undefined : `${base}`,
specifier == null ? undefined : `${specifier}`,
);
const result = deserializePackageJSON(jsonPath, parsed);
return {
__proto__: null,
...result.data,
exists: result.exists,
pjsonPath: result.path,
};
}
/**
* A cache mapping a directory to the path of the nearest `package.json` at or
* above it (`null` when there is none). The native traversal for a module
* starts at the module's directory, so every module in a directory shares one
* entry and one native call. Used in concert with
* `deserializedPackageJSONCache`.
*/
const directoryToParentPackageJSONPathCache = new SafeMap();
/**
* When the permission model is enabled the native traversal also depends on
* the read permissions in effect at the time of the call, so results are only
* remembered per exact module path, as before.
*/
const moduleToParentPackageJSONCache = new SafeMap();
/**
* A cache mapping the path of a `package.json` file to its
* {@link DeserializedPackageConfig deserialized representation},
* as produced by {@link deserializedPackageJSONCache}. The purpose of this
* cache is to ensure that we always return the same
* {@link DeserializedPackageConfig} instance for a given `package.json`,
* which is necessary to ensure that we don't re-parse `imports` and
* `exports` redundantly.
*/
const deserializedPackageJSONCache = new SafeMap();
/**
* The directory the native nearest-parent traversal starts from for `checkPath`
* (see BindingData::NormalizePath/TraverseParent): the path itself when it has
* a trailing separator, its dirname otherwise.
* @param {string} checkPath
* @returns {string}
*/
function getTraversalStartDirectory(checkPath) {
const last = checkPath[checkPath.length - 1];
if (last === '/' || (isWindows && last === '\\')) {
return StringPrototypeSlice(checkPath, 0, -1);
}
return path.dirname(checkPath);
}
/**
* Get the nearest parent package.json file from a given path.
* Return the package.json data and the path to the package.json file, or undefined.
* @param {string} checkPath The path to start searching from.
* @returns {undefined | DeserializedPackageConfig}
*/
function getNearestParentPackageJSON(checkPath) {
const permissionEnabled = permission.isEnabled();
const cache = permissionEnabled ? moduleToParentPackageJSONCache : directoryToParentPackageJSONPathCache;
const key = permissionEnabled ? checkPath : getTraversalStartDirectory(checkPath);
let parentPackageJSONPath = cache.get(key);
if (parentPackageJSONPath === undefined) {
const result = loaderMethods.getNearestParentPackageJSON(checkPath);
if (result === undefined) {
parentPackageJSONPath = null;
} else {
const packageConfig = deserializePackageJSON(checkPath, result);
parentPackageJSONPath = packageConfig.path;
if (!deserializedPackageJSONCache.has(parentPackageJSONPath)) {
deserializedPackageJSONCache.set(parentPackageJSONPath, packageConfig);
}
}
cache.set(key, parentPackageJSONPath);
}
if (parentPackageJSONPath === null) {
// No package.json above this path: same shape as before, carrying the queried path.
return deserializePackageJSON(checkPath, undefined);
}
return deserializedPackageJSONCache.get(parentPackageJSONPath);
}
const moduleToRootPackageJSONCache = new SafeMap();
/**
* This is stricter than {@link getNearestParentPackageJSON}: nested
* `package.json` files inside a package are ignored, and a non-private package
* anywhere in the `node_modules` chain (for example a publishable package that
* bundles a private dependency) disqualifies the path. A directory under
* `node_modules` with no `package.json` (such as pnpm's `.pnpm` store) is not
* a package and is skipped.
*
* Returns the package.json data and the path to the package.json file, or undefined.
* @param {string} checkPath The path to start searching from.
* @returns {undefined | DeserializedPackageConfig}
*/
function getRootPackageJSON(checkPath) {
const packageJSONPath = moduleToRootPackageJSONCache.get(checkPath);
if (packageJSONPath !== undefined) {
return deserializedPackageJSONCache.get(packageJSONPath);
}
const result = loaderMethods.getRootPackageJSON(checkPath);
const packageConfig = deserializePackageJSON(checkPath, result);
moduleToRootPackageJSONCache.set(checkPath, packageConfig.path);
const maybeCachedPackageConfig = deserializedPackageJSONCache.get(packageConfig.path);
if (maybeCachedPackageConfig !== undefined) {
return maybeCachedPackageConfig;
}
deserializedPackageJSONCache.set(packageConfig.path, packageConfig);
return packageConfig;
}
/**
* Returns the package configuration for the given resolved URL.
* @param {URL | string} resolved - The resolved URL.
* @returns {import('typings/internalBinding/modules').PackageConfig} - The package configuration.
*/
function getPackageScopeConfig(resolved) {
const result = loaderMethods.getPackageScopeConfig(`${resolved}`);
if (ArrayIsArray(result)) {
const { data, exists, path } = deserializePackageJSON(`${resolved}`, result);
return {
__proto__: null,
...data,
exists,
pjsonPath: path,
};
}
// This means that the response is a string
// and it is the path to the package.json file
return {
__proto__: null,
pjsonPath: result,
exists: false,
type: 'none',
};
}
/**
* Returns the package type for a given URL.
* @param {URL} url - The URL to get the package type for.
* @returns {string}
*/
function getPackageType(url) {
const type = loaderMethods.getPackageType(`${url}`);
return type ?? 'none';
}
const invalidPackageNameRegEx = /^\.|%|\\/;
/**
* Parse a package name from a specifier.
* @param {string} specifier - The import specifier.
* @param {string | URL | undefined} base - The parent URL.
* @returns {object}
*/
function parsePackageName(specifier, base) {
let separatorIndex = StringPrototypeIndexOf(specifier, '/');
let validPackageName = true;
let isScoped = false;
if (specifier[0] === '@') {
isScoped = true;
if (separatorIndex === -1 || specifier.length === 0) {
validPackageName = false;
} else {
separatorIndex = StringPrototypeIndexOf(
specifier, '/', separatorIndex + 1);
}
}
const packageName = separatorIndex === -1 ?
specifier : StringPrototypeSlice(specifier, 0, separatorIndex);
// Package name cannot have leading . and cannot have percent-encoding or
// \\ separators.
if (RegExpPrototypeExec(invalidPackageNameRegEx, packageName) !== null) {
validPackageName = false;
}
if (!validPackageName) {
throw new ERR_INVALID_MODULE_SPECIFIER(
specifier, 'is not a valid package name', fileURLToPath(base));
}
const packageSubpath = '.' + (separatorIndex === -1 ? '' :
StringPrototypeSlice(specifier, separatorIndex));
return { packageName, packageSubpath, isScoped };
}
function getPackageJSONURL(specifier, base) {
const { packageName, packageSubpath, isScoped } = parsePackageName(specifier, base);
// ResolveSelf
const packageConfig = getPackageScopeConfig(base);
if (packageConfig.exists) {
if (packageConfig.exports != null && packageConfig.name === packageName) {
const packageJSONPath = packageConfig.pjsonPath;
return { packageJSONUrl: pathToFileURL(packageJSONPath), packageJSONPath, packageSubpath };
}
}
let packageJSONUrl = new URL(`./node_modules/${packageName}/package.json`, base);
let packageJSONPath = fileURLToPath(packageJSONUrl);
// The walk stops at the resolution root (e.g. a VFS mount point)
// instead of continuing to the file system root.
const resolutionRoot = loaderMethods.getResolutionRoot(packageJSONPath);
const resolutionRootPrefix =
resolutionRoot === undefined ? undefined : resolutionRoot + path.sep;
let lastPath;
do {
const stat = loaderMethods.internalModuleStat(
StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13),
);
// Check for !stat.isDirectory()
if (stat !== 1) {
lastPath = packageJSONPath;
packageJSONUrl = new URL(
`${isScoped ? '../' : ''}../../../node_modules/${packageName}/package.json`,
packageJSONUrl,
);
packageJSONPath = fileURLToPath(packageJSONUrl);
if (resolutionRootPrefix !== undefined &&
!StringPrototypeStartsWith(packageJSONPath, resolutionRootPrefix)) {
break;
}
continue;
}
// Package match.
return { packageJSONUrl, packageJSONPath, packageSubpath };
} while (packageJSONPath.length !== lastPath.length);
throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
}
/** @type {import('./esm/resolve.js').defaultResolve} */
let defaultResolve;
/**
* @param {string | URL} specifier The location for which to get the "root" package.json
* @param {string | URL} [base] The location of the current module (ex file://tmp/foo.js).
* @returns {string}
*/
function findPackageJSON(specifier, base = 'data:') {
if (arguments.length === 0) {
throw new ERR_MISSING_ARGS('specifier');
}
try {
specifier = `${specifier}`;
} catch {
validateString(specifier, 'specifier');
}
let parentURL = base;
if (!isURL(base)) {
validateString(base, 'base');
parentURL = path.isAbsolute(base) ? pathToFileURL(base) : new URL(base);
}
if (specifier && specifier[0] !== '.' && specifier[0] !== '/' && !URLCanParse(specifier)) {
// If `specifier` is a bare specifier.
const { packageJSONPath } = getPackageJSONURL(specifier, parentURL);
return packageJSONPath;
}
let resolvedTarget;
defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;
try {
// TODO(@JakobJingleheimer): Detect whether findPackageJSON is being used within a loader
// (possibly piggyback on `isForAsyncLoaderHookWorker` from the loader?) and if so:
// - When inside, use the default resolve
// - (I think it's impossible to use the chain because of re-entry & a deadlock from atomics).
// - When outside, use cascadedLoader.resolveSync (not implemented yet, but the pieces exist).
resolvedTarget = defaultResolve(specifier, { parentURL: `${parentURL}` }).url;
} catch (err) {
if (err.code === 'ERR_UNSUPPORTED_DIR_IMPORT') {
resolvedTarget = err.url;
} else {
throw err;
}
}
const pkg = getNearestParentPackageJSON(fileURLToPath(resolvedTarget));
return pkg?.path;
}
/**
* Drop package.json-cache entries under an unmounted VFS mount point.
* Called by internal/vfs/setup.js. A module path and its owning
* package.json path always live in the same mount (the pjson search
* walks up from the module and stops at the mount root), so a single
* key test on each map is exact.
* @param {string} mountPoint Absolute mount-point path
*/
function purgePackageJSONCacheForPrefix(mountPoint) {
cleanForVfsPrefix(directoryToParentPackageJSONPathCache, mountPoint);
cleanForVfsPrefix(moduleToParentPackageJSONCache, mountPoint);
cleanForVfsPrefix(deserializedPackageJSONCache, mountPoint);
}
module.exports = {
read,
getNearestParentPackageJSON,
getRootPackageJSON,
getPackageScopeConfig,
getPackageType,
getPackageJSONURL,
findPackageJSON,
purgePackageJSONCacheForPrefix,
};