Skip to content

Commit 5aef593

Browse files
jlenon7RafaelGSS
authored andcommitted
module: implement register utility
PR-URL: #46826 Reviewed-By: Jacob Smith <[email protected]> Reviewed-By: Geoffrey Booth <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]>
1 parent fab6f58 commit 5aef593

21 files changed

+490
-10
lines changed

doc/api/errors.md

+17
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,23 @@ provided.
12331233
Encoding provided to `TextDecoder()` API was not one of the
12341234
[WHATWG Supported Encodings][].
12351235

1236+
<a id="ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE"></a>
1237+
1238+
### `ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE`
1239+
1240+
<!-- YAML
1241+
added: REPLACEME
1242+
-->
1243+
1244+
Programmatically registering custom ESM loaders
1245+
currently requires at least one custom loader to have been
1246+
registered via the `--experimental-loader` flag. A no-op
1247+
loader registered via CLI is sufficient
1248+
(for example: `--experimental-loader data:text/javascript,`;
1249+
do not omit the necessary trailing comma).
1250+
A future version of Node.js will support the programmatic
1251+
registration of loaders without needing to also use the flag.
1252+
12361253
<a id="ERR_EVAL_ESM_CANNOT_PRINT"></a>
12371254

12381255
### `ERR_EVAL_ESM_CANNOT_PRINT`

doc/api/esm.md

