diff --git a/benchmark/module/module-require-exports.js b/benchmark/module/module-require-exports.js new file mode 100644 index 000000000000..0ec41c1a1d89 --- /dev/null +++ b/benchmark/module/module-require-exports.js @@ -0,0 +1,43 @@ +'use strict'; + +// require() of a package that is already loaded, from a directory other than +// the one that first loaded it, with and without package.json "exports". + +const fs = require('fs'); +const path = require('path'); +const { createRequire } = require('module'); +const common = require('../common.js'); +const tmpdir = require('../../test/common/tmpdir'); + +const bench = common.createBenchmark(main, { + exports: ['true', 'false'], + n: [1e5], +}); + +function main({ exports, n }) { + tmpdir.refresh(); + const root = tmpdir.resolve('nodejs-benchmark-module-exports'); + const pkgDir = path.join(root, 'node_modules', 'pkg'); + const consumerDir = path.join(root, 'node_modules', 'consumer'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.mkdirSync(consumerDir); + + const manifest = { name: 'pkg', main: 'index.js' }; + if (exports === 'true') { + manifest.exports = './index.js'; + } + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(pkgDir, 'index.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(consumerDir, 'index.js'), + 'module.exports = () => require("pkg");'); + + const rootRequire = createRequire(path.join(root, 'index.js')); + rootRequire('pkg'); + const load = rootRequire('consumer'); + + bench.start(); + for (let i = 0; i < n; i++) { + load(); + } + bench.end(n); +} diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 2869abba8690..ea1c9192db9a 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -230,6 +230,11 @@ const { tracingChannel } = require('diagnostics_channel'); const onRequire = getLazy(() => tracingChannel('module.require')); const relativeResolveCache = new SafeMap(); +// Resolutions through package.json "exports" depend on the conditions, which the +// Module._pathCache key leaves out, so they are cached here for the default +// conditions only. +const exportsPathCache = new SafeMap(); +let exportsPathCacheOwner = null; let requireDepth = 0; let isPreloading = false; @@ -299,6 +304,7 @@ function purgeModuleCachesForPrefix(mountPoint) { cleanForVfsPrefix(Module._cache, mountPoint); // Module._pathCache keys are `request\0parent` tokens; match on value. cleanForVfsPrefix(Module._pathCache, mountPoint, true); + cleanForVfsPrefix(exportsPathCache, mountPoint, true); if (statCache !== null) { cleanForVfsPrefix(statCache, mountPoint); } } @@ -795,6 +801,21 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition return entry; } + // Only bare specifiers can resolve through "exports". + const cacheExports = !absoluteRequest && !isRelative(request) && + conditions === getCjsConditions(); + if (cacheExports) { + // Replacing Module._pathCache is how callers force a fresh resolution. + if (exportsPathCacheOwner !== Module._pathCache) { + exportsPathCache.clear(); + exportsPathCacheOwner = Module._pathCache; + } + const exportsEntry = exportsPathCache.get(cacheKey); + if (exportsEntry !== undefined) { + return exportsEntry; + } + } + let exts; const trailingSlash = request.length > 0 && (StringPrototypeCharCodeAt(request, request.length - 1) === CHAR_FORWARD_SLASH || ( @@ -831,6 +852,9 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition if (!absoluteRequest) { const exportsResolved = resolveExports(curPath, request, conditions); if (exportsResolved) { + if (cacheExports) { + exportsPathCache.set(cacheKey, exportsResolved); + } return exportsResolved; } } diff --git a/test/parallel/test-module-exports-resolution-cache.js b/test/parallel/test-module-exports-resolution-cache.js new file mode 100644 index 000000000000..939122052ad0 --- /dev/null +++ b/test/parallel/test-module-exports-resolution-cache.js @@ -0,0 +1,60 @@ +'use strict'; + +// Checks the cache for packages resolved through package.json "exports" in +// Module._findPath(): it must not serve other conditions, and replacing +// Module._pathCache must still force a fresh resolution. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); + +function writePackage(dir, files) { + fs.mkdirSync(dir, { recursive: true }); + for (const [name, contents] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), contents); + } +} + +writePackage(tmpdir.resolve('node_modules', 'pkg'), { + 'package.json': JSON.stringify({ + name: 'pkg', + exports: { custom: './custom.js', default: './default.js' }, + }), + 'custom.js': '', + 'default.js': '', +}); + +// Other conditions are resolved, not served the cached default result. +{ + const paths = Module._nodeModulePaths(tmpdir.path); + const defaultTarget = tmpdir.resolve('node_modules', 'pkg', 'default.js'); + assert.strictEqual(Module._findPath('pkg', paths), defaultTarget); + assert.strictEqual(Module._findPath('pkg', paths), defaultTarget); + assert.strictEqual( + Module._findPath('pkg', paths, false, new Set(['custom', 'require', 'node'])), + tmpdir.resolve('node_modules', 'pkg', 'custom.js'), + ); +} + +// A package installed closer to the parent is found once Module._pathCache +// has been replaced. +{ + const dir = tmpdir.resolve('nested'); + fs.mkdirSync(dir); + const paths = Module._nodeModulePaths(dir); + assert.strictEqual(Module._findPath('pkg', paths), + tmpdir.resolve('node_modules', 'pkg', 'default.js')); + + writePackage(path.join(dir, 'node_modules', 'pkg'), { + 'package.json': JSON.stringify({ name: 'pkg', exports: './nearer.js' }), + 'nearer.js': '', + }); + Module._pathCache = { __proto__: null }; + assert.strictEqual(Module._findPath('pkg', paths), + path.join(dir, 'node_modules', 'pkg', 'nearer.js')); +}