diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index 6c6bf0383bc3..c33cf166f53b 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -23,7 +23,8 @@ const { ERR_MODULE_NOT_FOUND, }, } = require('internal/errors'); -const { kEmptyObject } = require('internal/util'); +const { kEmptyObject, isWindows } = require('internal/util'); +const permission = require('internal/process/permission'); const modulesBinding = internalBinding('modules'); const path = require('path'); const { validateString } = require('internal/validators'); @@ -140,10 +141,18 @@ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { } /** - * A cache mapping a module's path to its parent `package.json` file's path. - * This is used in concert with `deserializedPackageJSONCache` to improve - * the performance of `getNearestParentPackageJSON` when called repeatedly - * on the same module paths. + * 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(); @@ -158,6 +167,21 @@ const moduleToParentPackageJSONCache = new SafeMap(); */ 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. @@ -165,23 +189,29 @@ const deserializedPackageJSONCache = new SafeMap(); * @returns {undefined | DeserializedPackageConfig} */ function getNearestParentPackageJSON(checkPath) { - const parentPackageJSONPath = moduleToParentPackageJSONCache.get(checkPath); - if (parentPackageJSONPath !== undefined) { - return deserializedPackageJSONCache.get(parentPackageJSONPath); + 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 = modulesBinding.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); } - const result = modulesBinding.getNearestParentPackageJSON(checkPath); - const packageConfig = deserializePackageJSON(checkPath, result); - - moduleToParentPackageJSONCache.set(checkPath, packageConfig.path); - - const maybeCachedPackageConfig = deserializedPackageJSONCache.get(packageConfig.path); - if (maybeCachedPackageConfig !== undefined) { - return maybeCachedPackageConfig; + if (parentPackageJSONPath === null) { + // No package.json above this path: same shape as before, carrying the queried path. + return deserializePackageJSON(checkPath, undefined); } - - deserializedPackageJSONCache.set(packageConfig.path, packageConfig); - return packageConfig; + return deserializedPackageJSONCache.get(parentPackageJSONPath); } /** diff --git a/test/parallel/test-module-nearest-parent-package-json-cache.js b/test/parallel/test-module-nearest-parent-package-json-cache.js new file mode 100644 index 000000000000..3d818c9fe1d9 --- /dev/null +++ b/test/parallel/test-module-nearest-parent-package-json-cache.js @@ -0,0 +1,57 @@ +'use strict'; +// Flags: --expose-internals +// The nearest parent package.json lookup that every CommonJS module load +// performs is answered once per directory, not once per file. +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { internalBinding } = require('internal/test/binding'); +const packageJsonReader = require('internal/modules/package_json_reader'); + +tmpdir.refresh(); +const root = tmpdir.resolve('pkg'); +const sub = path.join(root, 'lib', 'sub'); +fs.mkdirSync(sub, { recursive: true }); +fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'pkg', type: 'commonjs' })); +const files = []; +for (const dir of [path.join(root, 'lib'), sub]) { + for (let i = 0; i < 5; i++) { + const file = path.join(dir, `m${i}.js`); + fs.writeFileSync(file, 'module.exports = __filename;'); + files.push(file); + } +} + +const modulesBinding = internalBinding('modules'); +const original = modulesBinding.getNearestParentPackageJSON; +const calls = []; +modulesBinding.getNearestParentPackageJSON = common.mustCallAtLeast((checkPath) => { + calls.push(checkPath); + return original(checkPath); +}, 1); + +for (const file of files) { + assert.strictEqual(require(file), file); +} +// Ten modules in two directories: two lookups reach the binding. +assert.strictEqual(calls.length, 2, `binding called for: ${calls.join(', ')}`); + +// Same answer (and the same object) for every file of a directory, and for +// the directory itself when asked with a trailing separator. +const viaFile = packageJsonReader.getNearestParentPackageJSON(files[0]); +assert.strictEqual(viaFile.data.name, 'pkg'); +assert.strictEqual(packageJsonReader.getNearestParentPackageJSON(files[1]), viaFile); +assert.strictEqual(packageJsonReader.getNearestParentPackageJSON(path.join(root, 'lib') + path.sep), viaFile); +assert.strictEqual(calls.length, 2); + +// A directory that has not been seen yet is looked up once more. +const other = path.join(root, 'other'); +fs.mkdirSync(other); +fs.writeFileSync(path.join(other, 'x.js'), ''); +assert.strictEqual(packageJsonReader.getNearestParentPackageJSON(path.join(other, 'x.js')).data.name, 'pkg'); +assert.strictEqual(packageJsonReader.getNearestParentPackageJSON(path.join(other, 'y.js')).data.name, 'pkg'); +assert.strictEqual(calls.length, 3); + +modulesBinding.getNearestParentPackageJSON = original;