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
11 changes: 11 additions & 0 deletions .changeset/fix-join-cache-memory-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"enhanced-resolve": patch
---

Move `cachedJoin`/`cachedDirname` caches from module-level globals to
per-Resolver instances. This prevents unbounded memory growth in
long-running processes — when a Resolver is garbage collected, its
join/dirname caches are released with it.

Also export `createCachedJoin` and `createCachedDirname` factory
functions from `util/path` for creating independent cache instances.
38 changes: 28 additions & 10 deletions lib/Resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,23 @@ const createInnerContext = require("./createInnerContext");
const { parseIdentifier } = require("./util/identifier");
const {
PathType,
cachedJoin: join,
createCachedDirname,
createCachedJoin,
dirname: _dirname,
getType,
join: _join,
normalize,
} = require("./util/path");

/**
* @typedef {object} PathCacheFunctions
* @property {(rootPath: string, request: string) => string} join cached join
* @property {(maybePath: string) => string} dirname cached dirname
*/

/** @type {WeakMap<FileSystem, PathCacheFunctions>} */
const _pathCacheByFs = new WeakMap();

/** @typedef {import("./ResolverFactory").ResolveOptions} ResolveOptions */

/**
Expand Down Expand Up @@ -397,6 +409,21 @@ class Resolver {
constructor(fileSystem, options) {
this.fileSystem = fileSystem;
this.options = options;
if (options.unsafeCache) {
let pathCache = _pathCacheByFs.get(fileSystem);
if (!pathCache) {
pathCache = {
join: createCachedJoin(),
dirname: createCachedDirname(),
};
_pathCacheByFs.set(fileSystem, pathCache);
}
this.join = pathCache.join;
this.dirname = pathCache.dirname;
} else {
this.join = _join;
this.dirname = _dirname;
}
/** @type {KnownHooks} */
this.hooks = {
resolveStep: new SyncHook(["hook", "request"], "resolveStep"),
Expand Down Expand Up @@ -800,15 +827,6 @@ class Resolver {
return path.endsWith("/");
}

/**
* @param {string} path path
* @param {string} request request
* @returns {string} joined path
*/
join(path, request) {
return join(path, request);
}

/**
* @param {string} path path
* @returns {string} normalized path
Expand Down
58 changes: 30 additions & 28 deletions lib/TsconfigPathsPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,7 @@
const { aliasResolveHandler } = require("./AliasUtils");
const { modulesResolveHandler } = require("./ModulesUtils");
const { readJson } = require("./util/fs");
const {
PathType: _PathType,
cachedDirname: dirname,
cachedJoin: join,
isSubPath,
normalize,
} = require("./util/path");
const { PathType: _PathType, isSubPath, normalize } = require("./util/path");

/** @typedef {import("./Resolver")} Resolver */
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
Expand Down Expand Up @@ -102,10 +96,11 @@ function substituteConfigDir(pathValue, configDir) {
* Convert tsconfig paths to resolver options
* @param {string} configDir Config file directory
* @param {{ [key: string]: string[] }} paths TypeScript paths mapping
* @param {(rootPath: string, request: string) => string} join join function
* @param {string=} baseUrl Base URL for resolving paths (relative to configDir)
* @returns {TsconfigPathsData} the resolver options
*/
function tsconfigPathsToResolveOptions(configDir, paths, baseUrl) {
function tsconfigPathsToResolveOptions(configDir, paths, join, baseUrl) {
// Calculate absolute base URL
const absoluteBaseUrl = !baseUrl ? configDir : join(configDir, baseUrl);

Expand Down Expand Up @@ -155,10 +150,11 @@ function tsconfigPathsToResolveOptions(configDir, paths, baseUrl) {
/**
* Get the base context for the current project
* @param {string} context the context
* @param {(rootPath: string, request: string) => string} join join function
* @param {string=} baseUrl base URL for resolving paths
* @returns {string} the base context
*/
function getAbsoluteBaseUrl(context, baseUrl) {
function getAbsoluteBaseUrl(context, join, baseUrl) {
return !baseUrl ? context : join(context, baseUrl);
}

Expand Down Expand Up @@ -295,12 +291,12 @@ module.exports = class TsconfigPathsPlugin {
async _getTsconfigPathsMap(resolver, request, resolveContext) {
if (typeof request.tsconfigPathsMap === "undefined") {
try {
const absTsconfigPath = join(
const absTsconfigPath = resolver.join(
request.path || process.cwd(),
this.configFile,
);
const result = await this._loadTsconfigPathsMap(
resolver.fileSystem,
resolver,
absTsconfigPath,
);

Expand Down Expand Up @@ -332,28 +328,29 @@ module.exports = class TsconfigPathsPlugin {
/**
* Load tsconfig.json and build complete TsconfigPathsMap
* Includes main project paths and all referenced projects
* @param {FileSystem} fileSystem the file system
* @param {Resolver} resolver the resolver
* @param {string} absTsconfigPath absolute path to tsconfig.json
* @returns {Promise<TsconfigPathsMap>} the complete tsconfig paths map
*/
async _loadTsconfigPathsMap(fileSystem, absTsconfigPath) {
async _loadTsconfigPathsMap(resolver, absTsconfigPath) {
/** @type {Set<string>} */
const fileDependencies = new Set();
const config = await this._loadTsconfig(
fileSystem,
resolver,
absTsconfigPath,
fileDependencies,
);

const compilerOptions = config.compilerOptions || {};
const mainContext = dirname(absTsconfigPath);
const mainContext = resolver.dirname(absTsconfigPath);

const baseUrl =
this.baseUrl !== undefined ? this.baseUrl : compilerOptions.baseUrl;

const main = tsconfigPathsToResolveOptions(
mainContext,
compilerOptions.paths || {},
resolver.join,
baseUrl,
);
/** @type {{ [baseUrl: string]: TsconfigPathsData }} */
Expand All @@ -368,7 +365,7 @@ module.exports = class TsconfigPathsPlugin {

if (Array.isArray(referencesToUse)) {
await this._loadTsconfigReferences(
fileSystem,
resolver,
mainContext,
referencesToUse,
fileDependencies,
Expand Down Expand Up @@ -418,20 +415,22 @@ module.exports = class TsconfigPathsPlugin {

/**
* Load tsconfig from extends path
* @param {FileSystem} fileSystem the file system
* @param {Resolver} resolver the resolver
* @param {string} configFilePath current config file path
* @param {string} extendedConfigValue extends value
* @param {Set<string>} fileDependencies the file dependencies
* @param {Set<string>} visitedConfigPaths config paths being loaded (for circular extends detection)
* @returns {Promise<Tsconfig>} the extended tsconfig
*/
async _loadTsconfigFromExtends(
fileSystem,
resolver,
configFilePath,
extendedConfigValue,
fileDependencies,
visitedConfigPaths,
) {
const { join, dirname } = resolver;
const { fileSystem } = resolver;
const currentDir = dirname(configFilePath);

// Substitute ${configDir} in extends path
Expand Down Expand Up @@ -482,7 +481,7 @@ module.exports = class TsconfigPathsPlugin {
}

const config = await this._loadTsconfig(
fileSystem,
resolver,
extendedConfigPath,
fileDependencies,
visitedConfigPaths,
Expand All @@ -493,6 +492,7 @@ module.exports = class TsconfigPathsPlugin {
const extendedConfigDir = dirname(extendedConfigPath);
compilerOptions.baseUrl = getAbsoluteBaseUrl(
extendedConfigDir,
join,
compilerOptions.baseUrl,
);
}
Expand All @@ -506,28 +506,29 @@ module.exports = class TsconfigPathsPlugin {
* Load referenced tsconfig projects and store in referenceMatchMap
* Simple implementation matching tsconfig-paths-webpack-plugin:
* Just load each reference and store independently
* @param {FileSystem} fileSystem the file system
* @param {Resolver} resolver the resolver
* @param {string} context the context
* @param {TsconfigReference[]} references array of references
* @param {Set<string>} fileDependencies the file dependencies
* @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
* @returns {Promise<void>}
*/
async _loadTsconfigReferences(
fileSystem,
resolver,
context,
references,
fileDependencies,
referenceMatchMap,
) {
const { join, dirname } = resolver;
await Promise.all(
references.map(async (ref) => {
const refPath = substituteConfigDir(ref.path, context);
const refConfigPath = join(join(context, refPath), DEFAULT_CONFIG_FILE);

try {
const refConfig = await this._loadTsconfig(
fileSystem,
resolver,
refConfigPath,
fileDependencies,
);
Expand All @@ -538,6 +539,7 @@ module.exports = class TsconfigPathsPlugin {
referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
refContext,
refConfig.compilerOptions.paths || {},
join,
refConfig.compilerOptions.baseUrl,
);
}
Expand All @@ -547,7 +549,7 @@ module.exports = class TsconfigPathsPlugin {
Array.isArray(refConfig.references)
) {
await this._loadTsconfigReferences(
fileSystem,
resolver,
dirname(refConfigPath),
refConfig.references,
fileDependencies,
Expand All @@ -563,14 +565,14 @@ module.exports = class TsconfigPathsPlugin {

/**
* Load tsconfig.json with extends support
* @param {FileSystem} fileSystem the file system
* @param {Resolver} resolver the resolver
* @param {string} configFilePath absolute path to tsconfig.json
* @param {Set<string>} fileDependencies the file dependencies
* @param {Set<string>=} visitedConfigPaths config paths being loaded (for circular extends detection)
* @returns {Promise<Tsconfig>} the merged tsconfig
*/
async _loadTsconfig(
fileSystem,
resolver,
configFilePath,
fileDependencies,
visitedConfigPaths = new Set(),
Expand All @@ -579,7 +581,7 @@ module.exports = class TsconfigPathsPlugin {
return /** @type {Tsconfig} */ ({});
}
visitedConfigPaths.add(configFilePath);
const config = await readJson(fileSystem, configFilePath, {
const config = await readJson(resolver.fileSystem, configFilePath, {
stripComments: true,
});
fileDependencies.add(configFilePath);
Expand All @@ -594,7 +596,7 @@ module.exports = class TsconfigPathsPlugin {
base = {};
for (const extendedConfigElement of extendedConfig) {
const extendedTsconfig = await this._loadTsconfigFromExtends(
fileSystem,
resolver,
configFilePath,
extendedConfigElement,
fileDependencies,
Expand All @@ -604,7 +606,7 @@ module.exports = class TsconfigPathsPlugin {
}
} else {
base = await this._loadTsconfigFromExtends(
fileSystem,
resolver,
configFilePath,
extendedConfig,
fileDependencies,
Expand Down
23 changes: 15 additions & 8 deletions lib/UnsafeCachePlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

"use strict";

const { cachedJoin } = require("./util/path");

/** @typedef {import("./Resolver")} Resolver */
/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
Expand All @@ -18,10 +16,11 @@ const RELATIVE_REQUEST_REGEXP = /^\.\.?(?:\/|$)/;
/**
* @param {string} relativePath relative path from package root
* @param {string} request relative request
* @param {(rootPath: string, request: string) => string} join join function
* @returns {string} normalized request with a preserved leading dot
*/
function joinRelativePreservingLeadingDot(relativePath, request) {
const normalized = cachedJoin(relativePath, request);
function joinRelativePreservingLeadingDot(relativePath, request, join) {
const normalized = join(relativePath, request);
return RELATIVE_REQUEST_REGEXP.test(normalized)
? normalized
: `./${normalized}`;
Expand All @@ -40,9 +39,10 @@ function getCachePath(request) {

/**
* @param {ResolveRequest} request request
* @param {(rootPath: string, request: string) => string} join join function
* @returns {string | undefined} normalized request string
*/
function getCacheRequest(request) {
function getCacheRequest(request, join) {
const requestString = request.request;
if (
!requestString ||
Expand All @@ -51,23 +51,28 @@ function getCacheRequest(request) {
) {
return requestString;
}
return joinRelativePreservingLeadingDot(request.relativePath, requestString);
return joinRelativePreservingLeadingDot(
request.relativePath,
requestString,
join,
);
}

/**
* @param {string} type type of cache
* @param {ResolveRequest} request request
* @param {boolean} withContext cache with context?
* @param {(rootPath: string, request: string) => string} join join function
* @returns {string} cache id
*/
function getCacheId(type, request, withContext) {
function getCacheId(type, request, withContext, join) {
return JSON.stringify({
type,
context: withContext ? request.context : "",
path: getCachePath(request),
query: request.query,
fragment: request.fragment,
request: getCacheRequest(request),
request: getCacheRequest(request, join),
});
}

Expand All @@ -93,6 +98,7 @@ module.exports = class UnsafeCachePlugin {
*/
apply(resolver) {
const target = resolver.ensureHook(this.target);
const { join } = resolver;
resolver
.getHook(this.source)
.tapAsync("UnsafeCachePlugin", (request, resolveContext, callback) => {
Expand All @@ -110,6 +116,7 @@ module.exports = class UnsafeCachePlugin {
isYield ? "yield" : "default",
request,
this.withContext,
join,
);
const cacheEntry = this.cache[cacheId];
if (cacheEntry) {
Expand Down
Loading