Skip to content

Commit 65dfe85

Browse files
jlenon7targos
authored andcommitted
module: implement register utility
PR-URL: #46826 Backport-PR-URL: #50669 Reviewed-By: Jacob Smith <jacob@frende.me> Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent 2ef80f1 commit 65dfe85

21 files changed

+490
-10
lines changed

doc/api/errors.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,23 @@ provided.
12341234
Encoding provided to `TextDecoder()` API was not one of the
12351235
[WHATWG Supported Encodings][].
12361236

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

12391256
### `ERR_EVAL_ESM_CANNOT_PRINT`

doc/api/esm.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,17 @@ console.log('some module!');
12361236
If you run `node --experimental-loader ./import-map-loader.js main.js`
12371237
the output will be `some module!`.
12381238
1239+
### Register loaders programmatically
1240+
1241+
<!-- YAML
1242+
added: REPLACEME
1243+
-->
1244+
1245+
In addition to using the `--experimental-loader` option in the CLI,
1246+
loaders can also be registered programmatically. You can find
1247+
detailed information about this process in the documentation page
1248+
for [`module.register()`][].
1249+
12391250
## Resolution and loading algorithm
12401251
12411252
### Features
@@ -1632,6 +1643,7 @@ success!
16321643
[`import.meta.url`]: #importmetaurl
16331644
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
16341645
[`module.createRequire()`]: module.md#modulecreaterequirefilename
1646+
[`module.register()`]: module.md#moduleregister
16351647
[`module.syncBuiltinESMExports()`]: module.md#modulesyncbuiltinesmexports
16361648
[`package.json`]: packages.md#nodejs-packagejson-field-definitions
16371649
[`port.ref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portref

doc/api/module.md

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

lib/internal/errors.js

Lines changed: 5 additions & 0 deletions
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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const {
4444
validateString,
4545
} = require('internal/validators');
4646

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

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

lib/internal/modules/esm/loader.js

Lines changed: 53 additions & 6 deletions
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');
@@ -297,12 +298,19 @@ class CustomizedModuleLoader extends DefaultModuleLoader {
297298
constructor() {
298299
super();
299300

300-
if (hooksProxy) {
301-
// The worker proxy is shared across all instances, so don't recreate it if it already exists.
302-
return;
303-
}
304-
const { HooksProxy } = require('internal/modules/esm/hooks');
305-
hooksProxy = new HooksProxy(); // The user's custom hooks are loaded within the worker as part of its startup.
301+
getHooksProxy();
302+
}
303+
304+
/**
305+
* Register some loader specifier.
306+
* @param {string} originalSpecifier The specified URL path of the loader to
307+
* be registered.
308+
* @param {string} parentURL The parent URL from where the loader will be
309+
* registered if using it package name as specifier
310+
* @returns {{ format: string, url: URL['href'] }}
311+
*/
312+
register(originalSpecifier, parentURL) {
313+
return hooksProxy.makeSyncRequest('register', originalSpecifier, parentURL);
306314
}
307315

308316
/**
@@ -380,7 +388,46 @@ function createModuleLoader(useCustomLoadersIfPresent = true) {
380388
return new DefaultModuleLoader();
381389
}
382390

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

lib/internal/modules/esm/utils.js

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 22 additions & 0 deletions
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)