-
-
Notifications
You must be signed in to change notification settings - Fork 34.7k
module: add clearCache for CJS and ESM #61767
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,6 +66,52 @@ const require = createRequire(import.meta.url); | |
| const siblingModule = require('./sibling-module'); | ||
| ``` | ||
| ### `module.clearCache(specifier[, options])` | ||
| <!-- YAML | ||
| added: REPLACEME | ||
| --> | ||
| > Stability: 1.1 - Active development | ||
| * `specifier` {string|URL} The module specifier or URL to clear. | ||
| * `options` {Object} | ||
| * `mode` {string} Which caches to clear. Supported values are `'all'`, `'cjs'`, and `'esm'`. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the module namespace, these are generally called |
||
| **Default:** `'all'`. | ||
| * `parentURL` {string|URL} The parent URL or absolute path used to resolve non-URL specifiers. | ||
| For CommonJS, pass `__filename`. For ES modules, pass `import.meta.url`. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think accepting filename as parentURL doesn't make too much sense. It would be better to just stick to URL, a commonJS can still convert the file path into an URL. |
||
| * `type` {string} Import attributes `type` used for ESM resolution. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this necessary if we also accept |
||
| * `importAttributes` {Object} Import attributes for ESM resolution. Cannot be used with `type`. | ||
| * Returns: {Object} An object with `{ cjs: boolean, esm: boolean }` indicating whether entries | ||
| were removed from each cache. | ||
| Clears the CommonJS `require` cache and/or the ESM module cache for a module. This enables | ||
| reload patterns similar to deleting from `require.cache` in CommonJS, and is useful for HMR. | ||
| When `mode` is `'all'`, resolution failures for one module system do not throw; check the | ||
| returned flags to see what was cleared. | ||
| This also clears internal resolution caches for the resolved module. | ||
| ```mjs | ||
| import { clearCache } from 'node:module'; | ||
|
|
||
| const url = new URL('./mod.mjs', import.meta.url); | ||
| await import(url.href); | ||
|
|
||
| clearCache(url); | ||
| await import(url.href); // re-executes the module | ||
| ``` | ||
| ```cjs | ||
| const { clearCache } = require('node:module'); | ||
| const path = require('node:path'); | ||
|
|
||
| const file = path.join(__dirname, 'mod.js'); | ||
| require(file); | ||
|
|
||
| clearCache(file); | ||
| require(file); // re-executes the module | ||
| ``` | ||
| ### `module.findPackageJSON(specifier[, base])` | ||
| <!-- YAML | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -135,7 +135,7 @@ const { BuiltinModule } = require('internal/bootstrap/realm'); | |
| const { | ||
| maybeCacheSourceMap, | ||
| } = require('internal/source_map/source_map_cache'); | ||
| const { pathToFileURL, fileURLToPath, isURL, URL } = require('internal/url'); | ||
| const { pathToFileURL, fileURLToPath, isURL, URL, URLParse } = require('internal/url'); | ||
| const { | ||
| pendingDeprecate, | ||
| emitExperimentalWarning, | ||
|
|
@@ -200,7 +200,11 @@ const { | |
| }, | ||
| setArrowMessage, | ||
| } = require('internal/errors'); | ||
| const { validateString } = require('internal/validators'); | ||
| const { | ||
| validateObject, | ||
| validateOneOf, | ||
| validateString, | ||
| } = require('internal/validators'); | ||
|
|
||
| const { | ||
| CHAR_BACKWARD_SLASH, | ||
|
|
@@ -2028,6 +2032,242 @@ function createRequire(filenameOrURL) { | |
| return createRequireFromPath(filepath, fileURL); | ||
| } | ||
|
|
||
| /** | ||
| * Normalize the parent URL/path for cache clearing. | ||
| * @param {string|URL|undefined} parentURL | ||
| * @returns {{ parentURL: string|undefined, parentPath: string|undefined }} | ||
| */ | ||
| function normalizeClearCacheParent(parentURL) { | ||
| if (parentURL === undefined) { | ||
| return { __proto__: null, parentURL: undefined, parentPath: undefined }; | ||
| } | ||
|
|
||
| if (isURL(parentURL)) { | ||
| const parentPath = | ||
| parentURL.protocol === 'file:' && parentURL.search === '' && parentURL.hash === '' ? | ||
| fileURLToPath(parentURL) : | ||
| undefined; | ||
| return { __proto__: null, parentURL: parentURL.href, parentPath }; | ||
| } | ||
|
|
||
| validateString(parentURL, 'options.parentURL'); | ||
| if (path.isAbsolute(parentURL)) { | ||
| return { | ||
| __proto__: null, | ||
| parentURL: pathToFileURL(parentURL).href, | ||
| parentPath: parentURL, | ||
| }; | ||
| } | ||
|
|
||
| const url = URLParse(parentURL); | ||
| if (!url) { | ||
| throw new ERR_INVALID_ARG_VALUE('options.parentURL', parentURL, | ||
| 'must be an absolute path or URL'); | ||
| } | ||
|
|
||
| const parentPath = | ||
| url.protocol === 'file:' && url.search === '' && url.hash === '' ? | ||
| fileURLToPath(url) : | ||
| undefined; | ||
| return { __proto__: null, parentURL: url.href, parentPath }; | ||
| } | ||
|
|
||
| /** | ||
| * Parse a specifier as a URL when possible. | ||
| * @param {string|URL} specifier | ||
| * @returns {URL|null} | ||
| */ | ||
| function getURLFromClearCacheSpecifier(specifier) { | ||
| if (isURL(specifier)) { | ||
| return specifier; | ||
| } | ||
|
|
||
| if (typeof specifier !== 'string' || path.isAbsolute(specifier)) { | ||
| return null; | ||
| } | ||
|
|
||
| return URLParse(specifier) ?? null; | ||
| } | ||
|
|
||
| /** | ||
| * Create a synthetic parent module for CJS resolution. | ||
| * @param {string} parentPath | ||
| * @returns {Module} | ||
| */ | ||
| function createParentModuleForClearCache(parentPath) { | ||
| const parent = new Module(parentPath); | ||
| parent.filename = parentPath; | ||
| parent.paths = Module._nodeModulePaths(path.dirname(parentPath)); | ||
| return parent; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a cache filename for CommonJS. | ||
| * @param {string|URL} specifier | ||
| * @param {string|undefined} parentPath | ||
| * @returns {string|null} | ||
| */ | ||
| function resolveClearCacheFilename(specifier, parentPath) { | ||
| if (!parentPath && typeof specifier === 'string' && isRelative(specifier)) { | ||
| return null; | ||
| } | ||
|
|
||
| const parsedURL = getURLFromClearCacheSpecifier(specifier); | ||
| let request = specifier; | ||
| if (parsedURL) { | ||
| if (parsedURL.protocol !== 'file:' || parsedURL.search !== '' || parsedURL.hash !== '') { | ||
| return null; | ||
| } | ||
| request = fileURLToPath(parsedURL); | ||
| } | ||
|
|
||
| const parent = parentPath ? createParentModuleForClearCache(parentPath) : null; | ||
| return Module._resolveFilename(request, parent, false); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this would work with |
||
| } | ||
|
|
||
| /** | ||
| * Resolve a cache URL for ESM. | ||
| * @param {string|URL} specifier | ||
| * @param {string|undefined} parentURL | ||
| * @param {Record<string, string>|undefined} importAttributes | ||
| * @returns {string} | ||
| */ | ||
| function resolveClearCacheURL(specifier, parentURL, importAttributes) { | ||
| const parsedURL = getURLFromClearCacheSpecifier(specifier); | ||
| if (parsedURL) { | ||
| return parsedURL.href; | ||
| } | ||
|
|
||
| if (path.isAbsolute(specifier)) { | ||
| return pathToFileURL(specifier).href; | ||
| } | ||
|
|
||
| if (parentURL === undefined) { | ||
| throw new ERR_INVALID_ARG_VALUE('options.parentURL', parentURL, | ||
| 'must be provided for non-URL ESM specifiers'); | ||
| } | ||
|
|
||
| const cascadedLoader = | ||
| require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); | ||
| const request = { specifier, attributes: importAttributes, __proto__: null }; | ||
| return cascadedLoader.resolveSync(parentURL, request).url; | ||
| } | ||
|
|
||
| /** | ||
| * Remove path cache entries that resolve to a filename. | ||
| * @param {string} filename | ||
| * @returns {boolean} true if any entries were deleted. | ||
| */ | ||
| function deletePathCacheEntries(filename) { | ||
| const cache = Module._pathCache; | ||
| const keys = ObjectKeys(cache); | ||
| let deleted = false; | ||
| for (let i = 0; i < keys.length; i++) { | ||
| const key = keys[i]; | ||
| if (cache[key] === filename) { | ||
| delete cache[key]; | ||
| deleted = true; | ||
| } | ||
| } | ||
| return deleted; | ||
| } | ||
|
|
||
| /** | ||
| * Remove relative resolve cache entries that resolve to a filename. | ||
| * @param {string} filename | ||
| * @returns {boolean} true if any entries were deleted. | ||
| */ | ||
| function deleteRelativeResolveCacheEntries(filename) { | ||
| const keys = ObjectKeys(relativeResolveCache); | ||
| let deleted = false; | ||
| for (let i = 0; i < keys.length; i++) { | ||
| const key = keys[i]; | ||
| if (relativeResolveCache[key] === filename) { | ||
| delete relativeResolveCache[key]; | ||
| deleted = true; | ||
| } | ||
| } | ||
| return deleted; | ||
| } | ||
|
|
||
| /** | ||
| * Clear CommonJS and/or ESM module cache entries. | ||
| * @param {string|URL} specifier | ||
| * @param {object} [options] | ||
| * @param {'all'|'cjs'|'esm'} [options.mode] | ||
| * @param {string|URL} [options.parentURL] | ||
| * @param {string} [options.type] | ||
| * @param {Record<string, string>} [options.importAttributes] | ||
| * @returns {{ cjs: boolean, esm: boolean }} | ||
| */ | ||
| function clearCache(specifier, options = kEmptyObject) { | ||
| const isSpecifierURL = isURL(specifier); | ||
| if (!isSpecifierURL) { | ||
| validateString(specifier, 'specifier'); | ||
| } | ||
|
|
||
| validateObject(options, 'options'); | ||
| const mode = options.mode === undefined ? 'all' : options.mode; | ||
| validateOneOf(mode, 'options.mode', ['all', 'cjs', 'esm']); | ||
|
|
||
| if (options.importAttributes !== undefined && options.type !== undefined) { | ||
| throw new ERR_INVALID_ARG_VALUE('options.importAttributes', options.importAttributes, | ||
| 'cannot be used with options.type'); | ||
| } | ||
|
|
||
| let importAttributes = options.importAttributes; | ||
| if (options.type !== undefined) { | ||
| validateString(options.type, 'options.type'); | ||
| importAttributes = { __proto__: null, type: options.type }; | ||
| } else if (importAttributes !== undefined) { | ||
| validateObject(importAttributes, 'options.importAttributes'); | ||
| } | ||
|
|
||
| const { parentURL, parentPath } = normalizeClearCacheParent(options.parentURL); | ||
| const result = { __proto__: null, cjs: false, esm: false }; | ||
|
|
||
| if (mode !== 'esm') { | ||
| try { | ||
| const filename = resolveClearCacheFilename(specifier, parentPath); | ||
| if (filename) { | ||
| let deleted = false; | ||
| if (Module._cache[filename] !== undefined) { | ||
| delete Module._cache[filename]; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is exhibiting a very common mistake of CommonJS cache clearing - you must also delete it from the children arrays of every module that loads it. It otherwise always leaks and is worse than most user-land solutions that takes care of it (or if somehow one module uses this API and another uses a working user-land solution, and they end up in the same process, this now defeats that working user-land solution because they can no longer purge the children references). |
||
| deleted = true; | ||
| } | ||
| if (deletePathCacheEntries(filename)) { | ||
| deleted = true; | ||
| } | ||
| if (deleteRelativeResolveCacheEntries(filename)) { | ||
| deleted = true; | ||
| } | ||
| result.cjs = deleted; | ||
| } | ||
| } catch (err) { | ||
| if (mode === 'cjs') { | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (mode !== 'cjs') { | ||
| try { | ||
| const url = resolveClearCacheURL(specifier, parentURL, importAttributes); | ||
| const cascadedLoader = | ||
| require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); | ||
| const loadDeleted = cascadedLoader.loadCache.deleteAll(url); | ||
| const resolveDeleted = cascadedLoader.deleteResolveCache(url); | ||
| result.esm = loadDeleted || resolveDeleted; | ||
| } catch (err) { | ||
| if (mode === 'esm') { | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a path is relative | ||
| * @param {string} path the target path | ||
|
|
@@ -2044,6 +2284,7 @@ function isRelative(path) { | |
| } | ||
|
|
||
| Module.createRequire = createRequire; | ||
| Module.clearCache = clearCache; | ||
|
|
||
| /** | ||
| * Define the paths to use for resolving a module. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the resolution and loading cache should be separated instead of being mixed together:
Other than that, there is also the question of CommonJS/ESM cache complexity that isn't really surfaced in the current API: CommonJS uses filename as loading cache keys while ESM uses URLs, it gets even hairier when
import cjsandrequire(esm)happens, because you can have multiple file URLs mapping to the same file path with search parameters (more of a problem forimport cjs). This may not play well with how hot module reloading solutions import a full URL with changing search parameters in facade modules - if you don't iterate over all the variants with different search parameters, it will still leak. I am not sure what's the best way forward, though, maybe maintainers of these modules would know what would work for them in this case?