From bff0cbbe565ed97255814e2831cc0663f3bc071f Mon Sep 17 00:00:00 2001 From: Marcel Laverdet Date: Tue, 7 Nov 2023 14:58:35 -0500 Subject: [PATCH 1/5] module: add `releaseLoadedModule` This is a new utility as discussed in #49442 which provides the ability to release a loaded module. Releasing a module means that it can be garbage collected (if it is abandoned) and reloaded (by importing it again with the same specifier). --- lib/internal/modules/esm/loader.js | 28 ++++++++++++++++++++++++++ lib/internal/modules/esm/module_map.js | 23 +++++++++++++++++++++ lib/module.js | 3 ++- test/es-module/test-esm-evict.mjs | 16 +++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 test/es-module/test-esm-evict.mjs diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 6044765c3709f5..c53b16841e946e 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -223,6 +223,25 @@ class ModuleLoader { }; } + /** + * Evict saved result of `resolve` and `load` for the given import parameters. + */ + evict(specifier, parentURL, importAttributes = {}) { + let resolved; + try { + resolved = this.resolveSync(specifier, parentURL, importAttributes); + } catch { + return false; + } + const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes); + let didEvict = this.#resolveCache.delete(requestKey, parentURL); + if (this.loadCache.delete(resolved.url, importAttributes.type)) { + // nb: Careful with short-circuits here, we want to run both deletes unconditionally. + didEvict = true; + } + return didEvict; + } + /** * Get a (possibly still pending) module job from the cache, * or create one and return its Promise. @@ -618,8 +637,17 @@ function register(specifier, parentURL = undefined, options) { ); } +/** + * Release saved results of `resolve` and `load` for the given import parameters. + */ +function releaseLoadedModule(specifier, parentURL, importAssertions) { + const moduleLoader = require('internal/process/esm_loader').esmLoader; + return moduleLoader.evict(specifier, parentURL, importAssertions); +} + module.exports = { createModuleLoader, getHooksProxy, register, + releaseLoadedModule, }; diff --git a/lib/internal/modules/esm/module_map.js b/lib/internal/modules/esm/module_map.js index eab00386c413a5..493e20fa6e0f1f 100644 --- a/lib/internal/modules/esm/module_map.js +++ b/lib/internal/modules/esm/module_map.js @@ -59,6 +59,17 @@ class ResolveCache extends SafeMap { return internalCache; } + /** + * @param {string} serializedKey + * @param {string} parentURL + * @returns {boolean} + */ + delete(serializedKey, parentURL) { + const has = this.has(serializedKey, parentURL); + delete this.#getModuleCachedImports(parentURL)[serializedKey]; + return has; + } + /** * @param {string} serializedKey * @param {string} parentURL @@ -88,6 +99,18 @@ class ResolveCache extends SafeMap { */ class LoadCache extends SafeMap { constructor(i) { super(i); } // eslint-disable-line no-useless-constructor + delete(url, type = kImplicitAssertType) { + validateString(url, 'url'); + validateString(type, 'type'); + const cachedJobsForUrl = super.get(url); + if (cachedJobsForUrl) { + const has = type in cachedJobsForUrl; + delete cachedJobsForUrl[type]; + return has; + } else { + return false; + } + } get(url, type = kImplicitAssertType) { validateString(url, 'url'); validateString(type, 'type'); diff --git a/lib/module.js b/lib/module.js index ee90e92f53093c..27b8d4e7ebed37 100644 --- a/lib/module.js +++ b/lib/module.js @@ -2,10 +2,11 @@ const { findSourceMap } = require('internal/source_map/source_map_cache'); const { Module } = require('internal/modules/cjs/loader'); -const { register } = require('internal/modules/esm/loader'); +const { releaseLoadedModule, register } = require('internal/modules/esm/loader'); const { SourceMap } = require('internal/source_map/source_map'); Module.findSourceMap = findSourceMap; Module.register = register; +Module.releaseLoadedModule = releaseLoadedModule; Module.SourceMap = SourceMap; module.exports = Module; diff --git a/test/es-module/test-esm-evict.mjs b/test/es-module/test-esm-evict.mjs new file mode 100644 index 00000000000000..45d781426e179a --- /dev/null +++ b/test/es-module/test-esm-evict.mjs @@ -0,0 +1,16 @@ +import { strictEqual } from 'node:assert'; +import { releaseLoadedModule } from 'node:module'; + +const specifier = "data:application/javascript,export default globalThis.value;"; + +globalThis.value = 1; +const instance1 = await import(specifier); +strictEqual(instance1.default, 1); +globalThis.value = 2; +const instance2 = await import(specifier); +strictEqual(instance2.default, 1); + +strictEqual(releaseLoadedModule(specifier, import.meta.url), true); +strictEqual(releaseLoadedModule(specifier, import.meta.url), false); +const instance3 = await import(specifier); +strictEqual(instance3.default, 2); From 98928a2e181d1404889e2c5a8a700b06357c529a Mon Sep 17 00:00:00 2001 From: Marcel Laverdet Date: Tue, 7 Nov 2023 15:09:59 -0500 Subject: [PATCH 2/5] Fix lint issues --- lib/internal/modules/esm/module_map.js | 3 +-- test/es-module/test-esm-evict.mjs | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/internal/modules/esm/module_map.js b/lib/internal/modules/esm/module_map.js index 493e20fa6e0f1f..2ccbe216747cc5 100644 --- a/lib/internal/modules/esm/module_map.js +++ b/lib/internal/modules/esm/module_map.js @@ -107,9 +107,8 @@ class LoadCache extends SafeMap { const has = type in cachedJobsForUrl; delete cachedJobsForUrl[type]; return has; - } else { - return false; } + return false; } get(url, type = kImplicitAssertType) { validateString(url, 'url'); diff --git a/test/es-module/test-esm-evict.mjs b/test/es-module/test-esm-evict.mjs index 45d781426e179a..5962f0037dfbbb 100644 --- a/test/es-module/test-esm-evict.mjs +++ b/test/es-module/test-esm-evict.mjs @@ -1,7 +1,8 @@ +import '../common/index.mjs'; import { strictEqual } from 'node:assert'; import { releaseLoadedModule } from 'node:module'; -const specifier = "data:application/javascript,export default globalThis.value;"; +const specifier = 'data:application/javascript,export default globalThis.value;'; globalThis.value = 1; const instance1 = await import(specifier); From 9425692256837eb6daa06a26b47a4a251d4a37b2 Mon Sep 17 00:00:00 2001 From: Marcel Laverdet Date: Tue, 21 Nov 2023 17:49:42 -0600 Subject: [PATCH 3/5] Ensure consistency of resolve cache --- lib/internal/modules/esm/assert.js | 2 ++ lib/internal/modules/esm/loader.js | 29 +++++++++--------------- lib/internal/modules/esm/module_map.js | 31 ++++++++++++++++++++------ lib/module.js | 4 ++-- test/es-module/test-esm-evict.mjs | 6 ++--- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/lib/internal/modules/esm/assert.js b/lib/internal/modules/esm/assert.js index 5672f8c8f9959d..55d8bbda80ecfb 100644 --- a/lib/internal/modules/esm/assert.js +++ b/lib/internal/modules/esm/assert.js @@ -17,6 +17,7 @@ const { // The HTML spec has an implied default type of `'javascript'`. const kImplicitAssertType = 'javascript'; +const kAnyAssertType = ''; /** * Define a map of module formats to import attributes types (the value of @@ -112,5 +113,6 @@ function handleInvalidType(url, type) { module.exports = { kImplicitAssertType, + kAnyAssertType, validateAttributes, }; diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index c53b16841e946e..f8a540e51e55c6 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -20,6 +20,7 @@ const { ERR_REQUIRE_ESM, ERR_UNKNOWN_MODULE_FORMAT, } = require('internal/errors').codes; +const { kAnyAssertType } = require('internal/modules/esm/assert'); const { getOptionValue } = require('internal/options'); const { pathToFileURL, isURL } = require('internal/url'); const { emitExperimentalWarning } = require('internal/util'); @@ -224,22 +225,11 @@ class ModuleLoader { } /** - * Evict saved result of `resolve` and `load` for the given import parameters. + * Evict saved result of `resolve` and `load` for the given resolved URL. */ - evict(specifier, parentURL, importAttributes = {}) { - let resolved; - try { - resolved = this.resolveSync(specifier, parentURL, importAttributes); - } catch { - return false; - } - const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes); - let didEvict = this.#resolveCache.delete(requestKey, parentURL); - if (this.loadCache.delete(resolved.url, importAttributes.type)) { - // nb: Careful with short-circuits here, we want to run both deletes unconditionally. - didEvict = true; - } - return didEvict; + evict(resolvedURL) { + this.#resolveCache.clearResolvedURL(resolvedURL); + this.loadCache.delete(resolvedURL, kAnyAssertType); } /** @@ -638,16 +628,17 @@ function register(specifier, parentURL = undefined, options) { } /** - * Release saved results of `resolve` and `load` for the given import parameters. + * Release saved results of `resolve` and `load` for the given resolved module + * URL. */ -function releaseLoadedModule(specifier, parentURL, importAssertions) { +function releaseResolvedModule(resolvedURL) { const moduleLoader = require('internal/process/esm_loader').esmLoader; - return moduleLoader.evict(specifier, parentURL, importAssertions); + return moduleLoader.evict(resolvedURL); } module.exports = { createModuleLoader, getHooksProxy, register, - releaseLoadedModule, + releaseResolvedModule, }; diff --git a/lib/internal/modules/esm/module_map.js b/lib/internal/modules/esm/module_map.js index 2ccbe216747cc5..e1c777b1e28371 100644 --- a/lib/internal/modules/esm/module_map.js +++ b/lib/internal/modules/esm/module_map.js @@ -8,7 +8,10 @@ const { ObjectKeys, SafeMap, } = primordials; -const { kImplicitAssertType } = require('internal/modules/esm/assert'); +const { + kImplicitAssertType, + kAnyAssertType, +} = require('internal/modules/esm/assert'); let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); @@ -60,14 +63,24 @@ class ResolveCache extends SafeMap { } /** - * @param {string} serializedKey * @param {string} parentURL - * @returns {boolean} */ - delete(serializedKey, parentURL) { - const has = this.has(serializedKey, parentURL); - delete this.#getModuleCachedImports(parentURL)[serializedKey]; - return has; + clearResolvedURL(resolvedURL) { + for (const entry of this.entries()) { + const internalCache = entry[1]; + let isEmpty = true; + for (const serializedKey of ObjectKeys(internalCache)) { + if (internalCache[serializedKey].url === resolvedURL) { + delete internalCache[serializedKey]; + } else { + isEmpty = false; + } + } + if (isEmpty) { + const parentURL = entry[0]; + this.delete(parentURL); + } + } } /** @@ -104,6 +117,10 @@ class LoadCache extends SafeMap { validateString(type, 'type'); const cachedJobsForUrl = super.get(url); if (cachedJobsForUrl) { + if (type === kAnyAssertType) { + super.delete(url); + return true; + } const has = type in cachedJobsForUrl; delete cachedJobsForUrl[type]; return has; diff --git a/lib/module.js b/lib/module.js index 27b8d4e7ebed37..f4cd6f5d1f8bc3 100644 --- a/lib/module.js +++ b/lib/module.js @@ -2,11 +2,11 @@ const { findSourceMap } = require('internal/source_map/source_map_cache'); const { Module } = require('internal/modules/cjs/loader'); -const { releaseLoadedModule, register } = require('internal/modules/esm/loader'); +const { releaseResolvedModule, register } = require('internal/modules/esm/loader'); const { SourceMap } = require('internal/source_map/source_map'); Module.findSourceMap = findSourceMap; Module.register = register; -Module.releaseLoadedModule = releaseLoadedModule; +Module.releaseResolvedModule = releaseResolvedModule; Module.SourceMap = SourceMap; module.exports = Module; diff --git a/test/es-module/test-esm-evict.mjs b/test/es-module/test-esm-evict.mjs index 5962f0037dfbbb..8a49f25a9e4343 100644 --- a/test/es-module/test-esm-evict.mjs +++ b/test/es-module/test-esm-evict.mjs @@ -1,6 +1,6 @@ import '../common/index.mjs'; import { strictEqual } from 'node:assert'; -import { releaseLoadedModule } from 'node:module'; +import { releaseResolvedModule } from 'node:module'; const specifier = 'data:application/javascript,export default globalThis.value;'; @@ -11,7 +11,7 @@ globalThis.value = 2; const instance2 = await import(specifier); strictEqual(instance2.default, 1); -strictEqual(releaseLoadedModule(specifier, import.meta.url), true); -strictEqual(releaseLoadedModule(specifier, import.meta.url), false); +releaseResolvedModule(specifier); const instance3 = await import(specifier); strictEqual(instance3.default, 2); +delete globalThis.value; From e4d6b76d9a01c792642df63c5e2f8ab7365a5104 Mon Sep 17 00:00:00 2001 From: Marcel Laverdet Date: Tue, 21 Nov 2023 17:57:28 -0600 Subject: [PATCH 4/5] Doc placeholder --- doc/api/module.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/doc/api/module.md b/doc/api/module.md index f4338028abe31d..6259f208de3a3a 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -107,6 +107,18 @@ changes: Register a module that exports [hooks][] that customize Node.js module resolution and loading behavior. See [Customization hooks][]. +### `module.releaseResolvedModule(resolvedURL)` + + + +> Stability: 1 - Experimental + +* `resolvedURL` {string} The fully-resolved URL of a module. + +Clears the given module from the ESM resolve and load caches. + ### `module.syncBuiltinESMExports()`