Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions benchmark/module/resolve-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use strict';

// Measures the cold-start cost of resolving a dependency tree with and without
// the persistent module-resolution cache (NODE_MODULE_RESOLVE_CACHE). The
// cache benefits warm starts of fresh processes, so this benchmark spawns a
// child process per iteration.

const common = require('../common.js');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const tmpdir = require('../../test/common/tmpdir');

const bench = common.createBenchmark(main, {
files: [1000],
cache: ['true', 'false'],
n: [30],
});

function generate(dir, files) {
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true });
let app = '';
for (let i = 0; i < files; i++) {
const pkg = path.join(dir, 'node_modules', `pkg${i}`);
fs.mkdirSync(pkg);
fs.writeFileSync(path.join(pkg, 'package.json'),
`{"name":"pkg${i}","version":"1.0.0","main":"index.js"}`);
fs.writeFileSync(path.join(pkg, 'index.js'), `module.exports=${i};`);
app += `require("pkg${i}");`;
}
fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
const appFile = path.join(dir, 'app.js');
fs.writeFileSync(appFile, app);
return appFile;
}

function main({ n, files, cache }) {
tmpdir.refresh();
const dir = tmpdir.resolve('resolve-cache-bench');
const appFile = generate(dir, files);
const cmd = process.execPath || process.argv[0];

const env = { ...process.env };
if (cache === 'true') {
env.NODE_MODULE_RESOLVE_CACHE = tmpdir.resolve('resolve-cache-dir');
// Warm the cache once.
spawnSync(cmd, [appFile], { cwd: dir, env });
} else {
delete env.NODE_MODULE_RESOLVE_CACHE;
}

// Warmup.
for (let i = 0; i < 3; i++) spawnSync(cmd, [appFile], { cwd: dir, env });

bench.start();
for (let i = 0; i < n; i++) {
const child = spawnSync(cmd, [appFile], { cwd: dir, env });
if (child.status !== 0) {
throw new Error(`Child exited with ${child.status}: ${child.stderr}`);
}
}
bench.end(n);

tmpdir.refresh();
}
14 changes: 14 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -3663,6 +3663,18 @@ Enable the [module compile cache][] for the Node.js instance. See the documentat
When set to 1, the [module compile cache][] can be reused across different directory
locations as long as the module layout relative to the cache directory remains the same.

### `NODE_MODULE_RESOLVE_CACHE=dir`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Enable the [module resolution cache][] for the Node.js instance, storing it
under `dir`. See the documentation of
[`module.enableModuleResolveCache()`][] for details.

### `NODE_DEBUG=module[,…]`

<!-- YAML
Expand Down Expand Up @@ -4458,6 +4470,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options
[`import.meta.url`]: esm.md#importmetaurl
[`import` specifier]: esm.md#import-specifiers
[`module.enableModuleResolveCache()`]: module.md#moduleenablemoduleresolvecachedirectory
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: net.md#netgetdefaultautoselectfamilyattempttimeout
[`node:ffi`]: ffi.md
[`node:sqlite`]: sqlite.md
Expand Down Expand Up @@ -4486,6 +4499,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[jitless]: https://v8.dev/blog/jitless
[libuv threadpool documentation]: https://docs.libuv.org/en/latest/threadpool.html
[module compile cache]: module.md#module-compile-cache
[module resolution cache]: module.md#moduleenablemoduleresolvecachedirectory
[preloading asynchronous module customization hooks]: module.md#registration-of-asynchronous-customization-hooks
[randomizing tests execution order]: test.md#randomizing-tests-execution-order
[remote code execution]: https://www.owasp.org/index.php/Code_Injection
Expand Down
59 changes: 59 additions & 0 deletions doc/api/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,64 @@ changes:
* Returns: {string|undefined} Path to the [module compile cache][] directory if it is enabled,
or `undefined` otherwise.

### `module.enableModuleResolveCache([directory])`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

* `directory` {string} Optional. Directory to store the module resolution
cache. If not specified, the directory specified by the
[`NODE_MODULE_RESOLVE_CACHE=dir`][] environment variable will be used if it's
set, or `path.join(os.tmpdir(), 'node-module-resolve-cache')` otherwise.
* Returns: {string|undefined} The directory where the resolution cache is
stored, or `undefined` if it could not be enabled (for example, when the
[permission model][] denies access to the directory).

Enable the persistent **module resolution cache** in the current Node.js
instance.

When resolving a specifier such as `require('foo')` or `import 'foo'`, Node.js
performs a file system search across `node_modules` directories and reads
`package.json` files. The resolution cache persists the resolved file path for
each `(specifier, parent, conditions)` tuple to disk, so subsequent runs of the
same project skip the search entirely. This can substantially speed up the
startup of applications with large dependency trees.

The cache is validated coarsely with a single generation token derived from the
Node.js version and architecture, the resolution-affecting flags
(such as `--preserve-symlinks`), `NODE_PATH`, and the project's lockfile
signature (`package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`,
`pnpm-lock.yaml` or `bun.lockb`, falling back to `package.json`). When any of
these change — for example after a package manager updates the lockfile — the
whole cache is discarded and rebuilt. Because validation does not stat every
dependency, the per-process overhead is constant rather than proportional to
the number of dependencies.

