From feee20189c3cb49b184bec7a46b9eb5d6c87214e Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sat, 15 Aug 2026 23:55:08 +0000 Subject: [PATCH] module: cache nearest parent package.json per directory getNearestParentPackageJSON() memoized its answer per file, so every module loaded still made one native call, and TraverseParent() builds several std::filesystem::path temporaries per directory level and serializes the whole package.json, which the JS side then usually discarded because it already had that package.json deserialized. The native traversal starts at the directory of the given path, so the answer only depends on that directory: key the memo by it (following NormalizePath()'s trailing-separator rule), so that all modules in a directory share one native call. When the permission model is enabled the traversal also depends on the read permissions in effect at call time, so that configuration keeps the per-file cache. Loading a 1000-module tree spread over ~240 directories goes from 1000 to 236 native calls. Signed-off-by: Shelley Vohr --- lib/internal/modules/package_json_reader.js | 68 +++++++++++++------ ...odule-nearest-parent-package-json-cache.js | 57 ++++++++++++++++ 2 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 test/parallel/test-module-nearest-parent-package-json-cache.js 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;