+12
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,17 @@ console.log('some module!');
12251225
If you run `node --experimental-loader ./import-map-loader.js main.js`
12261226
the output will be `some module!`.
12271227
1228+
### Register loaders programmatically
1229+
1230+
<!-- YAML
1231+
added: REPLACEME
1232+
-->
1233+
1234+
In addition to using the `--experimental-loader` option in the CLI,
1235+
loaders can also be registered programmatically. You can find
1236+
detailed information about this process in the documentation page
1237+
for [`module.register()`][].
1238+
12281239
## Resolution and loading algorithm
12291240
12301241
### Features
@@ -1599,6 +1610,7 @@ for ESM specifiers is [commonjs-extension-resolution-loader][].
15991610
[`import.meta.url`]: #importmetaurl
16001611
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
16011612
[`module.createRequire()`]: module.md#modulecreaterequirefilename
1613+
[`module.register()`]: module.md#moduleregister
16021614
[`module.syncBuiltinESMExports()`]: module.md#modulesyncbuiltinesmexports
16031615
[`package.json`]: packages.md#nodejs-packagejson-field-definitions
16041616
[`port.ref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portref

doc/api/module.md

+95
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,101 @@ isBuiltin('fs'); // true
8080
isBuiltin('wss'); // false
8181
```
8282
83+
### `module.register()`
84+
85+
<!-- YAML
86+
added: REPLACEME
87+
-->
88+
89+
In addition to using the `--experimental-loader` option in the CLI,
90+
loaders can be registered programmatically using the
91+
`module.register()` method.
92+
93+
```mjs
94+
import { register } from 'node:module';
95+
96+
register('http-to-https', import.meta.url);
97+
98+
// Because this is a dynamic `import()`, the `http-to-https` hooks will run
99+
// before importing `./my-app.mjs`.
100+
await import('./my-app.mjs');
101+
```
102+
103+
In the example above, we are registering the `http-to-https` loader,
104+
but it will only be available for subsequently imported modules—in
105+
this case, `my-app.mjs`. If the `await import('./my-app.mjs')` had
106+
instead been a static `import './my-app.mjs'`, _the app would already
107+
have been loaded_ before the `http-to-https` hooks were
108+
registered. This is part of the design of ES modules, where static
109+
imports are evaluated from the leaves of the tree first back to the
110+
trunk. There can be static imports _within_ `my-app.mjs`, which
111+
will not be evaluated until `my-app.mjs` is when it's dynamically
112+
imported.
113+
114+
The `--experimental-loader` flag of the CLI can be used together
115+
with the `register` function; the loaders registered with the
116+
function will follow the same evaluation chain of loaders registered
117+
within the CLI:
118+
119+
```console
120+
node \
121+
--experimental-loader unpkg \
122+
--experimental-loader http-to-https \
123+
--experimental-loader cache-buster \
124+
entrypoint.mjs
125+
```
126+
127+
```mjs
128+
// entrypoint.mjs
129+
import { URL } from 'node:url';
130+
import { register } from 'node:module';
131+
132+
const loaderURL = new URL('./my-programmatically-loader.mjs', import.meta.url);
133+
134+
register(loaderURL);
135+
await import('./my-app.mjs');
136+
```
137+
138+
The `my-programmatic-loader.mjs` can leverage `unpkg`,
139+
`http-to-https`, and `cache-buster` loaders.
140+
141+
It's also possible to use `register` more than once:
142+
143+
```mjs
144+
// entrypoint.mjs
145+
import { URL } from 'node:url';
146+
import { register } from 'node:module';
147+
148+
register(new URL('./first-loader.mjs', import.meta.url));
149+
register('./second-loader.mjs', import.meta.url);
150+
await import('./my-app.mjs');
151+
```
152+
153+
Both loaders (`first-loader.mjs` and `second-loader.mjs`) can use
154+
all the resources provided by the loaders registered in the CLI. But
155+
remember that they will only be available in the next imported
156+
module (`my-app.mjs`). The evaluation order of the hooks when
157+
importing `my-app.mjs` and consecutive modules in the example above
158+
will be:
159+
160+
```console
161+
resolve: second-loader.mjs
162+
resolve: first-loader.mjs
163+
resolve: cache-buster
164+
resolve: http-to-https
165+
resolve: unpkg
166+
load: second-loader.mjs
167+
load: first-loader.mjs
168+
load: cache-buster
169+
load: http-to-https
170+
load: unpkg
171+
globalPreload: second-loader.mjs
172+
globalPreload: first-loader.mjs
173+
globalPreload: cache-buster
174+
globalPreload: http-to-https
175+
globalPreload: unpkg
176+
```
177+
83178
### `module.syncBuiltinESMExports()`
84179
85180
<!-- YAML

lib/internal/errors.js

+5
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,11 @@ E('ERR_ENCODING_INVALID_ENCODED_DATA', function(encoding, ret) {
10391039
}, TypeError);
10401040
E('ERR_ENCODING_NOT_SUPPORTED', 'The "%s" encoding is not supported',
10411041
RangeError);
1042+
E('ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE', 'Programmatically registering custom ESM loaders ' +
1043+
'currently requires at least one custom loader to have been registered via the --experimental-loader ' +
1044+
'flag. A no-op loader registered via CLI is sufficient (for example: `--experimental-loader ' +
1045+
'"data:text/javascript,"` with the necessary trailing comma). A future version of Node.js ' +
1046+
'will remove this requirement.', Error);
10421047
E('ERR_EVAL_ESM_CANNOT_PRINT', '--print cannot be used with ESM input', Error);
10431048
E('ERR_EVENT_RECURSION', 'The event "%s" is already being dispatched', Error);
10441049
E('ERR_FALSY_VALUE_REJECTION', function(reason) {

lib/internal/modules/esm/hooks.js

+19
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const {
4545
validateString,
4646
} = require('internal/validators');
4747

48+
const { kEmptyObject } = require('internal/util');
49+
4850
const {
4951
defaultResolve,
5052
throwIfInvalidParentURL,
@@ -117,6 +119,23 @@ class Hooks {
117119
// Cache URLs we've already validated to avoid repeated validation
118120
#validatedUrls = new SafeSet();
119121

122+
/**
123+
* Import and register custom/user-defined module loader hook(s).
124+
* @param {string} urlOrSpecifier
125+
* @param {string} parentURL
126+
*/
127+
async register(urlOrSpecifier, parentURL) {
128+
const moduleLoader = require('internal/process/esm_loader').esmLoader;
129+
130+
const keyedExports = await moduleLoader.import(
131+
urlOrSpecifier,
132+
parentURL,
133+
kEmptyObject,
134+
);
135+
136+
this.addCustomLoader(urlOrSpecifier, keyedExports);
137+
}
138+
120139
/**
121140
* Collect custom/user-defined module loader hook(s).
122141
* After all hooks have been collected, the global preload hook(s) must be initialized.

lib/internal/modules/esm/loader.js

+53-6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
} = primordials;
1212

1313
const {
14+
ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE,
1415
ERR_UNKNOWN_MODULE_FORMAT,
1516
} = require('internal/errors').codes;
1617
const { getOptionValue } = require('internal/options');
@@ -287,12 +288,19 @@ class CustomizedModuleLoader extends DefaultModuleLoader {
287288
constructor() {
288289
super();
289290

290-
if (hooksProxy) {
291-
// The worker proxy is shared across all instances, so don't recreate it if it already exists.
292-
return;
293-
}
294-
const { HooksProxy } = require('internal/modules/esm/hooks');
295-
hooksProxy = new HooksProxy(); // The user's custom hooks are loaded within the worker as part of its startup.
291+
getHooksProxy();
292+
}
293+
294+
/**
295+
* Register some loader specifier.
296+
* @param {string} originalSpecifier The specified URL path of the loader to
297+
* be registered.
298+
* @param {string} parentURL The parent URL from where the loader will be
299+
* registered if using it package name as specifier
300+
* @returns {{ format: string, url: URL['href'] }}
301+
*/
302+
register(originalSpecifier, parentURL) {
303+
return hooksProxy.makeSyncRequest('register', originalSpecifier, parentURL);
296304
}
297305

298306
/**
@@ -370,7 +378,46 @@ function createModuleLoader(useCustomLoadersIfPresent = true) {
370378
return new DefaultModuleLoader();
371379
}
372380

381+
/**
382+
* Get the HooksProxy instance. If it is not defined, then create a new one.
383+
* @returns {HooksProxy}
384+
*/
385+
function getHooksProxy() {
386+
if (!hooksProxy) {
387+
const { HooksProxy } = require('internal/modules/esm/hooks');
388+
hooksProxy = new HooksProxy();
389+
}
390+
391+
return hooksProxy;
392+
}
393+
394+
/**
395+
* Register a single loader programmatically.
396+
* @param {string} specifier
397+
* @param {string} [parentURL]
398+
* @returns {void}
399+
* @example
400+
* ```js
401+
* register('./myLoader.js');
402+
* register('ts-node/esm', import.meta.url);
403+
* register('./myLoader.js', import.meta.url);
404+
* register(new URL('./myLoader.js', import.meta.url));
405+
* ```
406+
*/
407+
function register(specifier, parentURL = 'data:') {
408+
// TODO: Remove this limitation in a follow-up before `register` is released publicly
409+
if (getOptionValue('--experimental-loader').length < 1) {
410+
throw new ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE();
411+
}
412+
413+
const moduleLoader = require('internal/process/esm_loader').esmLoader;
414+
415+
moduleLoader.register(`${specifier}`, parentURL);
416+
}
417+
373418
module.exports = {
374419
DefaultModuleLoader,
375420
createModuleLoader,
421+
getHooksProxy,
422+
register,
376423
};

lib/internal/modules/esm/utils.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ async function initializeHooks() {
146146
load(url, context) { return hooks.load(url, context); }
147147
}
148148
const privateModuleLoader = new ModuleLoader();
149-
150149
const parentURL = pathToFileURL(cwd).href;
151150

151+
// TODO(jlenon7): reuse the `Hooks.register()` method for registering loaders.
152152
for (let i = 0; i < customLoaderURLs.length; i++) {
153153
const customLoaderURL = customLoaderURLs[i];
154154

lib/module.js

+2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
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');
56
const { SourceMap } = require('internal/source_map/source_map');
67

78
Module.findSourceMap = findSourceMap;
9+
Module.register = register;
810
Module.SourceMap = SourceMap;
911
module.exports = Module;

test/es-module/test-esm-loader-hooks.mjs

+22
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ describe('Loader hooks', { concurrency: true }, () => {
2424
assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/);
2525
});
2626

27+
it('are called with all expected arguments using register function', async () => {
28+
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
29+
'--no-warnings',
30+
'--experimental-loader=data:text/javascript,',
31+
'--input-type=module',
32+
'--eval',
33+
"import { register } from 'node:module';" +
34+
`register(${JSON.stringify(fixtures.fileURL('/es-module-loaders/hooks-input.mjs'))});` +
35+
`await import(${JSON.stringify(fixtures.fileURL('/es-modules/json-modules.mjs'))});`,
36+
]);
37+
38+
assert.strictEqual(stderr, '');
39+
assert.strictEqual(code, 0);
40+
assert.strictEqual(signal, null);
41+
42+
const lines = stdout.split('\n');
43+
assert.match(lines[0], /{"url":"file:\/\/\/.*\/json-modules\.mjs","format":"test","shortCircuit":true}/);
44+
assert.match(lines[1], /{"source":{"type":"Buffer","data":\[.*\]},"format":"module","shortCircuit":true}/);
45+
assert.match(lines[2], /{"url":"file:\/\/\/.*\/experimental\.json","format":"test","shortCircuit":true}/);
46+
assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/);
47+
});
48+
2749
describe('should handle never-settling hooks in ESM files', { concurrency: true }, () => {
2850
it('top-level await of a never-settling resolve', async () => {
2951
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [

0 commit comments

Comments
 (0)