Skip to content

Commit 84be528

Browse files
esm: add deregister method
Suggestion from @GeoffreyBooth. Adds `deregister` method on `node:module` that looks like this: ```ts type Deregister = (id: string) => boolean; ``` Modifies the initialize hook to look like this: ```ts type Initialize = (data: any, meta: {id: opaque}) => Promise<any>; ``` Internally registered instances of hooks are now tracked. This is so they can be removed later. The id of the registered instance is now passed to the `initialize` hook. The return value of register is now the loader id. This is mapped to a symbol outside of the loader thread to prevent abuse. ```js // Caller import {register, deregister} from "node:module"; const id = register(...); // ... deregister(id); ```
1 parent de4553f commit 84be528

File tree

4 files changed

+192
-78
lines changed

4 files changed

+192
-78
lines changed

lib/internal/modules/esm/hooks.js

Lines changed: 69 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ const {
5454
} = require('internal/util');
5555

5656
const {
57-
defaultResolve,
5857
throwIfInvalidParentURL,
5958
} = require('internal/modules/esm/resolve');
6059
const {
@@ -87,51 +86,26 @@ let importMetaInitializer;
8786
// [2] `validate...()`s throw the wrong error
8887

8988
class Hooks {
90-
#chains = {
91-
/**
92-
* Prior to ESM loading. These are called once before any modules are started.
93-
* @private
94-
* @property {KeyedHook[]} globalPreload Last-in-first-out list of preload hooks.
95-
*/
96-
globalPreload: [],
97-
98-
/**
99-
* Phase 1 of 2 in ESM loading.
100-
* The output of the `resolve` chain of hooks is passed into the `load` chain of hooks.
101-
* @private
102-
* @property {KeyedHook[]} resolve Last-in-first-out collection of resolve hooks.
103-
*/
104-
resolve: [
105-
{
106-
fn: defaultResolve,
107-
url: 'node:internal/modules/esm/resolve',
108-
},
109-
],
110-
111-
/**
112-
* Phase 2 of 2 in ESM loading.
113-
* @private
114-
* @property {KeyedHook[]} load Last-in-first-out collection of loader hooks.
115-
*/
116-
load: [
117-
{
118-
fn: require('internal/modules/esm/load').defaultLoad,
119-
url: 'node:internal/modules/esm/load',
120-
},
121-
],
122-
};
89+
#loaderInstances = [];
90+
#lastInstanceId = 0;
12391

12492
// Cache URLs we've already validated to avoid repeated validation
12593
#validatedUrls = new SafeSet();
12694

12795
allowImportMetaResolve = false;
12896

97+
constructor() {
98+
const defaultLoader = 'internal/modules/esm/default_loader';
99+
this.addCustomLoader(`node:${defaultLoader}`, require(defaultLoader));
100+
}
101+
129102
/**
130103
* Import and register custom/user-defined module loader hook(s).
131104
* @param {string} urlOrSpecifier
132105
* @param {string} parentURL
133106
* @param {any} [data] Arbitrary data to be passed from the custom
134107
* loader (user-land) to the worker.
108+
* @returns {Promise<number>} The id of the registered loader.
135109
*/
136110
async register(urlOrSpecifier, parentURL, data) {
137111
const moduleLoader = require('internal/process/esm_loader').esmLoader;
@@ -143,46 +117,71 @@ class Hooks {
143117
return this.addCustomLoader(urlOrSpecifier, keyedExports, data);
144118
}
145119

120+
deregister(id) {
121+
return this.removeCustomLoader(id);
122+
}
123+
146124
/**
147125
* Collect custom/user-defined module loader hook(s).
148126
* After all hooks have been collected, the global preload hook(s) must be initialized.
149127
* @param {string} url Custom loader specifier
150128
* @param {Record<string, unknown>} exports
151129
* @param {any} [data] Arbitrary data to be passed from the custom loader (user-land)
152130
* to the worker.
153-
* @returns {any} The result of the loader's `initialize` hook, if provided.
131+
* @returns {Promise<number>} The id of the registered loader.
154132
*/
155-
addCustomLoader(url, exports, data) {
133+
async addCustomLoader(url, exports, data) {
156134
const {
157135
globalPreload,
158136
initialize,
159137
resolve,
160138
load,
161139
} = pluckHooks(exports);
162-
163140
if (globalPreload && !initialize) {
164141
emitExperimentalWarning(
165142
'`globalPreload` is planned for removal in favor of `initialize`. `globalPreload`',
166143
);
167-
ArrayPrototypePush(this.#chains.globalPreload, { __proto__: null, fn: globalPreload, url });
168-
}
169-
if (resolve) {
170-
const next = this.#chains.resolve[this.#chains.resolve.length - 1];
171-
ArrayPrototypePush(this.#chains.resolve, { __proto__: null, fn: resolve, url, next });
172144
}
173-
if (load) {
174-
const next = this.#chains.load[this.#chains.load.length - 1];
175-
ArrayPrototypePush(this.#chains.load, { __proto__: null, fn: load, url, next });
145+
const next = this.#loaderInstances[this.#loaderInstances.length - 1];
146+
const instance = {
147+
__proto__: null,
148+
id: ++this.#lastInstanceId,
149+
url,
150+
globalPreload,
151+
initialize,
152+
resolve,
153+
load,
154+
next,
155+
};
156+
ArrayPrototypePush(this.#loaderInstances, instance);
157+
instance.state = await initialize?.(data, { __proto__: null, id: instance.id, url });
158+
return instance.id;
159+
}
160+
161+
removeCustomLoader(id) {
162+
// This loop purposefully has `> 0` in order to prevent people from
163+
// removing the first loader (i.e. the default one).
164+
for (let i = this.#loaderInstances.length - 1; i > 0; --i) {
165+
if (id === this.#loaderInstances[i].id) {
166+
if (i + 1 < this.#loaderInstances.length) {
167+
this.#loaderInstances[i + 1].next = this.#loaderInstances[i - 1];
168+
}
169+
this.#loaderInstances.splice(i, 1);
170+
return true;
171+
}
176172
}
177-
return initialize?.(data);
173+
return false;
178174
}
179175

180176
/**
181177
* Initialize `globalPreload` hooks.
182178
*/
183179
initializeGlobalPreload() {
184180
const preloadScripts = [];
185-
for (let i = this.#chains.globalPreload.length - 1; i >= 0; i--) {
181+
for (const loader of this.#loaderInstances) {
182+
if (!loader.globalPreload) {
183+
continue;
184+
}
186185
const { MessageChannel } = require('internal/worker/io');
187186
const channel = new MessageChannel();
188187
const {
@@ -193,10 +192,7 @@ class Hooks {
193192
insidePreload.unref();
194193
insideLoader.unref();
195194

196-
const {
197-
fn: preload,
198-
url: specifier,
199-
} = this.#chains.globalPreload[i];
195+
const preload = loader.globalPreload;
200196

201197
const preloaded = preload({
202198
port: insideLoader,
@@ -207,8 +203,8 @@ class Hooks {
207203
if (typeof preloaded !== 'string') { // [2]
208204
throw new ERR_INVALID_RETURN_VALUE(
209205
'a string',
210-
`${specifier} globalPreload`,
211-
preload,
206+
`${loader.url} globalPreload`,
207+
loader.globalPreload,
212208
);
213209
}
214210

@@ -240,7 +236,6 @@ class Hooks {
240236
) {
241237
throwIfInvalidParentURL(parentURL);
242238

243-
const chain = this.#chains.resolve;
244239
const context = {
245240
conditions: getDefaultConditions(),
246241
importAssertions,
@@ -272,7 +267,11 @@ class Hooks {
272267
}
273268
};
274269

275-
const nextResolve = nextHookFactory(chain[chain.length - 1], meta, { validateArgs, validateOutput });
270+
const nextResolve = nextHookFactory(
271+
this.#loaderInstances[this.#loaderInstances.length - 1],
272+
meta,
273+
{ validateArgs, validateOutput },
274+
);
276275

277276
const resolution = await nextResolve(originalSpecifier, context);
278277
const { hookErrIdentifier } = meta; // Retrieve the value after all settled
@@ -364,7 +363,6 @@ class Hooks {
364363
* @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>}
365364
*/
366365
async load(url, context = {}) {
367-
const chain = this.#chains.load;
368366
const meta = {
369367
chainFinished: null,
370368
context,
@@ -410,7 +408,11 @@ class Hooks {
410408
}
411409
};
412410

413-
const nextLoad = nextHookFactory(chain[chain.length - 1], meta, { validateArgs, validateOutput });
411+
const nextLoad = nextHookFactory(
412+
this.#loaderInstances[this.#loaderInstances.length - 1],
413+
meta,
414+
{ validateArgs, validateOutput },
415+
);
414416

415417
const loaded = await nextLoad(url, context);
416418
const { hookErrIdentifier } = meta; // Retrieve the value after all settled
@@ -789,11 +791,13 @@ function pluckHooks({
789791
function nextHookFactory(current, meta, { validateArgs, validateOutput }) {
790792
// First, prepare the current
791793
const { hookName } = meta;
792-
const {
793-
fn: hook,
794-
url: hookFilePath,
795-
next,
796-
} = current;
794+
795+
const { next, state, url: hookFilePath } = current;
796+
const hook = current[hookName];
797+
798+
if (!hook) {
799+
return nextHookFactory(next, meta, { validateArgs, validateOutput });
800+
}
797801

798802
// ex 'nextResolve'
799803
const nextHookName = `next${
@@ -828,8 +832,9 @@ function nextHookFactory(current, meta, { validateArgs, validateOutput }) {
828832
if (context) { // `context` has already been validated, so no fancy check needed.
829833
ObjectAssign(meta.context, context);
830834
}
831-
832-
const output = await hook(arg0, meta.context, nextNextHook);
835+
const output = hook.length === 4 ?
836+
await hook(arg0, meta.context, state, nextNextHook) :
837+
await hook(arg0, meta.context, nextNextHook);
833838
validateOutput(outputErrIdentifier, output);
834839

835840
if (output?.shortCircuit === true) { meta.shortCircuited = true; }

lib/internal/modules/esm/loader.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ require('internal/modules/cjs/loader');
66
const {
77
FunctionPrototypeCall,
88
ObjectSetPrototypeOf,
9+
SafeMap,
910
SafeWeakMap,
11+
Symbol,
1012
} = primordials;
1113

1214
const {
@@ -323,6 +325,15 @@ class ModuleLoader {
323325
return this.#customizations.register(specifier, parentURL, data, transferList);
324326
}
325327

328+
deregister(id) {
329+
// They have had to register customizations before this method does
330+
// anything useful.
331+
if (!this.#customizations) {
332+
return false;
333+
}
334+
return this.#customizations.deregister(id);
335+
}
336+
326337
/**
327338
* Resolve the location of the module.
328339
* @param {string} originalSpecifier The specified URL path of the module to
@@ -435,6 +446,8 @@ ObjectSetPrototypeOf(ModuleLoader.prototype, null);
435446
class CustomizedModuleLoader {
436447

437448
allowImportMetaResolve = true;
449+
#symbolForwardMap = new SafeMap();
450+
#symbolReverseMap = new SafeMap();
438451

439452
/**
440453
* Instantiate a module loader that uses user-provided custom loader hooks.
@@ -452,10 +465,32 @@ class CustomizedModuleLoader {
452465
* @param {any} [data] Arbitrary data to be passed from the custom loader
453466
* (user-land) to the worker.
454467
* @param {any[]} [transferList] Objects in `data` that are changing ownership
455-
* @returns {{ format: string, url: URL['href'] }}
468+
* @returns {symbol} Unique identifier for the loader instance.
456469
*/
457470
register(originalSpecifier, parentURL, data, transferList) {
458-
return hooksProxy.makeSyncRequest('register', transferList, originalSpecifier, parentURL, data);
471+
const id = hooksProxy.makeSyncRequest('register', transferList, originalSpecifier, parentURL, data);
472+
let symbol = this.#symbolForwardMap.get(id);
473+
if (!symbol) {
474+
symbol = Symbol(`${originalSpecifier}-${id}`);
475+
this.#symbolForwardMap.set(id, symbol);
476+
this.#symbolReverseMap.set(symbol, id);
477+
}
478+
return symbol;
479+
}
480+
481+
/**
482+
* Remove a loader and all its hooks from the module system.
483+
* @param {symbol} symbol The value from calling `register()`
484+
* @returns {boolean} True if the loader was de-registered, false otherwise
485+
*/
486+
deregister(symbol) {
487+
const id = this.#symbolReverseMap.get(symbol);
488+
if (id && hooksProxy.makeSyncRequest('deregister', undefined, id)) {
489+
this.#symbolForwardMap.delete(id);
490+
this.#symbolReverseMap.delete(symbol);
491+
return true;
492+
}
493+
return false;
459494
}
460495

461496
/**
@@ -582,8 +617,14 @@ function register(specifier, parentURL = undefined, options) {
582617
);
583618
}
584619

620+
function deregister(id) {
621+
const moduleLoader = require('internal/process/esm_loader').esmLoader;
622+
return moduleLoader.deregister(id);
623+
}
624+
585625
module.exports = {
586626
createModuleLoader,
587627
getHooksProxy,
588628
register,
629+
deregister,
589630
};

lib/module.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
const { findSourceMap } = require('internal/source_map/source_map_cache');
44
const { Module } = require('internal/modules/cjs/loader');
5-
const { register } = require('internal/modules/esm/loader');
5+
const { register, deregister } = require('internal/modules/esm/loader');
66
const { SourceMap } = require('internal/source_map/source_map');
77

88
Module.findSourceMap = findSourceMap;
99
Module.register = register;
10+
Module.deregister = deregister;
1011
Module.SourceMap = SourceMap;
1112
module.exports = Module;

0 commit comments

Comments
 (0)