module: implement register utility

PR-URL: https://github.com/nodejs/node/pull/46826
Reviewed-By: Jacob Smith <jacob@frende.me>
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
This commit is contained in:
João Lenon 2023-06-11 21:00:46 -03:00 committed by GitHub
parent d2d4a310f1
commit a40a6c890a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 490 additions and 10 deletions

View File

@ -1267,6 +1267,23 @@ provided.
Encoding provided to `TextDecoder()` API was not one of the
[WHATWG Supported Encodings][].
<a id="ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE"></a>
### `ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE`
<!-- YAML
added: REPLACEME
-->
Programmatically registering custom ESM loaders
currently requires at least one custom loader to have been
registered via the `--experimental-loader` flag. A no-op
loader registered via CLI is sufficient
(for example: `--experimental-loader data:text/javascript,`;
do not omit the necessary trailing comma).
A future version of Node.js will support the programmatic
registration of loaders without needing to also use the flag.
<a id="ERR_EVAL_ESM_CANNOT_PRINT"></a>
### `ERR_EVAL_ESM_CANNOT_PRINT`

View File

@ -1225,6 +1225,17 @@ console.log('some module!');
If you run `node --experimental-loader ./import-map-loader.js main.js`
the output will be `some module!`.
### Register loaders programmatically
<!-- YAML
added: REPLACEME
-->
In addition to using the `--experimental-loader` option in the CLI,
loaders can also be registered programmatically. You can find
detailed information about this process in the documentation page
for [`module.register()`][].
## Resolution and loading algorithm
### Features
@ -1599,6 +1610,7 @@ for ESM specifiers is [commonjs-extension-resolution-loader][].
[`import.meta.url`]: #importmetaurl
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
[`module.createRequire()`]: module.md#modulecreaterequirefilename
[`module.register()`]: module.md#moduleregister
[`module.syncBuiltinESMExports()`]: module.md#modulesyncbuiltinesmexports
[`package.json`]: packages.md#nodejs-packagejson-field-definitions
[`port.ref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portref

View File

@ -80,6 +80,101 @@ isBuiltin('fs'); // true
isBuiltin('wss'); // false
```
### `module.register()`
<!-- YAML
added: REPLACEME
-->
In addition to using the `--experimental-loader` option in the CLI,
loaders can be registered programmatically using the
`module.register()` method.
```mjs
import { register } from 'node:module';
register('http-to-https', import.meta.url);
// Because this is a dynamic `import()`, the `http-to-https` hooks will run
// before importing `./my-app.mjs`.
await import('./my-app.mjs');
```
In the example above, we are registering the `http-to-https` loader,
but it will only be available for subsequently imported modules—in
this case, `my-app.mjs`. If the `await import('./my-app.mjs')` had
instead been a static `import './my-app.mjs'`, _the app would already
have been loaded_ before the `http-to-https` hooks were
registered. This is part of the design of ES modules, where static
imports are evaluated from the leaves of the tree first back to the
trunk. There can be static imports _within_ `my-app.mjs`, which
will not be evaluated until `my-app.mjs` is when it's dynamically
imported.
The `--experimental-loader` flag of the CLI can be used together
with the `register` function; the loaders registered with the
function will follow the same evaluation chain of loaders registered
within the CLI:
```console
node \
--experimental-loader unpkg \
--experimental-loader http-to-https \
--experimental-loader cache-buster \
entrypoint.mjs
```
```mjs
// entrypoint.mjs
import { URL } from 'node:url';
import { register } from 'node:module';
const loaderURL = new URL('./my-programmatically-loader.mjs', import.meta.url);
register(loaderURL);
await import('./my-app.mjs');
```
The `my-programmatic-loader.mjs` can leverage `unpkg`,
`http-to-https`, and `cache-buster` loaders.
It's also possible to use `register` more than once:
```mjs
// entrypoint.mjs
import { URL } from 'node:url';
import { register } from 'node:module';
register(new URL('./first-loader.mjs', import.meta.url));
register('./second-loader.mjs', import.meta.url);
await import('./my-app.mjs');
```
Both loaders (`first-loader.mjs` and `second-loader.mjs`) can use
all the resources provided by the loaders registered in the CLI. But
remember that they will only be available in the next imported
module (`my-app.mjs`). The evaluation order of the hooks when
importing `my-app.mjs` and consecutive modules in the example above
will be:
```console
resolve: second-loader.mjs
resolve: first-loader.mjs
resolve: cache-buster
resolve: http-to-https
resolve: unpkg
load: second-loader.mjs
load: first-loader.mjs
load: cache-buster
load: http-to-https
load: unpkg
globalPreload: second-loader.mjs
globalPreload: first-loader.mjs
globalPreload: cache-buster
globalPreload: http-to-https
globalPreload: unpkg
```
### `module.syncBuiltinESMExports()`
<!-- YAML

View File

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

View File

@ -45,6 +45,8 @@ const {
validateString,
} = require('internal/validators');
const { kEmptyObject } = require('internal/util');
const {
defaultResolve,
throwIfInvalidParentURL,
@ -117,6 +119,23 @@ class Hooks {
// Cache URLs we've already validated to avoid repeated validation
#validatedUrls = new SafeSet();
/**
* Import and register custom/user-defined module loader hook(s).
* @param {string} urlOrSpecifier
* @param {string} parentURL
*/
async register(urlOrSpecifier, parentURL) {
const moduleLoader = require('internal/process/esm_loader').esmLoader;
const keyedExports = await moduleLoader.import(
urlOrSpecifier,
parentURL,
kEmptyObject,
);
this.addCustomLoader(urlOrSpecifier, keyedExports);
}
/**
* Collect custom/user-defined module loader hook(s).
* After all hooks have been collected, the global preload hook(s) must be initialized.

View File

@ -11,6 +11,7 @@ const {
} = primordials;
const {
ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE,
ERR_UNKNOWN_MODULE_FORMAT,
} = require('internal/errors').codes;
const { getOptionValue } = require('internal/options');
@ -287,12 +288,19 @@ class CustomizedModuleLoader extends DefaultModuleLoader {
constructor() {
super();
if (hooksProxy) {
// The worker proxy is shared across all instances, so don't recreate it if it already exists.
return;
}
const { HooksProxy } = require('internal/modules/esm/hooks');
hooksProxy = new HooksProxy(); // The user's custom hooks are loaded within the worker as part of its startup.
getHooksProxy();
}
/**
* Register some loader specifier.
* @param {string} originalSpecifier The specified URL path of the loader to
* be registered.
* @param {string} parentURL The parent URL from where the loader will be
* registered if using it package name as specifier
* @returns {{ format: string, url: URL['href'] }}
*/
register(originalSpecifier, parentURL) {
return hooksProxy.makeSyncRequest('register', originalSpecifier, parentURL);
}
/**
@ -370,7 +378,46 @@ function createModuleLoader(useCustomLoadersIfPresent = true) {
return new DefaultModuleLoader();
}
/**
* Get the HooksProxy instance. If it is not defined, then create a new one.
* @returns {HooksProxy}
*/
function getHooksProxy() {
if (!hooksProxy) {
const { HooksProxy } = require('internal/modules/esm/hooks');
hooksProxy = new HooksProxy();
}
return hooksProxy;
}
/**
* Register a single loader programmatically.
* @param {string} specifier
* @param {string} [parentURL]
* @returns {void}
* @example
* ```js
* register('./myLoader.js');
* register('ts-node/esm', import.meta.url);
* register('./myLoader.js', import.meta.url);
* register(new URL('./myLoader.js', import.meta.url));
* ```
*/
function register(specifier, parentURL = 'data:') {
// TODO: Remove this limitation in a follow-up before `register` is released publicly
if (getOptionValue('--experimental-loader').length < 1) {
throw new ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE();
}
const moduleLoader = require('internal/process/esm_loader').esmLoader;
moduleLoader.register(`${specifier}`, parentURL);
}
module.exports = {
DefaultModuleLoader,
createModuleLoader,
getHooksProxy,
register,
};

View File

@ -146,9 +146,9 @@ async function initializeHooks() {
load(url, context) { return hooks.load(url, context); }
}
const privateModuleLoader = new ModuleLoader();
const parentURL = pathToFileURL(cwd).href;
// TODO(jlenon7): reuse the `Hooks.register()` method for registering loaders.
for (let i = 0; i < customLoaderURLs.length; i++) {
const customLoaderURL = customLoaderURLs[i];

View File

@ -2,8 +2,10 @@
const { findSourceMap } = require('internal/source_map/source_map_cache');
const { Module } = require('internal/modules/cjs/loader');
const { register } = require('internal/modules/esm/loader');
const { SourceMap } = require('internal/source_map/source_map');
Module.findSourceMap = findSourceMap;
Module.register = register;
Module.SourceMap = SourceMap;
module.exports = Module;

View File

@ -24,6 +24,28 @@ describe('Loader hooks', { concurrency: true }, () => {
assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/);
});
it('are called with all expected arguments using register function', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader=data:text/javascript,',
'--input-type=module',
'--eval',
"import { register } from 'node:module';" +
`register(${JSON.stringify(fixtures.fileURL('/es-module-loaders/hooks-input.mjs'))});` +
`await import(${JSON.stringify(fixtures.fileURL('/es-modules/json-modules.mjs'))});`,
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /{"url":"file:\/\/\/.*\/json-modules\.mjs","format":"test","shortCircuit":true}/);
assert.match(lines[1], /{"source":{"type":"Buffer","data":\[.*\]},"format":"module","shortCircuit":true}/);
assert.match(lines[2], /{"url":"file:\/\/\/.*\/experimental\.json","format":"test","shortCircuit":true}/);
assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/);
});
describe('should handle never-settling hooks in ESM files', { concurrency: true }, () => {
it('top-level await of a never-settling resolve', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [

View File

@ -0,0 +1,236 @@
import { spawnPromisified } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { execPath } from 'node:process';
import { describe, it } from 'node:test';
// This test ensures that the register function can register loaders
// programmatically.
const commonArgs = [
'--no-warnings',
'--input-type=module',
'--loader=data:text/javascript,',
];
const commonEvals = {
import: (module) => `await import(${JSON.stringify(module)});`,
register: (loader, parentURL = 'file:///') => `register(${JSON.stringify(loader)}, ${JSON.stringify(parentURL)});`,
dynamicImport: (module) => `await import(${JSON.stringify(`data:text/javascript,${encodeURIComponent(module)}`)});`,
staticImport: (module) => `import ${JSON.stringify(`data:text/javascript,${encodeURIComponent(module)}`)};`,
};
describe('ESM: programmatically register loaders', { concurrency: true }, () => {
it('works with only a dummy CLI argument', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
"import { register } from 'node:module';" +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')) +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /resolve passthru/);
assert.match(lines[1], /load passthru/);
assert.match(lines[2], /Hello from dynamic import/);
assert.strictEqual(lines[3], '');
});
describe('registering via --import', { concurrency: true }, () => {
for (const moduleType of ['mjs', 'cjs']) {
it(`should programmatically register a loader from a ${moduleType.toUpperCase()} file`, async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--import', fixtures.fileURL('es-module-loaders', `register-loader.${moduleType}`).href,
'--eval', commonEvals.staticImport('console.log("entry point")'),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const [
passthruStdout,
entryPointStdout,
] = stdout.split('\n');
assert.match(passthruStdout, /resolve passthru/);
assert.match(entryPointStdout, /entry point/);
});
}
});
it('programmatically registered loaders are appended to an existing chaining', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--loader',
fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'),
'--eval',
"import { register } from 'node:module';" +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /resolve passthru/);
assert.match(lines[1], /resolve passthru/);
assert.match(lines[2], /load passthru/);
assert.match(lines[3], /Hello from dynamic import/);
assert.strictEqual(lines[4], '');
});
it('works registering loaders across files', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-load.mjs')) +
commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-resolve.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /resolve passthru/);
assert.match(lines[1], /load passthru/);
assert.match(lines[2], /Hello from dynamic import/);
assert.strictEqual(lines[3], '');
});
it('works registering loaders across virtual files', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-load.mjs')) +
commonEvals.dynamicImport(
commonEvals.import(fixtures.fileURL('es-module-loaders', 'register-programmatically-loader-resolve.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /resolve passthru/);
assert.match(lines[1], /load passthru/);
assert.match(lines[2], /Hello from dynamic import/);
assert.strictEqual(lines[3], '');
});
it('works registering the same loaders more them once', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
"import { register } from 'node:module';" +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')) +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs')) +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /resolve passthru/);
assert.match(lines[1], /resolve passthru/);
assert.match(lines[2], /load passthru/);
assert.match(lines[3], /load passthru/);
assert.match(lines[4], /Hello from dynamic import/);
assert.strictEqual(lines[5], '');
});
it('works registering loaders as package name', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
"import { register } from 'node:module';" +
commonEvals.register('resolve', fixtures.fileURL('es-module-loaders', 'package.json')) +
commonEvals.register('load', fixtures.fileURL('es-module-loaders', 'package.json')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
const lines = stdout.split('\n');
assert.match(lines[0], /resolve passthru/);
assert.match(lines[1], /load passthru/);
assert.match(lines[2], /Hello from dynamic import/);
assert.strictEqual(lines[3], '');
});
it('does not work without dummy CLI loader', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--input-type=module',
'--eval',
"import { register } from 'node:module';" +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stdout, '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
assert.match(stderr, /ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE/);
});
it('does not work with a loader specifier that does not exist', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
"import { register } from 'node:module';" +
commonEvals.register('./not-found.mjs', import.meta.url) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stdout, '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
assert.match(stderr, /ERR_MODULE_NOT_FOUND/);
});
it('does not work with a loader that got syntax errors', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
...commonArgs,
'--eval',
"import { register } from 'node:module';" +
commonEvals.register(fixtures.fileURL('es-module-loaders', 'syntax-error.mjs')) +
commonEvals.dynamicImport('console.log("Hello from dynamic import");'),
]);
assert.strictEqual(stdout, '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
assert.match(stderr, /SyntaxError/);
});
});

