Skip to content

Commit 115db10

Browse files
committed
fix: move join/dirname caches to Resolver instance to prevent memory leak
1 parent a44aaeb commit 115db10

File tree

7 files changed

+224
-77
lines changed

7 files changed

+224
-77
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"enhanced-resolve": patch
3+
---
4+
5+
Move `cachedJoin`/`cachedDirname` caches from module-level globals to
6+
per-Resolver instances. This prevents unbounded memory growth in
7+
long-running processes — when a Resolver is garbage collected, its
8+
join/dirname caches are released with it.
9+
10+
Also export `createCachedJoin` and `createCachedDirname` factory
11+
functions from `util/path` for creating independent cache instances.

lib/Resolver.js

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ const createInnerContext = require("./createInnerContext");
1010
const { parseIdentifier } = require("./util/identifier");
1111
const {
1212
PathType,
13-
cachedJoin: join,
13+
createCachedDirname,
14+
createCachedJoin,
15+
dirname: _dirname,
1416
getType,
17+
join: _join,
1518
normalize,
1619
} = require("./util/path");
1720

21+
/**
22+
* @typedef {object} PathCacheFunctions
23+
* @property {(rootPath: string, request: string) => string} join cached join
24+
* @property {(maybePath: string) => string} dirname cached dirname
25+
*/
26+
27+
/** @type {WeakMap<FileSystem, PathCacheFunctions>} */
28+
const _pathCacheByFs = new WeakMap();
29+
1830
/** @typedef {import("./ResolverFactory").ResolveOptions} ResolveOptions */
1931

