GeezerVison is showing github.com — module: implement `register` utility · nodejs/node@65dfe85
File treeExpand file treeCollapse file tree Open diff view settings fixtures/es-module-loaders
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/errors.md +17Lines changed: 17 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -1234,6 +1234,23 @@ provided. | 1234 | 1234 | Encoding provided to `TextDecoder()` API was not one of the | 1235 | 1235 | [WHATWG Supported Encodings][]. | 1236 | 1236 |
| | 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 | + | 1237 | 1254 | <a id="ERR_EVAL_ESM_CANNOT_PRINT"></a> | 1238 | 1255 |
| 1239 | 1256 | ### `ERR_EVAL_ESM_CANNOT_PRINT` | |
Collapse file Copy file name to clipboardExpand all lines: doc/api/esm.md +12Lines changed: 12 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -1236,6 +1236,17 @@ console.log('some module!'); | 1236 | 1236 | If you run `node --experimental-loader ./import-map-loader.js main.js` | 1237 | 1237 | the output will be `some module!`. | 1238 | 1238 |
| | 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 | +
| 1239 | 1250 | ## Resolution and loading algorithm | 1240 | 1251 |
| 1241 | 1252 | ### Features | @@ -1632,6 +1643,7 @@ success! | 1632 | 1643 | [`import.meta.url`]: #importmetaurl | 1633 | 1644 | [`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import | 1634 | 1645 | [`module.createRequire()`]: module.md#modulecreaterequirefilename | | 1646 | +[`module.register()`]: module.md#moduleregister | 1635 | 1647 | [`module.syncBuiltinESMExports()`]: module.md#modulesyncbuiltinesmexports | 1636 | 1648 | [`package.json`]: packages.md#nodejs-packagejson-field-definitions | 1637 | 1649 | [`port.ref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portref | |
Collapse file Copy file name to clipboardExpand all lines: doc/api/module.md +95Lines changed: 95 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -78,6 +78,101 @@ isBuiltin('fs'); // true | 78 | 78 | isBuiltin('wss'); // false | 79 | 79 | ``` | 80 | 80 |
| | 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 | +
| 81 | 176 | ### `module.syncBuiltinESMExports()` | 82 | 177 |
| 83 | 178 | <!-- YAML | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/errors.js +5Lines changed: 5 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -1039,6 +1039,11 @@ E('ERR_ENCODING_INVALID_ENCODED_DATA', function(encoding, ret) { | 1039 | 1039 | }, TypeError); | 1040 | 1040 | E('ERR_ENCODING_NOT_SUPPORTED', 'The "%s" encoding is not supported', | 1041 | 1041 | 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); | 1042 | 1047 | E('ERR_EVAL_ESM_CANNOT_PRINT', '--print cannot be used with ESM input', Error); | 1043 | 1048 | E('ERR_EVENT_RECURSION', 'The event "%s" is already being dispatched', Error); | 1044 | 1049 | E('ERR_FALSY_VALUE_REJECTION', function(reason) { | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/modules/esm/hooks.js +19Lines changed: 19 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -44,6 +44,8 @@ const { | 44 | 44 | validateString, | 45 | 45 | } = require('internal/validators'); | 46 | 46 |
| | 47 | +const { kEmptyObject } = require('internal/util'); | | 48 | + | 47 | 49 | const { | 48 | 50 | defaultResolve, | 49 | 51 | throwIfInvalidParentURL, | @@ -116,6 +118,23 @@ class Hooks { | 116 | 118 | // Cache URLs we've already validated to avoid repeated validation | 117 | 119 | #validatedUrls = new SafeSet(); | 118 | 120 |
| | 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 | + | 119 | 138 | /** | 120 | 139 | * Collect custom/user-defined module loader hook(s). | 121 | 140 | * After all hooks have been collected, the global preload hook(s) must be initialized. | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/modules/esm/loader.js +53-6Lines changed: 53 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -11,6 +11,7 @@ const { | 11 | 11 | } = primordials; | 12 | 12 |
| 13 | 13 | const { | | 14 | + ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE, | 14 | 15 | ERR_UNKNOWN_MODULE_FORMAT, | 15 | 16 | } = require('internal/errors').codes; | 16 | 17 | const { getOptionValue } = require('internal/options'); | @@ -297,12 +298,19 @@ class CustomizedModuleLoader extends DefaultModuleLoader { | 297 | 298 | constructor() { | 298 | 299 | super(); | 299 | 300 |
| 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); | 306 | 314 | } | 307 | 315 |
| 308 | 316 | /** | @@ -380,7 +388,46 @@ function createModuleLoader(useCustomLoadersIfPresent = true) { | 380 | 388 | return new DefaultModuleLoader(); | 381 | 389 | } | 382 | 390 |
| | 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 | + | 383 | 428 | module.exports = { | 384 | 429 | DefaultModuleLoader, | 385 | 430 | createModuleLoader, | | 431 | + getHooksProxy, | | 432 | + register, | 386 | 433 | }; |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/modules/esm/utils.js +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -146,9 +146,9 @@ async function initializeHooks() { | 146 | 146 | load(url, context) { return hooks.load(url, context); } | 147 | 147 | } | 148 | 148 | const privateModuleLoader = new ModuleLoader(); | 149 | | - | 150 | 149 | const parentURL = pathToFileURL(cwd).href; | 151 | 150 |
| | 151 | + // TODO(jlenon7): reuse the `Hooks.register()` method for registering loaders. | 152 | 152 | for (let i = 0; i < customLoaderURLs.length; i++) { | 153 | 153 | const customLoaderURL = customLoaderURLs[i]; | 154 | 154 |
| |
Collapse file Copy file name to clipboardExpand all lines: lib/module.js +2Lines changed: 2 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 2 | 2 |
| 3 | 3 | const { findSourceMap } = require('internal/source_map/source_map_cache'); | 4 | 4 | const { Module } = require('internal/modules/cjs/loader'); | | 5 | +const { register } = require('internal/modules/esm/loader'); | 5 | 6 | const { SourceMap } = require('internal/source_map/source_map'); | 6 | 7 |
| 7 | 8 | Module.findSourceMap = findSourceMap; | | 9 | +Module.register = register; | 8 | 10 | Module.SourceMap = SourceMap; | 9 | 11 | module.exports = Module; |
Collapse file Copy file name to clipboardExpand all lines: test/es-module/test-esm-loader-hooks.mjs +22Lines changed: 22 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -24,6 +24,28 @@ describe('Loader hooks', { concurrency: true }, () => { | 24 | 24 | assert.match(lines[3], /{"source":{"type":"Buffer","data":\[.*\]},"format":"json","shortCircuit":true}/); | 25 | 25 | }); | 26 | 26 |
| | 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 | + | 27 | 49 | describe('should handle never-settling hooks in ESM files', { concurrency: true }, () => { | 28 | 50 | it('top-level await of a never-settling resolve', async () => { | 29 | 51 | const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ | |
|