diff --git a/benchmark/module/package-scope-config.js b/benchmark/module/package-scope-config.js new file mode 100644 index 000000000000..e07f15cbb6d7 --- /dev/null +++ b/benchmark/module/package-scope-config.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common'); +const fs = require('fs'); +const { pathToFileURL } = require('url'); +const tmpdir = require('../../test/common/tmpdir'); + +const bench = common.createBenchmark(main, { + entries: [0, 20, 200, 1000], + n: [1e4], +}, { flags: ['--expose-internals'] }); + +function main({ entries, n }) { + const { getPackageScopeConfig } = require('internal/modules/package_json_reader'); + tmpdir.refresh(); + const imports = {}; + const exports = {}; + for (let i = 0; i < entries; i++) { + imports[`#entry${i}`] = `./entry${i}.js`; + exports[`./entry${i}`] = `./entry${i}.js`; + } + fs.writeFileSync(tmpdir.resolve('package.json'), JSON.stringify({ + type: 'module', imports, exports, + })); + const urls = Array.from({ length: 100 }, (_, i) => + pathToFileURL(tmpdir.resolve(`entry${i}.js`)).href); + // Warm the package cache while looking up distinct modules in one scope. + for (const url of urls) getPackageScopeConfig(url); + + bench.start(); + for (let i = 0; i < n; i++) { + getPackageScopeConfig(urls[i % urls.length]); + } + bench.end(n); +} diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index ea6b7d26395e..758c104ceddb 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -170,6 +170,10 @@ const moduleToParentPackageJSONCache = new SafeMap(); */ const deserializedPackageJSONCache = new SafeMap(); +// Keep the serialized fields as well: unlike the native package cache, VFS +// lookups can return updated contents for the same package.json path. +const packageScopeConfigCache = new SafeMap(); + /** * The directory the native nearest-parent traversal starts from for `checkPath` * (see BindingData::NormalizePath/TraverseParent): the path itself when it has @@ -226,7 +230,22 @@ function getPackageScopeConfig(resolved) { const result = loaderMethods.getPackageScopeConfig(`${resolved}`); if (ArrayIsArray(result)) { - const { data, exists, path } = deserializePackageJSON(`${resolved}`, result); + const pjsonPath = result[5]; + let cached = packageScopeConfigCache.get(pjsonPath); + if (cached === undefined || + cached.serialized[0] !== result[0] || + cached.serialized[1] !== result[1] || + cached.serialized[2] !== result[2] || + cached.serialized[3] !== result[3] || + cached.serialized[4] !== result[4]) { + cached = { + __proto__: null, + serialized: result, + deserialized: deserializePackageJSON(`${resolved}`, result), + }; + packageScopeConfigCache.set(pjsonPath, cached); + } + const { data, exists, path } = cached.deserialized; return { __proto__: null, @@ -407,6 +426,7 @@ function purgePackageJSONCacheForPrefix(mountPoint) { cleanForVfsPrefix(directoryToParentPackageJSONPathCache, mountPoint); cleanForVfsPrefix(moduleToParentPackageJSONCache, mountPoint); cleanForVfsPrefix(deserializedPackageJSONCache, mountPoint); + cleanForVfsPrefix(packageScopeConfigCache, mountPoint); } module.exports = { diff --git a/test/parallel/test-package-scope-config-cache.js b/test/parallel/test-package-scope-config-cache.js new file mode 100644 index 000000000000..cf0f7e242784 --- /dev/null +++ b/test/parallel/test-package-scope-config-cache.js @@ -0,0 +1,56 @@ +// Flags: --expose-internals +'use strict'; + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const tmpdir = require('../common/tmpdir'); +const { getPackageScopeConfig } = require('internal/modules/package_json_reader'); + +tmpdir.refresh(); +const packageConfig = { + name: 'scope-cache', + type: 'module', + exports: { '.': './index.js' }, + imports: { '#dep': './dep.js' }, +}; +fs.writeFileSync(tmpdir.resolve('package.json'), JSON.stringify(packageConfig)); +fs.mkdirSync(tmpdir.resolve('nested')); +fs.writeFileSync(tmpdir.resolve('nested', 'package.json'), JSON.stringify({ + type: 'commonjs', + exports: ['./other.js'], +})); + +const first = getPackageScopeConfig(pathToFileURL(tmpdir.resolve('index.js'))); +const second = getPackageScopeConfig(pathToFileURL(tmpdir.resolve('dep.js')).href); +assert.strictEqual(first.pjsonPath, + path.toNamespacedPath(tmpdir.resolve('package.json'))); +assert.strictEqual(first.type, 'module'); +assert.deepStrictEqual(first.exports, packageConfig.exports); +assert.deepStrictEqual(first.imports, packageConfig.imports); +assert.strictEqual(second.exports, first.exports); +assert.strictEqual(second.imports, first.imports); + +// Each caller still receives its own configuration wrapper. +first.type = 'commonjs'; +assert.strictEqual(second.type, 'module'); +assert.strictEqual(getPackageScopeConfig( + pathToFileURL(tmpdir.resolve('third.js'))).type, 'module'); + +const nested = getPackageScopeConfig(pathToFileURL(tmpdir.resolve('nested', 'index.js'))); +assert.strictEqual(nested.pjsonPath, + path.toNamespacedPath(tmpdir.resolve('nested', 'package.json'))); +assert.strictEqual(nested.type, 'commonjs'); +assert.deepStrictEqual(nested.exports, ['./other.js']); +assert.strictEqual(nested.imports, undefined); + +// Package scope traversal must still stop at node_modules. +fs.mkdirSync(tmpdir.resolve('node_modules', 'bare'), { recursive: true }); +const missing = getPackageScopeConfig( + pathToFileURL(tmpdir.resolve('node_modules', 'bare', 'index.js'))); +assert.strictEqual(missing.exists, false); +assert.strictEqual(missing.type, 'none'); +assert.strictEqual(missing.pjsonPath, + path.join(tmpdir.path, 'node_modules', 'package.json')); diff --git a/test/parallel/test-vfs-package-scope-config-cache.js b/test/parallel/test-vfs-package-scope-config-cache.js new file mode 100644 index 000000000000..6d433146f16d --- /dev/null +++ b/test/parallel/test-vfs-package-scope-config-cache.js @@ -0,0 +1,73 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +require('../common'); +const assert = require('assert'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const vfs = require('node:vfs'); +const { getPackageScopeConfig } = require('internal/modules/package_json_reader'); + +const volume = vfs.create(); +volume.mkdirSync('/pkg'); +const mountPoint = volume.mount(); +const packagePath = path.join(mountPoint, 'pkg', 'package.json'); +const url = pathToFileURL(path.join(mountPoint, 'pkg', 'index.js')); +const config = { + name: 'before', + main: './index.js', + type: 'module', + imports: { '#dep': './dep.js' }, + exports: { '.': './index.js' }, +}; + +function readConfig() { + volume.writeFileSync(packagePath, JSON.stringify(config)); + const result = getPackageScopeConfig(url); + for (const key of Object.keys(config)) { + assert.deepStrictEqual(result[key], config[key]); + } + assert.strictEqual(result.exists, true); + assert.strictEqual(result.pjsonPath, path.join(mountPoint, 'pkg', 'package.json')); + return result; +} + +try { + const first = readConfig(); + assert.strictEqual(getPackageScopeConfig(url).exports, first.exports); + + // Invalidate on each serialized field independently, including fields that + // are not used to resolve package imports. + for (const [key, value] of Object.entries({ + name: 'after', + main: './other.js', + type: 'commonjs', + imports: { '#dep': './other.js' }, + exports: ['./other.js'], + })) { + config[key] = value; + readConfig(); + } + + const beforePurge = getPackageScopeConfig(url); + volume.unmount(); + assert.strictEqual(volume.mount(), mountPoint); + const afterPurge = getPackageScopeConfig(url); + assert.deepStrictEqual(afterPurge, beforePurge); + assert.notStrictEqual(afterPurge.exports, beforePurge.exports); + + config.exports = './direct.js'; + readConfig(); + delete config.imports; + assert.strictEqual(readConfig().imports, undefined); + delete config.exports; + assert.strictEqual(readConfig().exports, undefined); + + // A warm cache must not hide parse errors or a removed package.json. + volume.writeFileSync(packagePath, '{'); + assert.throws(() => getPackageScopeConfig(url), { code: 'ERR_INVALID_PACKAGE_CONFIG' }); + volume.unlinkSync(packagePath); + assert.strictEqual(getPackageScopeConfig(url).exists, false); +} finally { + volume.unmount(); +}