2032
/**
@@ -397,6 +409,21 @@ class Resolver {
397409
constructor(fileSystem, options) {
398410
this.fileSystem = fileSystem;
399411
this.options = options;
412+
if (options.unsafeCache) {
413+
let pathCache = _pathCacheByFs.get(fileSystem);
414+
if (!pathCache) {
415+
pathCache = {
416+
join: createCachedJoin(),
417+
dirname: createCachedDirname(),
418+
};
419+
_pathCacheByFs.set(fileSystem, pathCache);
420+
}
421+
this.join = pathCache.join;
422+
this.dirname = pathCache.dirname;
423+
} else {
424+
this.join = _join;
425+
this.dirname = _dirname;
426+
}
400427
/** @type {KnownHooks} */
401428
this.hooks = {
402429
resolveStep: new SyncHook(["hook", "request"], "resolveStep"),
@@ -800,15 +827,6 @@ class Resolver {
800827
return path.endsWith("/");
801828
}
802829

803-
/**
804-
* @param {string} path path
805-
* @param {string} request request
806-
* @returns {string} joined path
807-
*/
808-
join(path, request) {
809-
return join(path, request);
810-
}
811-
812830
/**
813831
* @param {string} path path
814832
* @returns {string} normalized path

lib/TsconfigPathsPlugin.js

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,7 @@
88
const { aliasResolveHandler } = require("./AliasUtils");
99
const { modulesResolveHandler } = require("./ModulesUtils");
1010
const { readJson } = require("./util/fs");
11-
const {
12-
PathType: _PathType,
13-
cachedDirname: dirname,
14-
cachedJoin: join,
15-
isSubPath,
16-
normalize,
17-
} = require("./util/path");
11+
const { PathType: _PathType, isSubPath, normalize } = require("./util/path");
1812

1913
/** @typedef {import("./Resolver")} Resolver */
2014
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
@@ -102,10 +96,11 @@ function substituteConfigDir(pathValue, configDir) {
10296
* Convert tsconfig paths to resolver options
10397
* @param {string} configDir Config file directory
10498
* @param {{ [key: string]: string[] }} paths TypeScript paths mapping
99+
* @param {(rootPath: string, request: string) => string} join join function
105100
* @param {string=} baseUrl Base URL for resolving paths (relative to configDir)
106101
* @returns {TsconfigPathsData} the resolver options
107102
*/
108-
function tsconfigPathsToResolveOptions(configDir, paths, baseUrl) {
103+
function tsconfigPathsToResolveOptions(configDir, paths, join, baseUrl) {
109104
// Calculate absolute base URL
110105
const absoluteBaseUrl = !baseUrl ? configDir : join(configDir, baseUrl);
111106

@@ -155,10 +150,11 @@ function tsconfigPathsToResolveOptions(configDir, paths, baseUrl) {
155150
/**
156151
* Get the base context for the current project
157152
* @param {string} context the context
153+
* @param {(rootPath: string, request: string) => string} join join function
158154
* @param {string=} baseUrl base URL for resolving paths
159155
* @returns {string} the base context
160156
*/
161-
function getAbsoluteBaseUrl(context, baseUrl) {
157+
function getAbsoluteBaseUrl(context, join, baseUrl) {
162158
return !baseUrl ? context : join(context, baseUrl);
163159
}
164160

@@ -295,12 +291,12 @@ module.exports = class TsconfigPathsPlugin {
295291
async _getTsconfigPathsMap(resolver, request, resolveContext) {
296292
if (typeof request.tsconfigPathsMap === "undefined") {
297293
try {
298-
const absTsconfigPath = join(
294+
const absTsconfigPath = resolver.join(
299295
request.path || process.cwd(),
300296
this.configFile,
301297
);
302298
const result = await this._loadTsconfigPathsMap(
303-
resolver.fileSystem,
299+
resolver,
304300
absTsconfigPath,
305301
);
306302

@@ -332,28 +328,29 @@ module.exports = class TsconfigPathsPlugin {
332328
/**
333329
* Load tsconfig.json and build complete TsconfigPathsMap
334330
* Includes main project paths and all referenced projects
335-
* @param {FileSystem} fileSystem the file system
331+
* @param {Resolver} resolver the resolver
336332
* @param {string} absTsconfigPath absolute path to tsconfig.json
337333
* @returns {Promise<TsconfigPathsMap>} the complete tsconfig paths map
338334
*/
339-
async _loadTsconfigPathsMap(fileSystem, absTsconfigPath) {
335+
async _loadTsconfigPathsMap(resolver, absTsconfigPath) {
340336
/** @type {Set<string>} */
341337
const fileDependencies = new Set();
342338
const config = await this._loadTsconfig(
343-
fileSystem,
339+
resolver,
344340
absTsconfigPath,
345341
fileDependencies,
346342
);
347343

348344
const compilerOptions = config.compilerOptions || {};
349-
const mainContext = dirname(absTsconfigPath);
345+
const mainContext = resolver.dirname(absTsconfigPath);
350346

351347
const baseUrl =
352348
this.baseUrl !== undefined ? this.baseUrl : compilerOptions.baseUrl;
353349

354350
const main = tsconfigPathsToResolveOptions(
355351
mainContext,
356352
compilerOptions.paths || {},
353+
resolver.join,
357354
baseUrl,
358355
);
359356
/** @type {{ [baseUrl: string]: TsconfigPathsData }} */
@@ -368,7 +365,7 @@ module.exports = class TsconfigPathsPlugin {
368365

369366
if (Array.isArray(referencesToUse)) {
370367
await this._loadTsconfigReferences(
371-
fileSystem,
368+
resolver,
372369
mainContext,
373370
referencesToUse,
374371
fileDependencies,
@@ -418,20 +415,22 @@ module.exports = class TsconfigPathsPlugin {
418415

419416
/**
420417
* Load tsconfig from extends path
421-
* @param {FileSystem} fileSystem the file system
418+
* @param {Resolver} resolver the resolver
422419
* @param {string} configFilePath current config file path
423420
* @param {string} extendedConfigValue extends value
424421
* @param {Set<string>} fileDependencies the file dependencies
425422
* @param {Set<string>} visitedConfigPaths config paths being loaded (for circular extends detection)
426423
* @returns {Promise<Tsconfig>} the extended tsconfig
427424
*/
428425
async _loadTsconfigFromExtends(
429-
fileSystem,
426+
resolver,
430427
configFilePath,
431428
extendedConfigValue,
432429
fileDependencies,
433430
visitedConfigPaths,
434431
) {
432+
const { join, dirname } = resolver;
433+
const { fileSystem } = resolver;
435434
const currentDir = dirname(configFilePath);
436435

437436
// Substitute ${configDir} in extends path
@@ -482,7 +481,7 @@ module.exports = class TsconfigPathsPlugin {
482481
}
483482

484483
const config = await this._loadTsconfig(
485-
fileSystem,
484+
resolver,
486485
extendedConfigPath,
487486
fileDependencies,
488487
visitedConfigPaths,
@@ -493,6 +492,7 @@ module.exports = class TsconfigPathsPlugin {
493492
const extendedConfigDir = dirname(extendedConfigPath);
494493
compilerOptions.baseUrl = getAbsoluteBaseUrl(
495494
extendedConfigDir,
495+
join,
496496
compilerOptions.baseUrl,
497497
);
498498
}
@@ -506,28 +506,29 @@ module.exports = class TsconfigPathsPlugin {
506506
* Load referenced tsconfig projects and store in referenceMatchMap
507507
* Simple implementation matching tsconfig-paths-webpack-plugin:
508508
* Just load each reference and store independently
509-
* @param {FileSystem} fileSystem the file system
509+
* @param {Resolver} resolver the resolver
510510
* @param {string} context the context
511511
* @param {TsconfigReference[]} references array of references
512512
* @param {Set<string>} fileDependencies the file dependencies
513513
* @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
514514
* @returns {Promise<void>}
515515
*/
516516
async _loadTsconfigReferences(
517-
fileSystem,
517+
resolver,
518518
context,
519519
references,
520520
fileDependencies,
521521
referenceMatchMap,
522522
) {
523+
const { join, dirname } = resolver;
523524
await Promise.all(
524525
references.map(async (ref) => {
525526
const refPath = substituteConfigDir(ref.path, context);
526527
const refConfigPath = join(join(context, refPath), DEFAULT_CONFIG_FILE);
527528

528529
try {
529530
const refConfig = await this._loadTsconfig(
530-
fileSystem,
531+
resolver,
531532
refConfigPath,
532533
fileDependencies,
533534
);
@@ -538,6 +539,7 @@ module.exports = class TsconfigPathsPlugin {
538539
referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
539540
refContext,
540541
refConfig.compilerOptions.paths || {},
542+
join,
541543
refConfig.compilerOptions.baseUrl,
542544
);
543545
}
@@ -547,7 +549,7 @@ module.exports = class TsconfigPathsPlugin {
547549
Array.isArray(refConfig.references)
548550
) {
549551
await this._loadTsconfigReferences(
550-
fileSystem,
552+
resolver,
551553
dirname(refConfigPath),
552554
refConfig.references,
553555
fileDependencies,
@@ -563,14 +565,14 @@ module.exports = class TsconfigPathsPlugin {
563565

564566
/**
565567
* Load tsconfig.json with extends support
566-
* @param {FileSystem} fileSystem the file system
568+
* @param {Resolver} resolver the resolver
567569
* @param {string} configFilePath absolute path to tsconfig.json
568570
* @param {Set<string>} fileDependencies the file dependencies
569571
* @param {Set<string>=} visitedConfigPaths config paths being loaded (for circular extends detection)
570572
* @returns {Promise<Tsconfig>} the merged tsconfig
571573
*/
572574
async _loadTsconfig(
573-
fileSystem,
575+
resolver,
574576
configFilePath,
575577
fileDependencies,
576578
visitedConfigPaths = new Set(),
@@ -579,7 +581,7 @@ module.exports = class TsconfigPathsPlugin {
579581
return /** @type {Tsconfig} */ ({});
580582
}
581583
visitedConfigPaths.add(configFilePath);
582-
const config = await readJson(fileSystem, configFilePath, {
584+
const config = await readJson(resolver.fileSystem, configFilePath, {
583585
stripComments: true,
584586
});
585587
fileDependencies.add(configFilePath);
@@ -594,7 +596,7 @@ module.exports = class TsconfigPathsPlugin {
594596
base = {};
595597
for (const extendedConfigElement of extendedConfig) {
596598
const extendedTsconfig = await this._loadTsconfigFromExtends(
597-
fileSystem,
599+
resolver,
598600
configFilePath,
599601
extendedConfigElement,
600602
fileDependencies,
@@ -604,7 +606,7 @@ module.exports = class TsconfigPathsPlugin {
604606
}
605607
} else {
606608
base = await this._loadTsconfigFromExtends(
607-
fileSystem,
609+
resolver,
608610
configFilePath,
609611
extendedConfig,
610612
fileDependencies,

lib/UnsafeCachePlugin.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55

66
"use strict";
77

8-
const { cachedJoin } = require("./util/path");
9-
108
/** @typedef {import("./Resolver")} Resolver */
119
/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
1210
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
@@ -18,10 +16,11 @@ const RELATIVE_REQUEST_REGEXP = /^\.\.?(?:\/|$)/;
1816
/**
1917
* @param {string} relativePath relative path from package root
2018
* @param {string} request relative request
19+
* @param {(rootPath: string, request: string) => string} join join function
2120
* @returns {string} normalized request with a preserved leading dot
2221
*/
23-
function joinRelativePreservingLeadingDot(relativePath, request) {
24-
const normalized = cachedJoin(relativePath, request);
22+
function joinRelativePreservingLeadingDot(relativePath, request, join) {
23+
const normalized = join(relativePath, request);
2524
return RELATIVE_REQUEST_REGEXP.test(normalized)
2625
? normalized
2726
: `./${normalized}`;
@@ -40,9 +39,10 @@ function getCachePath(request) {
4039

4140
/**
4241
* @param {ResolveRequest} request request
42+
* @param {(rootPath: string, request: string) => string} join join function
4343
* @returns {string | undefined} normalized request string
4444
*/
45-
function getCacheRequest(request) {
45+
function getCacheRequest(request, join) {
4646
const requestString = request.request;
4747
if (
4848
!requestString ||
@@ -51,23 +51,28 @@ function getCacheRequest(request) {
5151
) {
5252
return requestString;
5353
}
54-
return joinRelativePreservingLeadingDot(request.relativePath, requestString);
54+
return joinRelativePreservingLeadingDot(
55+
request.relativePath,
56+
requestString,
57+
join,
58+
);
5559
}
5660

5761
/**
5862
* @param {string} type type of cache
5963
* @param {ResolveRequest} request request
6064
* @param {boolean} withContext cache with context?
65+
* @param {(rootPath: string, request: string) => string} join join function
6166
* @returns {string} cache id
6267
*/
63-
function getCacheId(type, request, withContext) {
68+
function getCacheId(type, request, withContext, join) {
6469
return JSON.stringify({
6570
type,
6671
context: withContext ? request.context : "",
6772
path: getCachePath(request),
6873
query: request.query,
6974
fragment: request.fragment,
70-
request: getCacheRequest(request),
75+
request: getCacheRequest(request, join),
7176
});
7277
}
7378

@@ -93,6 +98,7 @@ module.exports = class UnsafeCachePlugin {
9398
*/
9499
apply(resolver) {
95100
const target = resolver.ensureHook(this.target);
101+
const { join } = resolver;
96102
resolver
97103
.getHook(this.source)
98104
.tapAsync("UnsafeCachePlugin", (request, resolveContext, callback) => {
@@ -110,6 +116,7 @@ module.exports = class UnsafeCachePlugin {
110116
isYield ? "yield" : "default",
111117
request,
112118
this.withContext,
119+
join,
113120
);
114121
const cacheEntry = this.cache[cacheId];
115122
if (cacheEntry) {

0 commit comments

Comments
 (0)