Manual edits inside `node_modules` that are not reflected in the lockfile are
not detected within a generation. This matches the assumption that
`node_modules` is managed by a package manager.

Like the [module compile cache][], this method does not throw when the cache
cannot be enabled; it returns `undefined` instead.

This method only affects the current Node.js instance. To enable it in child
worker threads, either call this method there too, or set the
`process.env.NODE_MODULE_RESOLVE_CACHE` value so the behavior is inherited.

### `module.getModuleResolveCacheDir()`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

* Returns: {string|undefined} Path to the module resolution cache directory if
it is enabled, or `undefined` otherwise.

<i id="module_customization_hooks"></i>

## Customization Hooks
Expand Down Expand Up @@ -2059,6 +2117,7 @@ returned object contains the following keys:
[`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir
[`NODE_COMPILE_CACHE_PORTABLE=1`]: cli.md#node_compile_cache_portable1
[`NODE_DISABLE_COMPILE_CACHE=1`]: cli.md#node_disable_compile_cache1
[`NODE_MODULE_RESOLVE_CACHE=dir`]: cli.md#node_module_resolve_cachedir
[`NODE_V8_COVERAGE=dir`]: cli.md#node_v8_coveragedir
[`Object.freeze()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
[`SourceMap`]: #class-modulesourcemap
Expand Down
25 changes: 25 additions & 0 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ const {
initializeCjsConditions,
loadBuiltinModule,
makeRequireFunction,
resolveCacheEnabled,
resolveCacheLookup,
resolveCacheStore,
setHasStartedUserCJSExecution,
stripBOM,
toRealPath,
Expand Down Expand Up @@ -791,6 +794,21 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition
return entry;
}

// Persistent module-resolution cache (coarse-validated). The
// persistent key adds the conditions (the in-memory _pathCache key can omit
// them since conditions are fixed per process).
const useResolveCache = resolveCacheEnabled();
let persistKey;
if (useResolveCache) {
persistKey = 'c\x00' + ArrayPrototypeJoin([...conditions], ',') +
'\x00' + cacheKey;
const cached = resolveCacheLookup(persistKey);
if (cached !== undefined) {
Module._pathCache[cacheKey] = cached;
return cached;
}
}

let exts;
const trailingSlash = request.length > 0 &&
(StringPrototypeCharCodeAt(request, request.length - 1) === CHAR_FORWARD_SLASH || (
Expand Down Expand Up @@ -827,6 +845,10 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition
if (!absoluteRequest) {
const exportsResolved = resolveExports(curPath, request, conditions);
if (exportsResolved) {
// Cache exports-resolved results too.
if (useResolveCache) {
resolveCacheStore(persistKey, exportsResolved);
}
return exportsResolved;
}
}
Expand Down Expand Up @@ -877,6 +899,9 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition

if (filename) {
Module._pathCache[cacheKey] = filename;
if (useResolveCache) {
resolveCacheStore(persistKey, filename);
}
return filename;
}

Expand Down
31 changes: 30 additions & 1 deletion lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const { getOptionValue } = require('internal/options');
const { sep, posix: { relative: relativePosixPath }, resolve, join } = require('path');
const { URL, pathToFileURL, fileURLToPath, isURL, URLParse } = require('internal/url');
const { getCWDURL, setOwnProperty, kEmptyObject } = require('internal/util');
const {
resolveCacheEnabled,
resolveCacheLookup,
resolveCacheStore,
} = require('internal/modules/helpers');
const { canParse: URLCanParse } = internalBinding('url');
const { legacyMainResolve: FSLegacyMainResolve } = internalBinding('fs');
const {
Expand Down Expand Up @@ -1001,6 +1006,25 @@ function defaultResolve(specifier, context = kEmptyObject) {
}

conditions = getConditionsSet(conditions);

// Persistent module-resolution cache for ESM (coarse-validated).
const useResolveCache = resolveCacheEnabled();
let persistKey;
if (useResolveCache) {
persistKey = 'e\x00' + ArrayPrototypeJoin([...conditions], ',') +
'\x00' + specifier + '\x00' + parentURL;
const cached = resolveCacheLookup(persistKey);
if (cached !== undefined) {
const sep = StringPrototypeIndexOf(cached, '\x00');
const cachedFormat = StringPrototypeSlice(cached, sep + 1);
return {
__proto__: null,
url: StringPrototypeSlice(cached, 0, sep),
format: cachedFormat === '' ? null : cachedFormat,
};
}
}

let url;
try {
url = moduleResolve(
Expand All @@ -1022,12 +1046,17 @@ function defaultResolve(specifier, context = kEmptyObject) {
throw error;
}

const format = defaultGetFormatWithoutErrors(url, context);
if (useResolveCache) {
resolveCacheStore(persistKey, url.href + '\x00' + (format ?? ''));
}

return {
__proto__: null,
// Do NOT cast `url` to a string: that will work even when there are real
// problems, silencing them
url: url.href,
format: defaultGetFormatWithoutErrors(url, context),
format,
};
}

Expand Down
Loading
Loading