diff --git a/benchmark/module/resolve-cache.js b/benchmark/module/resolve-cache.js
new file mode 100644
index 00000000000000..b7c8726cc458f8
--- /dev/null
+++ b/benchmark/module/resolve-cache.js
@@ -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();
+}
diff --git a/doc/api/cli.md b/doc/api/cli.md
index 79f02ee5eddd86..7b309faeb2cbed 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -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`
+
+
+
+> 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[,…]`
+
+> 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()`
+
+
+
+> Stability: 1 - Experimental
+
+* Returns: {string|undefined} Path to the module resolution cache directory if
+ it is enabled, or `undefined` otherwise.
+
## Customization Hooks
@@ -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
diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js
index 219618da8a8a93..ca7127dcf78d11 100644
--- a/lib/internal/modules/cjs/loader.js
+++ b/lib/internal/modules/cjs/loader.js
@@ -165,6 +165,9 @@ const {
initializeCjsConditions,
loadBuiltinModule,
makeRequireFunction,
+ resolveCacheEnabled,
+ resolveCacheLookup,
+ resolveCacheStore,
setHasStartedUserCJSExecution,
stripBOM,
toRealPath,
@@ -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 || (
@@ -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;
}
}
@@ -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;
}
diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js
index 3916e79328ed9c..a0af64a3dd904c 100644
--- a/lib/internal/modules/esm/resolve.js
+++ b/lib/internal/modules/esm/resolve.js
@@ -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 {
@@ -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(
@@ -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,
};
}
diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js
index 839ce9af4bb678..8f8758052e046f 100644
--- a/lib/internal/modules/helpers.js
+++ b/lib/internal/modules/helpers.js
@@ -2,6 +2,8 @@
const {
ArrayPrototypeForEach,
+ MathImul,
+ NumberPrototypeToString,
ObjectDefineProperty,
ObjectFreeze,
ObjectPrototypeHasOwnProperty,
@@ -513,11 +515,120 @@ function getCompileCacheDir() {
return _getCompileCacheDir() || undefined;
}
+// Shared persistent module-resolution cache (CJS + ESM).
+//
+// Enabled via the NODE_MODULE_RESOLVE_CACHE=
environment variable or the
+// module.enableModuleResolveCache(dir) API. The on-disk cache maps resolution
+// keys to resolved filenames/URLs so that warm runs skip the candidate-stat
+// search and package.json reads. It is validated coarsely with a single
+// generation token (Node version/arch, resolution flags, NODE_PATH and the
+// project's lockfile signature) so per-process validation is O(1) rather than
+// O(dependencies). The token deliberately omits conditions; those are part of
+// the per-entry cache keys, so CJS- and ESM-entry runs share one cache file.
+const modulesBinding = internalBinding('modules');
+let resolveCacheState = -1; // -1 unchecked, 0 off, 1 on
+let resolveCacheDir; // Override set by enableModuleResolveCache()
+let resolveCacheActiveDir; // The directory actually in use
+
+function cyrb53(str) {
+ let h1 = 0xdeadbeef;
+ let h2 = 0x41c6ce57;
+ for (let i = 0; i < str.length; i++) {
+ const ch = StringPrototypeCharCodeAt(str, i);
+ h1 = MathImul(h1 ^ ch, 2654435761);
+ h2 = MathImul(h2 ^ ch, 1597334677);
+ }
+ h1 = MathImul(h1 ^ (h1 >>> 16), 2246822507) ^ MathImul(h2 ^ (h2 >>> 13), 3266489909);
+ h2 = MathImul(h2 ^ (h2 >>> 16), 2246822507) ^ MathImul(h1 ^ (h1 >>> 13), 3266489909);
+ return NumberPrototypeToString((h2 >>> 0) * 4294967296 + (h1 >>> 0), 16);
+}
+
+function computeResolveCacheToken(cwd) {
+ let lockSig = '';
+ const lockfiles = ['package-lock.json', 'npm-shrinkwrap.json',
+ 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb'];
+ for (let i = 0; i < lockfiles.length; i++) {
+ try {
+ const st = fs.statSync(path.join(cwd, lockfiles[i]));
+ lockSig += `${lockfiles[i]}:${st.mtimeMs}:${st.size};`;
+ } catch { /* not present */ }
+ }
+ if (lockSig === '') {
+ try {
+ const st = fs.statSync(path.join(cwd, 'package.json'));
+ lockSig = `package.json:${st.mtimeMs}:${st.size}`;
+ } catch { /* none */ }
+ }
+ const flags = `${getOptionValue('--preserve-symlinks')}:` +
+ `${getOptionValue('--preserve-symlinks-main')}`;
+ const nodePath = process.env.NODE_PATH || '';
+ return `${process.version}|${process.arch}|${flags}|np=${nodePath}|${lockSig}`;
+}
+
+function initResolveCache(dir) {
+ resolveCacheState = 0;
+ if (!dir) { return; }
+ try {
+ const cwd = process.cwd();
+ const token = computeResolveCacheToken(cwd);
+ const tag = `${process.version}-${process.arch}`;
+ const subdir = path.join(dir, tag);
+ fs.mkdirSync(subdir, { recursive: true });
+ const file = path.join(subdir, `${cyrb53(cwd)}.resolve.bin`);
+ modulesBinding.resolveCacheInit(token, file);
+ process.on('exit', () => { modulesBinding.flushResolveCache(); });
+ resolveCacheActiveDir = dir;
+ resolveCacheState = 1;
+ } catch {
+ resolveCacheState = 0; // e.g. permission denied creating the directory
+ }
+}
+
+function resolveCacheEnabled() {
+ if (resolveCacheState === -1) {
+ initResolveCache(resolveCacheDir ?? process.env.NODE_MODULE_RESOLVE_CACHE);
+ }
+ return resolveCacheState === 1;
+}
+
+/**
+ * Enable the persistent module-resolution cache, storing it under `dir`.
+ * Must be called before the modules whose resolution should be cached are
+ * loaded. Returns the directory in use, or undefined if it could not be
+ * enabled (e.g. the permission model denied access).
+ * @param {string} [dir]
+ * @returns {string|undefined}
+ */
+function enableModuleResolveCache(dir) {
+ resolveCacheDir = dir || process.env.NODE_MODULE_RESOLVE_CACHE ||
+ path.join(lazyTmpdir(), 'node-module-resolve-cache');
+ resolveCacheState = -1; // Force re-init on next use
+ resolveCacheEnabled();
+ return resolveCacheActiveDir;
+}
+
+function getModuleResolveCacheDir() {
+ return resolveCacheActiveDir;
+}
+
+function resolveCacheLookup(key) {
+ return modulesBinding.resolveCacheLookup(key);
+}
+
+function resolveCacheStore(key, value) {
+ modulesBinding.resolveCacheStore(key, value);
+}
+
module.exports = {
addBuiltinLibsToObject,
assertBufferSource,
constants,
enableCompileCache,
+ enableModuleResolveCache,
+ getModuleResolveCacheDir,
+ resolveCacheEnabled,
+ resolveCacheLookup,
+ resolveCacheStore,
flushCompileCache,
getBuiltinModule,
getCjsConditions,
diff --git a/lib/module.js b/lib/module.js
index 1217172afb3ccb..e534727ce79b3e 100644
--- a/lib/module.js
+++ b/lib/module.js
@@ -13,8 +13,10 @@ const {
const {
constants,
enableCompileCache,
+ enableModuleResolveCache,
flushCompileCache,
getCompileCacheDir,
+ getModuleResolveCacheDir,
} = require('internal/modules/helpers');
const {
findPackageJSON,
@@ -24,9 +26,11 @@ const { stripTypeScriptTypes } = require('internal/modules/typescript');
Module.register = register;
Module.constants = constants;
Module.enableCompileCache = enableCompileCache;
+Module.enableModuleResolveCache = enableModuleResolveCache;
Module.findPackageJSON = findPackageJSON;
Module.flushCompileCache = flushCompileCache;
Module.getCompileCacheDir = getCompileCacheDir;
+Module.getModuleResolveCacheDir = getModuleResolveCacheDir;
Module.stripTypeScriptTypes = stripTypeScriptTypes;
// SourceMap APIs
diff --git a/src/node_modules.cc b/src/node_modules.cc
index 717d84a4f89aaf..b2cbf2c5eda978 100644
--- a/src/node_modules.cc
+++ b/src/node_modules.cc
@@ -1,9 +1,12 @@
#include "node_modules.h"
+#include
#include
+#include
#include "base_object-inl.h"
#include "compile_cache.h"
#include "node_errors.h"
#include "node_external_reference.h"
+#include "node_file_utils.h"
#include "node_url.h"
#include "path.h"
#include "permission/permission.h"
@@ -39,10 +42,150 @@ using v8::String;
using v8::Undefined;
using v8::Value;
+// Binary (de)serialization helpers for the module-resolution cache.
+namespace {
+constexpr uint32_t kResolveCacheMagic = 0x4D524331; // "MRC1"
+
+void AppendU32(std::string* out, uint32_t v) {
+ out->append(reinterpret_cast(&v), sizeof(v));
+}
+void AppendStr(std::string* out, std::string_view s) {
+ AppendU32(out, static_cast(s.size()));
+ out->append(s.data(), s.size());
+}
+
+struct Reader {
+ const char* p;
+ const char* end;
+ bool U32(uint32_t* v) {
+ if (end - p < 4) return false;
+ memcpy(v, p, 4);
+ p += 4;
+ return true;
+ }
+ bool Str(std::string* s) {
+ uint32_t n;
+ if (!U32(&n) || static_cast(end - p) < n) return false;
+ s->assign(p, n);
+ p += n;
+ return true;
+ }
+};
+} // namespace
+
void BindingData::MemoryInfo(MemoryTracker* tracker) const {
// Do nothing
}
+// Persistent module-resolution cache.
+// Loaded lazily once the generation token has been supplied via
+// ResolveCacheInit. If the on-disk token differs from the current one, the
+// whole cache is discarded (coarse invalidation) and repopulated this run.
+void BindingData::MaybeInitResolveCache() {
+ // resolve_path_ and resolve_token_ are supplied by ResolveCacheInit.
+ if (resolve_path_.empty()) return;
+
+ std::string buf;
+ if (ReadFileSync(&buf, resolve_path_.c_str()) < 0) return;
+
+ Reader r{buf.data(), buf.data() + buf.size()};
+ uint32_t magic = 0;
+ std::string stored_token;
+ uint32_t count = 0;
+ if (!r.U32(&magic) || magic != kResolveCacheMagic || !r.Str(&stored_token)) {
+ return;
+ }
+
+ if (stored_token != resolve_token_) {
+ // Coarse invalidation: project/toolchain changed -> drop everything and
+ // rebuild this run.
+ resolve_dirty_ = true;
+ return;
+ }
+
+ if (!r.U32(&count)) return;
+ for (uint32_t i = 0; i < count; i++) {
+ std::string key;
+ std::string val;
+ if (!r.Str(&key) || !r.Str(&val)) break;
+ resolve_cache_.insert({std::move(key), std::move(val)});
+ }
+}
+
+// args: (token, absoluteCacheFilePath). Path resolution and the directory
+// layout are handled in JS; here we only validate permissions and load.
+void BindingData::ResolveCacheInit(const FunctionCallbackInfo& args) {
+ Realm* realm = Realm::GetCurrent(args);
+ Environment* env = realm->env();
+ auto binding_data = realm->GetBindingData();
+ if (binding_data->resolve_checked_) return;
+ binding_data->resolve_checked_ = true;
+
+ Utf8Value token(realm->isolate(), args[0]);
+ Utf8Value file(realm->isolate(), args[1]);
+ std::string path = *file;
+
+ // Respect the permission model: need both read (load) and write (flush).
+ if (!env->permission()->is_granted(
+ env, permission::PermissionScope::kFileSystemRead, path) ||
+ !env->permission()->is_granted(
+ env, permission::PermissionScope::kFileSystemWrite, path)) {
+ return; // leave resolve_path_ empty -> feature disabled
+ }
+
+ binding_data->resolve_token_ = *token;
+ binding_data->resolve_path_ = std::move(path);
+ binding_data->MaybeInitResolveCache();
+}
+
+void BindingData::ResolveCacheLookup(const FunctionCallbackInfo& args) {
+ Realm* realm = Realm::GetCurrent(args);
+ auto binding_data = realm->GetBindingData();
+ if (binding_data->resolve_path_.empty()) return;
+ Utf8Value key(realm->isolate(), args[0]);
+ // Keys contain embedded NULs as separators, so construct with the explicit
+ // length instead of treating *key as a C string.
+ auto it = binding_data->resolve_cache_.find(std::string(*key, key.length()));
+ if (it == binding_data->resolve_cache_.end()) return;
+ args.GetReturnValue().Set(String::NewFromUtf8(realm->isolate(),
+ it->second.c_str(),
+ NewStringType::kNormal,
+ it->second.size())
+ .ToLocalChecked());
+}
+
+void BindingData::ResolveCacheStore(const FunctionCallbackInfo& args) {
+ Realm* realm = Realm::GetCurrent(args);
+ auto binding_data = realm->GetBindingData();
+ if (binding_data->resolve_path_.empty()) return;
+ Utf8Value key(realm->isolate(), args[0]);
+ Utf8Value val(realm->isolate(), args[1]);
+ auto r = binding_data->resolve_cache_.insert(
+ {std::string(*key, key.length()), std::string(*val, val.length())});
+ if (r.second) binding_data->resolve_dirty_ = true;
+}
+
+void BindingData::FlushResolveCache(const FunctionCallbackInfo& args) {
+ Realm* realm = Realm::GetCurrent(args);
+ auto binding_data = realm->GetBindingData();
+ if (!binding_data->resolve_dirty_ || binding_data->resolve_path_.empty()) {
+ return;
+ }
+ std::string out;
+ AppendU32(&out, kResolveCacheMagic);
+ AppendStr(&out, binding_data->resolve_token_);
+ AppendU32(&out, static_cast(binding_data->resolve_cache_.size()));
+ for (const auto& [key, val] : binding_data->resolve_cache_) {
+ AppendStr(&out, key);
+ AppendStr(&out, val);
+ }
+ FILE* fp = fopen(binding_data->resolve_path_.c_str(), "wb");
+ if (fp == nullptr) return;
+ fwrite(out.data(), 1, out.size(), fp);
+ fclose(fp);
+ binding_data->resolve_dirty_ = false;
+}
+
BindingData::BindingData(Realm* realm,
v8::Local object,
InternalFieldInfo* info)
@@ -623,6 +766,10 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
SetMethod(
isolate, target, "getPackageScopeConfig", GetPackageScopeConfig);
SetMethod(isolate, target, "getPackageType", GetPackageScopeConfig);
+ SetMethod(isolate, target, "resolveCacheInit", ResolveCacheInit);
+ SetMethod(isolate, target, "resolveCacheLookup", ResolveCacheLookup);
+ SetMethod(isolate, target, "resolveCacheStore", ResolveCacheStore);
+ SetMethod(isolate, target, "flushResolveCache", FlushResolveCache);
SetMethod(isolate, target, "enableCompileCache", EnableCompileCache);
SetMethod(isolate, target, "getCompileCacheDir", GetCompileCacheDir);
SetMethod(isolate, target, "flushCompileCache", FlushCompileCache);
@@ -694,6 +841,10 @@ void BindingData::RegisterExternalReferences(
registry->Register(GetNearestParentPackageJSON);
registry->Register(GetPackageScopeConfig);
registry->Register(GetPackageScopeConfig);
+ registry->Register(ResolveCacheInit);
+ registry->Register(ResolveCacheLookup);
+ registry->Register(ResolveCacheStore);
+ registry->Register(FlushResolveCache);
registry->Register(EnableCompileCache);
registry->Register(GetCompileCacheDir);
registry->Register(FlushCompileCache);
diff --git a/src/node_modules.h b/src/node_modules.h
index d610306a3a3111..bb4ac90dfd57a7 100644
--- a/src/node_modules.h
+++ b/src/node_modules.h
@@ -83,6 +83,27 @@ class BindingData : public SnapshotableObject {
std::unordered_map >
package_configs_;
simdjson::ondemand::parser json_parser;
+
+ // Persistent module-resolution cache. Persists Module._pathCache and the
+ // ESM resolver results (cacheKey -> resolved filename/URL) so warm runs skip
+ // the candidate-stat search and package.json reads entirely. Coarse
+ // invalidation via a generation token (see resolve_token_).
+ bool resolve_checked_ = false;
+ bool resolve_dirty_ = false;
+ std::string resolve_path_;
+ std::string resolve_token_; // generation token for coarse invalidation
+ std::unordered_map resolve_cache_;
+ void MaybeInitResolveCache();
+ // Sets the generation token (from JS) and loads the cache, discarding it
+ // entirely if the stored token differs.
+ static void ResolveCacheInit(const v8::FunctionCallbackInfo& args);
+ static void ResolveCacheLookup(
+ const v8::FunctionCallbackInfo& args);
+ static void ResolveCacheStore(
+ const v8::FunctionCallbackInfo& args);
+ static void FlushResolveCache(
+ const v8::FunctionCallbackInfo& args);
+
static const std::filesystem::path NormalizePath(Realm* realm,
BufferValue* path_value);
// returns null on error
diff --git a/test/parallel/test-module-resolve-cache.js b/test/parallel/test-module-resolve-cache.js
new file mode 100644
index 00000000000000..613a68e38fa393
--- /dev/null
+++ b/test/parallel/test-module-resolve-cache.js
@@ -0,0 +1,99 @@
+'use strict';
+
+// Tests the persistent module-resolution cache: the NODE_MODULE_RESOLVE_CACHE
+// environment variable and the module.enableModuleResolveCache() API, for both
+// CommonJS and ES modules, including coarse (lockfile) invalidation.
+
+require('../common');
+const assert = require('assert');
+const { spawnSyncAndAssert } = require('../common/child_process');
+const tmpdir = require('../common/tmpdir');
+const fs = require('fs');
+const path = require('path');
+
+tmpdir.refresh();
+
+const projectDir = tmpdir.resolve('project');
+const cacheDir = tmpdir.resolve('resolve-cache');
+
+function writePkg(name, pkgJson, indexSource) {
+ const dir = path.join(projectDir, 'node_modules', name);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkgJson));
+ fs.writeFileSync(path.join(dir, pkgJson.main ?? 'index.js'), indexSource);
+}
+
+fs.mkdirSync(projectDir, { recursive: true });
+fs.writeFileSync(path.join(projectDir, 'package-lock.json'), '{}');
+
+// A CommonJS package and an ESM package.
+writePkg('cjsdep', { name: 'cjsdep', version: '1.0.0', main: 'index.js' },
+ 'module.exports = "cjs-old";');
+writePkg('esmdep',
+ { name: 'esmdep', version: '1.0.0', type: 'module', exports: './index.js' },
+ 'export default "esm-old";');
+
+const cjsApp = path.join(projectDir, 'app.cjs');
+fs.writeFileSync(cjsApp, 'console.log("cjs:" + require("cjsdep"));');
+const esmApp = path.join(projectDir, 'app.mjs');
+fs.writeFileSync(esmApp, 'import v from "esmdep"; console.log("esm:" + v);');
+
+function runApp(appFile, env) {
+ return {
+ env: { ...process.env, NODE_MODULE_RESOLVE_CACHE: cacheDir, ...env },
+ cwd: projectDir,
+ };
+}
+
+// 1. CJS: populate then read warm; both must resolve correctly.
+spawnSyncAndAssert(process.execPath, [cjsApp], runApp(cjsApp),
+ { stdout: 'cjs:cjs-old\n' });
+spawnSyncAndAssert(process.execPath, [cjsApp], runApp(cjsApp),
+ { stdout: 'cjs:cjs-old\n' });
+
+// A cache file must have been written.
+const files = fs.readdirSync(cacheDir, { recursive: true })
+ .filter((f) => f.endsWith('.resolve.bin'));
+assert.ok(files.length >= 1, `expected a .resolve.bin file, got ${files}`);
+
+// 2. ESM: populate then read warm.
+spawnSyncAndAssert(process.execPath, [esmApp], runApp(esmApp),
+ { stdout: 'esm:esm-old\n' });
+spawnSyncAndAssert(process.execPath, [esmApp], runApp(esmApp),
+ { stdout: 'esm:esm-old\n' });
+
+// 3. Coarse invalidation: change the resolution AND bump the lockfile; the
+// cache must be discarded and the new resolution picked up.
+writePkg('cjsdep', { name: 'cjsdep', version: '1.0.0', main: 'other.js' },
+ 'unused');
+fs.writeFileSync(path.join(projectDir, 'node_modules', 'cjsdep', 'other.js'),
+ 'module.exports = "cjs-new";');
+// Bump the lockfile so the generation token changes.
+fs.writeFileSync(path.join(projectDir, 'package-lock.json'),
+ JSON.stringify({ lockfileVersion: 3, changed: true }));
+spawnSyncAndAssert(process.execPath, [cjsApp], runApp(cjsApp),
+ { stdout: 'cjs:cjs-new\n' });
+
+// 4. Program API: module.enableModuleResolveCache(dir) reports the dir and
+// resolution still works.
+const apiDir = tmpdir.resolve('api-cache');
+const apiApp = path.join(projectDir, 'api.cjs');
+fs.writeFileSync(apiApp, `
+const m = require('module');
+const dir = m.enableModuleResolveCache(${JSON.stringify(apiDir)});
+console.log('dir:' + (typeof dir === 'string' && dir.length > 0));
+console.log('match:' + (m.getModuleResolveCacheDir() === dir));
+console.log('val:' + require('cjsdep'));
+`);
+spawnSyncAndAssert(
+ process.execPath, [apiApp],
+ { env: { ...process.env, NODE_MODULE_RESOLVE_CACHE: undefined }, cwd: projectDir },
+ { stdout: 'dir:true\nmatch:true\nval:cjs-new\n' });
+
+// 5. Disabled by default: no cache directory is created, resolution works.
+const offDir = tmpdir.resolve('never-created');
+spawnSyncAndAssert(
+ process.execPath, [cjsApp],
+ { env: { ...process.env, NODE_MODULE_RESOLVE_CACHE: undefined }, cwd: projectDir },
+ { stdout: 'cjs:cjs-new\n' });
+assert.ok(!fs.existsSync(offDir));