View File

@ -17,7 +17,11 @@ export async function resolve(specifier, context, next) {
if (resolveCalls === 1) {
url = new URL(specifier).href;
assert.match(specifier, /json-modules\.mjs$/);
assert.strictEqual(context.parentURL, undefined);
if (!(/\[eval\d*\]$/).test(context.parentURL)) {
assert.strictEqual(context.parentURL, undefined);
}
assert.deepStrictEqual(context.importAssertions, {});
} else if (resolveCalls === 2) {
url = new URL(specifier, context.parentURL).href;

View File

@ -1,6 +1,5 @@
import { writeSync } from 'node:fs';
export async function load(url, context, next) {
// This check is needed to make sure that we don't prevent the
// resolution from follow-up loaders. It wouldn't be a problem

View File

@ -1,6 +1,5 @@
import { writeSync } from 'node:fs';
export async function resolve(specifier, context, next) {
// This check is needed to make sure that we don't prevent the
// resolution from follow-up loaders. It wouldn't be a problem

View File

@ -0,0 +1 @@
export * from '../../loader-load-passthru.mjs'

View File

@ -0,0 +1,3 @@
{
"exports": "./index.mjs"
}

View File

@ -0,0 +1 @@
export * from '../../loader-resolve-passthru.mjs'

View File

@ -0,0 +1,3 @@
{
"exports": "./index.mjs"
}

View File

@ -0,0 +1,4 @@
const { register } = require('node:module');
const fixtures = require('../../common/fixtures.js');
register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'));

View File

@ -0,0 +1,4 @@
import { register } from 'node:module';
import fixtures from '../../common/fixtures.js';
register(fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'));

View File

@ -0,0 +1,4 @@
import * as fixtures from '../../common/fixtures.mjs';
import { register } from 'node:module';
register(fixtures.fileURL('es-module-loaders', 'loader-load-passthru.mjs'));

View File

@ -0,0 +1,3 @@
import { register } from 'node:module';
register('./loader-resolve-passthru.mjs', import.meta.url);