From 1527799a96706e91c09a3c3d266d528ca0889797 Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Tue, 2 Dec 2025 14:28:30 +0800 Subject: [PATCH 1/3] doc: reorganize docs of module customization hooks This reorganizes the documentation of module customization hooks to promote the synchronous variant as it has fewer caveats. Previously the documentation was organized as follows: To do something: 1. For asynchronous hooks, do this, which may have these caveats 2. For synchronous hooks, do this, which does not have the caveats To do something else: 1. For asynchronous hooks, do this, which may have these caveats 2. For synchronous hooks, do this, which does not have the caveats It's now organized as follows: Synchronous hooks: To do something, do this. To do something else, do this. (No mention that it doesn't have caveats, because users are not supposed to burden themselves with caveats in the other API that they do not use). Asynchronous hooks: They have these caveats, if they are too complex to deal with, consider use the synchronous variant. To do something, do this, which may have these caveats. To do something, do this, which may have these caveats. --- doc/api/cli.md | 8 +- doc/api/module.md | 840 ++++++++++++++++++++++++++++------------------ 2 files changed, 513 insertions(+), 335 deletions(-) diff --git a/doc/api/cli.md b/doc/api/cli.md index 4e2a44e868f741..e21b5252f905a1 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1188,9 +1188,9 @@ changes: > This flag is discouraged and may be removed in a future version of Node.js. > Please use -> [`--import` with `register()`][module customization hooks: enabling] instead. +> [`--import` with `register()`][preloading asynchronous module customization hooks] instead. -Specify the `module` containing exported [module customization hooks][]. +Specify the `module` containing exported [asynchronous module customization hooks][]. `module` may be any string accepted as an [`import` specifier][]. This feature requires `--allow-worker` if used with the [Permission Model][]. @@ -4141,8 +4141,6 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [ExperimentalWarning: `vm.measureMemory` is an experimental feature]: vm.md#vmmeasurememoryoptions [File System Permissions]: permissions.md#file-system-permissions [Loading ECMAScript modules using `require()`]: modules.md#loading-ecmascript-modules-using-require -[Module customization hooks]: module.md#customization-hooks -[Module customization hooks: enabling]: module.md#enabling [Module resolution and loading]: packages.md#module-resolution-and-loading [Navigator API]: globals.md#navigator [Node.js issue tracker]: https://github.com/nodejs/node/issues @@ -4204,6 +4202,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data [`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data [`v8.startupSnapshot` API]: v8.md#startup-snapshot-api +[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks [captured by the built-in snapshot of Node.js]: https://github.com/nodejs/node/blob/b19525a33cc84033af4addd0f80acd4dc33ce0cf/test/parallel/test-bootstrap-modules.js#L24 [collecting code coverage from tests]: test.md#collecting-code-coverage [conditional exports]: packages.md#conditional-exports @@ -4218,6 +4217,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [jitless]: https://v8.dev/blog/jitless [libuv threadpool documentation]: https://docs.libuv.org/en/latest/threadpool.html [module compile cache]: module.md#module-compile-cache +[preloading asynchronous module customization hooks]: module.md#registration-of-asynchronous-customization-hooks [remote code execution]: https://www.owasp.org/index.php/Code_Injection [running tests from the command line]: test.md#running-tests-from-the-command-line [scavenge garbage collector]: https://v8.dev/blog/orinoco-parallel-scavenger diff --git a/doc/api/module.md b/doc/api/module.md index efb85efc4c93ca..61d302b610b549 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -222,6 +222,11 @@ This feature requires `--allow-worker` if used with the [Permission Model][]. added: - v23.5.0 - v22.15.0 +changes: + - version: + - REPLACEME + pr-url: https://github.com/nodejs/node/pull/60960 + description: Synchronous and in-thread hooks are now release candidate. --> > Stability: 1.1 - Active development @@ -620,53 +625,33 @@ changes: -> Stability: 1.1 - Active development - -There are two types of module customization hooks that are currently supported: +Node.js currently supports two types of module customization hooks: -1. `module.register(specifier[, parentURL][, options])` which takes a module that - exports asynchronous hook functions. The functions are run on a separate loader - thread. -2. `module.registerHooks(options)` which takes synchronous hook functions that are - run directly on the thread where the module is loaded. +1. [`module.registerHooks(options)`][`module.registerHooks()`]: takes synchronous hook + functions that are run directly on the thread where the modules are loaded. +2. [`module.register(specifier[, parentURL][, options])`][`register`]: takes specifier to a + module that exports asynchronous hook functions. The functions are run on a + separate loader thread. - +The asynchronous hooks incur extra overhead from inter-thread communication, +and have [several caveats][caveats of asynchronous customization hooks] especially +when customizing CommonJS modules in the module graph. +In most cases, it's recommended to use synchronous hooks via `module.registerHooks()` +for simplicity. -### Enabling +### Synchronous customization hooks -Module resolution and loading can be customized by: +> Stability: 1.1 - Active Development -1. Registering a file which exports a set of asynchronous hook functions, using the - [`register`][] method from `node:module`, -2. Registering a set of synchronous hook functions using the [`registerHooks`][] method - from `node:module`. + -The hooks can be registered before the application code is run by using the -[`--import`][] or [`--require`][] flag: +#### Registration of synchronous customization hooks -```bash -node --import ./register-hooks.js ./my-app.js -node --require ./register-hooks.js ./my-app.js -``` +To register synchronous customization hooks, use [`module.registerHooks()`][], which +takes [synchronous hook functions][] directly in-line. ```mjs // register-hooks.js -// This file can only be require()-ed if it doesn't contain top-level await. -// Use module.register() to register asynchronous hooks in a dedicated thread. -import { register } from 'node:module'; -register('./hooks.mjs', import.meta.url); -``` - -```cjs -// register-hooks.js -const { register } = require('node:module'); -const { pathToFileURL } = require('node:url'); -// Use module.register() to register asynchronous hooks in a dedicated thread. -register('./hooks.mjs', pathToFileURL(__filename)); -``` - -```mjs -// Use module.registerHooks() to register synchronous hooks in the main thread. import { registerHooks } from 'node:module'; registerHooks({ resolve(specifier, context, nextResolve) { /* implementation */ }, @@ -675,7 +660,7 @@ registerHooks({ ``` ```cjs -// Use module.registerHooks() to register synchronous hooks in the main thread. +// register-hooks.js const { registerHooks } = require('node:module'); registerHooks({ resolve(specifier, context, nextResolve) { /* implementation */ }, @@ -683,7 +668,17 @@ registerHooks({ }); ``` -The file passed to `--import` or `--require` can also be an export from a dependency: +##### Registering hooks before application code runs with flags + +The hooks can be registered before the application code is run by using the +[`--import`][] or [`--require`][] flag: + +```bash +node --import ./register-hooks.js ./my-app.js +node --require ./register-hooks.js ./my-app.js +``` + +The specifier passed to `--import` or `--require` can also come from a package: ```bash node --import some-package/register ./my-app.js @@ -691,96 +686,443 @@ node --require some-package/register ./my-app.js ``` Where `some-package` has an [`"exports"`][] field defining the `/register` -export to map to a file that calls `register()`, like the following `register-hooks.js` -example. +export to map to a file that calls `registerHooks()`, like the +`register-hooks.js` examples above. Using `--import` or `--require` ensures that the hooks are registered before any -application files are imported, including the entry point of the application and for +application code is loaded, including the entry point of the application and for any worker threads by default as well. -Alternatively, `register()` and `registerHooks()` can be called from the entry point, -though dynamic `import()` must be used for any ESM code that should be run after the hooks -are registered. +##### Registering hooks before application code runs programmatically + +Alternatively, `registerHooks()` can be called from the entry point. + +If the entry point needs to load other modules and the loading process needs to be +customized, load them using either `require()` or dynamic `import()` after the hooks +are registered. Do not use static `import` statements to load modules that need to be +customized in the same module that registers the hooks, because static `import` statements +are evaluated before any code in the importer module is run, including the call to +`registerHooks()`, regardless of where the static `import` statements appear in the importer +module. ```mjs -import { register } from 'node:module'; +import { registerHooks } from 'node:module'; + +registerHooks({ /* implementation of synchronous hooks */ }); -register('http-to-https', import.meta.url); +// If loaded using static import, the hooks would not be applied when loading +// my-app.mjs, because statically imported modules are all executed before its +// importer regardless of where the static import appears. +// import './my-app.mjs'; -// Because this is a dynamic `import()`, the `http-to-https` hooks will run -// to handle `./my-app.js` and any other files it imports or requires. -await import('./my-app.js'); +// my-app.mjs must be loaded dynamically to ensure the hooks are applied. +await import('./my-app.mjs'); ``` ```cjs -const { register } = require('node:module'); -const { pathToFileURL } = require('node:url'); +const { registerHooks } = require('node:module'); -register('http-to-https', pathToFileURL(__filename)); +registerHooks({ /* implementation of synchronous hooks */ }); -// Because this is a dynamic `import()`, the `http-to-https` hooks will run -// to handle `./my-app.js` and any other files it imports or requires. -import('./my-app.js'); +import('./my-app.mjs'); +// Or, if my-app.mjs does not have top-level await or it's a CommonJS module, +// require() can also be used: +// require('./my-app.mjs'); +``` + +##### Registering hooks before application code runs with a `data:` URL + +Alternatively, inline JavaScript code can be embedded in `data:` URLs to register +the hooks before the application code runs. For example, + +```bash +node --import 'data:text/javascript,import {registerHooks} from "node:module"; registerHooks(/* hooks code */);' ./my-app.js ``` -Customization hooks will run for any modules loaded later than the registration -and the modules they reference via `import` and the built-in `require`. -`require` function created by users using `module.createRequire()` can only be -customized by the synchronous hooks. +#### Convention of hooks and chaining -In this example, we are registering the `http-to-https` hooks, but they will -only be available for subsequently imported modules — in this case, `my-app.js` -and anything it references via `import` or built-in `require` in CommonJS dependencies. +Hooks are part of a chain, even if that chain consists of only one +custom (user-provided) hook and the default hook, which is always present. -If the `import('./my-app.js')` had instead been a static `import './my-app.js'`, the -app would have _already_ been loaded **before** the `http-to-https` hooks were -registered. This due to the ES modules specification, where static imports are -evaluated from the leaves of the tree first, then back to the trunk. There can -be static imports _within_ `my-app.js`, which will not be evaluated until -`my-app.js` is dynamically imported. +Hook functions nest: each one must always return a plain object, and chaining happens +as a result of each function calling `next()`, which is a reference to +the subsequent loader's hook (in LIFO order). -If synchronous hooks are used, both `import`, `require` and user `require` created -using `createRequire()` are supported. +It's possible to call `registerHooks()` more than once: ```mjs -import { registerHooks, createRequire } from 'node:module'; +// entrypoint.mjs +import { registerHooks } from 'node:module'; -registerHooks({ /* implementation of synchronous hooks */ }); +const hook1 = { /* implementation of hooks */ }; +const hook2 = { /* implementation of hooks */ }; +// hook2 runs before hook1. +registerHooks(hook1); +registerHooks(hook2); +``` -const require = createRequire(import.meta.url); +```cjs +// entrypoint.cjs +const { registerHooks } = require('node:module'); + +const hook1 = { /* implementation of hooks */ }; +const hook2 = { /* implementation of hooks */ }; +// hook2 runs before hook1. +registerHooks(hook1); +registerHooks(hook2); +``` + +In this example, the registered hooks will form chains. These chains run +last-in, first out (LIFO). If both `hook1` and `hook2` define a `resolve` +hook, they will be called like so (note the right-to-left, +starting with `hook2.resolve`, then `hook1.resolve`, then the Node.js default): + + + +Node.js' default ← `hook1.resolve` ← `hook2.resolve` + + + +The same applies to all the other hooks. + +A hook that returns a value lacking a required property triggers an exception. A +hook that returns without calling `next()` _and_ without returning +`shortCircuit: true` also triggers an exception. These errors are to help +prevent unintentional breaks in the chain. Return `shortCircuit: true` from a +hook to signal that the chain is intentionally ending at your hook. + +If a hook should be applied when loading other hook modules, the other hook +modules should be loaded after the hook is registered. + +#### Hook functions accepted by `module.registerHooks()` + + + +The `module.registerHooks()` method accepts the following synchronous hook functions. + +```mjs +function resolve(specifier, context, nextResolve) { + // Take an `import` or `require` specifier and resolve it to a URL. +} + +function load(url, context, nextLoad) { + // Take a resolved URL and return the source code to be evaluated. +} +``` + +Synchronous hooks are run in the same thread and the same [realm][] where the modules +are loaded, the code in the hook function can pass values to the modules being referenced +directly via global variables or other shared states. + +Unlike the asynchronous hooks, the synchronous hooks are not inherited into child worker +threads by default, though if the hooks are registered using a file preloaded by +[`--import`][] or [`--require`][], child worker threads can inherit the preloaded scripts +via `process.execArgv` inheritance. See [the documentation of `Worker`][] for details. + +#### Synchronous `resolve(specifier, context, nextResolve)` + + + +* `specifier` {string} +* `context` {Object} + * `conditions` {string\[]} Export conditions of the relevant `package.json` + * `importAttributes` {Object} An object whose key-value pairs represent the + attributes for the module to import + * `parentURL` {string|undefined} The module importing this one, or undefined + if this is the Node.js entry point +* `nextResolve` {Function} The subsequent `resolve` hook in the chain, or the + Node.js default `resolve` hook after the last user-supplied `resolve` hook + * `specifier` {string} + * `context` {Object|undefined} When omitted, the defaults are provided. When provided, defaults + are merged in with preference to the provided properties. +* Returns: {Object} + * `format` {string|null|undefined} A hint to the `load` hook (it might be ignored). It can be a + module format (such as `'commonjs'` or `'module'`) or an arbitrary value like `'css'` or + `'yaml'`. + * `importAttributes` {Object|undefined} The import attributes to use when + caching the module (optional; if excluded the input will be used) + * `shortCircuit` {undefined|boolean} A signal that this hook intends to + terminate the chain of `resolve` hooks. **Default:** `false` + * `url` {string} The absolute URL to which this input resolves + +The `resolve` hook chain is responsible for telling Node.js where to find and +how to cache a given `import` statement or expression, or `require` call. It can +optionally return a format (such as `'module'`) as a hint to the `load` hook. If +a format is specified, the `load` hook is ultimately responsible for providing +the final `format` value (and it is free to ignore the hint provided by +`resolve`); if `resolve` provides a `format`, a custom `load` hook is required +even if only to pass the value to the Node.js default `load` hook. + +Import type attributes are part of the cache key for saving loaded modules into +the internal module cache. The `resolve` hook is responsible for returning an +`importAttributes` object if the module should be cached with different +attributes than were present in the source code. + +The `conditions` property in `context` is an array of conditions that will be used +to match [package exports conditions][Conditional exports] for this resolution +request. They can be used for looking up conditional mappings elsewhere or to +modify the list when calling the default resolution logic. + +The current [package exports conditions][Conditional exports] are always in +the `context.conditions` array passed into the hook. To guarantee _default +Node.js module specifier resolution behavior_ when calling `defaultResolve`, the +`context.conditions` array passed to it _must_ include _all_ elements of the +`context.conditions` array originally passed into the `resolve` hook. + +```mjs +import { registerHooks } from 'node:module'; + +function resolve(specifier, context, nextResolve) { + // When calling `defaultResolve`, the arguments can be modified. For example, + // to change the specifier or to add applicable export conditions. + if (specifier.includes('foo')) { + specifier = specifier.replace('foo', 'bar'); + return nextResolve(specifier, { + ...context, + conditions: [...context.conditions, 'another-condition'], + }); + } + + // The hook can also skip default resolution and provide a custom URL. + if (specifier === 'special-module') { + return { + url: 'file:///path/to/special-module.mjs', + format: 'module', + shortCircuit: true, // This is mandatory if nextResolve() is not called. + }; + } + + // If no customization is needed, defer to the next hook in the chain which would be the + // Node.js default resolve if this is the last user-specified loader. + return nextResolve(specifier); +} + +registerHooks({ resolve }); +``` + +#### Synchronous `load(url, context, nextLoad)` + + + +* `url` {string} The URL returned by the `resolve` chain +* `context` {Object} + * `conditions` {string\[]} Export conditions of the relevant `package.json` + * `format` {string|null|undefined} The format optionally supplied by the + `resolve` hook chain. This can be any string value as an input; input values do not need to + conform to the list of acceptable return values described below. + * `importAttributes` {Object} +* `nextLoad` {Function} The subsequent `load` hook in the chain, or the + Node.js default `load` hook after the last user-supplied `load` hook + * `url` {string} + * `context` {Object|undefined} When omitted, defaults are provided. When provided, defaults are + merged in with preference to the provided properties. In the default `nextLoad`, if + the module pointed to by `url` does not have explicit module type information, + `context.format` is mandatory. + +* Returns: {Object} + * `format` {string} One of the acceptable module formats listed [below][accepted final formats]. + * `shortCircuit` {undefined|boolean} A signal that this hook intends to + terminate the chain of `load` hooks. **Default:** `false` + * `source` {string|ArrayBuffer|TypedArray} The source for Node.js to evaluate + +The `load` hook provides a way to define a custom method for retrieving the +source code of a resolved URL. This would allow a loader to potentially avoid +reading files from disk. It could also be used to map an unrecognized format to +a supported one, for example `yaml` to `module`. + +```mjs +import { registerHooks } from 'node:module'; +import { Buffer } from 'node:buffer'; + +function load(url, context, nextLoad) { + // The hook can skip default loading and provide a custom source code. + if (url === 'special-module') { + return { + source: 'export const special = 42;', + format: 'module', + shortCircuit: true, // This is mandatory if nextLoad() is not called. + }; + } + + // It's possible to modify the source code loaded by the next - possibly default - step, + // for example, replacing 'foo' with 'bar' in the source code of the module. + const result = nextLoad(url, context); + const source = typeof result.source === 'string' ? + result.source : Buffer.from(result.source).toString('utf8'); + return { + source: source.replace(/foo/g, 'bar'), + ...result, + }; +} + +registerHooks({ resolve }); +``` + +In a more advanced scenario, this can also be used to transform an unsupported +source to a supported one (see [Examples](#examples) below). + +##### Accepted final formats returned by `load` + +The final value of `format` must be one of the following: + +| `format` | Description | Acceptable types for `source` returned by `load` | +| ----------------------- | ----------------------------------------------------- | -------------------------------------------------- | +| `'addon'` | Load a Node.js addon | {null} | +| `'builtin'` | Load a Node.js builtin module | {null} | +| `'commonjs-typescript'` | Load a Node.js CommonJS module with TypeScript syntax | {string\|ArrayBuffer\|TypedArray\|null\|undefined} | +| `'commonjs'` | Load a Node.js CommonJS module | {string\|ArrayBuffer\|TypedArray\|null\|undefined} | +| `'json'` | Load a JSON file | {string\|ArrayBuffer\|TypedArray} | +| `'module-typescript'` | Load an ES module with TypeScript syntax | {string\|ArrayBuffer\|TypedArray} | +| `'module'` | Load an ES module | {string\|ArrayBuffer\|TypedArray} | +| `'wasm'` | Load a WebAssembly module | {ArrayBuffer\|TypedArray} | + +The value of `source` is ignored for format `'builtin'` because currently it is +not possible to replace the value of a Node.js builtin (core) module. + +> These types all correspond to classes defined in ECMAScript. + +* The specific {ArrayBuffer} object is a {SharedArrayBuffer}. +* The specific {TypedArray} object is a {Uint8Array}. + +If the source value of a text-based format (i.e., `'json'`, `'module'`) +is not a string, it is converted to a string using [`util.TextDecoder`][]. + +### Asynchronous customization hooks + +> Stability: 1.1 - Active Development -// The synchronous hooks affect import, require() and user require() function -// created through createRequire(). -await import('./my-app.js'); -require('./my-app-2.js'); +#### Caveats of asynchronous customization hooks + +The asynchronous customization hooks have many caveats and the it is uncertain if their +issues can be resolved. Users are encouraged to use the synchronous customization hooks +via `module.registerHooks()` instead to avoid these caveats. + +* Asynchronous hooks run on a separate thread, so the hook functions cannot directly + mutate the global state of the modules being customized. It's typical to use message + channels and atomics to pass data between the two or to affect control flows. + See [Communication with asynchronous module customization hooks](#communication-with-asynchronous-module-customization-hooks). +* Asynchronous hooks do not affect all `require()` calls in the module graph. + * Custom `require` functions created using `module.createRequire()` are not + affected. + * If the asynchronous `load` hook does not override the `source` for CommonJS modules + that go through it, the child modules loaded by those CommonJS modules via built-in + `require()` would not be affected by the asynchronous hooks either. +* There are several caveats that the asynchronous hooks need to handle when + customizing CommonJS modules. See [asynchronous `resolve` hook][] and + [asynchronous `load` hook][] for details. +* When `require()` calls inside CommonJS modules are customized by asynchronous hooks, + Node.js may need to load the source code of the CommonJS module multiple times to maintain + compatibility with existing CommonJS monkey-patching. If the module code changes between + loads, this may lead to unexpected behaviors. + * As a side effect, if both asynchronous hooks and synchronous hooks are registered and the + asynchronous hooks choose to customize the CommonJS module, the synchronous hooks may be + invoked multiple times for the `require()` calls in that CommonJS module. + +#### Registration of asynchronous customization hooks + +Asynchronous customization hooks are registered using [`module.register()`][`register`] which takes +a path or URL to another module that exports the [asynchronous hook functions][]. + +Similar to `registerHooks()`, `register()` can be called in a module preloaded by `--import` or +`--require`, or called directly within the entry point. + +```mjs +// Use module.register() to register asynchronous hooks in a dedicated thread. +import { register } from 'node:module'; +register('./hooks.mjs', import.meta.url); + +// If my-app.mjs is loaded statically here as `import './my-app.mjs'`, since ESM +// dependencies are evaluated before the module that imports them, +// it's loaded _before_ the hooks are registered above and won't be affected. +// To ensure the hooks are applied, dynamic import() must be used to load ESM +// after the hooks are registered. +import('./my-app.mjs'); ``` ```cjs -const { register, registerHooks } = require('node:module'); +const { register } = require('node:module'); const { pathToFileURL } = require('node:url'); +// Use module.register() to register asynchronous hooks in a dedicated thread. +register('./hooks.mjs', pathToFileURL(__filename)); -registerHooks({ /* implementation of synchronous hooks */ }); +import('./my-app.mjs'); +``` + +In `hooks.mjs`: +```mjs +// hooks.mjs +export async function resolve(specifier, context, nextResolve) { + /* implementation */ +} +export async function load(url, context, nextLoad) { + /* implementation */ +} +``` + +Unlike synchronous hooks, the asynchronous hooks would not run for these modules loaded in the file +that calls `register()`: + + + +```mjs +// register-hooks.js +import { register, createRequire } from 'node:module'; +register('./hooks.mjs', import.meta.url); + +// Asynchronous hooks does not affect modules loaded via custom require() +// functions created by module.createRequire(). const userRequire = createRequire(__filename); +userRequire('./my-app-2.cjs'); // Hooks won't affect this +``` -// The synchronous hooks affect import, require() and user require() function -// created through createRequire(). -import('./my-app.js'); -require('./my-app-2.js'); -userRequire('./my-app-3.js'); + + +```cjs +// register-hooks.js +const { register, createRequire } = require('node:module'); +const { pathToFileURL } = require('node:url'); +register('./hooks.mjs', pathToFileURL(__filename)); + +// Asynchronous hooks does not affect modules loaded via built-in require() +// in the module calling `register()` +require('./my-app-2.cjs'); // Hooks won't affect this +// .. or custom require() functions created by module.createRequire(). +const userRequire = createRequire(__filename); +userRequire('./my-app-3.cjs'); // Hooks won't affect this ``` -Finally, if all you want to do is register hooks before your app runs and you -don't want to create a separate file for that purpose, you can pass a `data:` -URL to `--import`: +Asynchronous hooks can also be registered using a `data:` URL with the `--import` flag: ```bash -node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("http-to-https", pathToFileURL("./"));' ./my-app.js +node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("my-instrumentation", pathToFileURL("./"));' ./my-app.js ``` -### Chaining +#### Chaining of asynchronous customization hooks -It's possible to call `register` more than once: +Chaining of `register()` work similarly to `registerHooks()`. If synchronous and asynchronous +hooks are mixed, the synchronous hooks are always run first before the asynchronous +hooks start running, that is, in the last synchronous hook being run, its next +hook includes invocation of the asynchronous hooks. ```mjs // entrypoint.mjs @@ -802,50 +1144,26 @@ register('./bar.mjs', parentURL); import('./my-app.mjs'); ``` -In this example, the registered hooks will form chains. These chains run -last-in, first out (LIFO). If both `foo.mjs` and `bar.mjs` define a `resolve` -hook, they will be called like so (note the right-to-left): -node's default ← `./foo.mjs` ← `./bar.mjs` -(starting with `./bar.mjs`, then `./foo.mjs`, then the Node.js default). -The same applies to all the other hooks. +If `foo.mjs` and `bar.mjs` define a `resolve` hook, they will be called like so +(note the right-to-left, starting with `./bar.mjs`, then `./foo.mjs`, then the Node.js default): -The registered hooks also affect `register` itself. In this example, + + +The Node.js' default ← `./foo.mjs` ← `./bar.mjs` + + + +When using the asynchronous hooks, the registered hooks also affect subsequent +`register` calls, which takes care of loading hook modules. In the example above, `bar.mjs` will be resolved and loaded via the hooks registered by `foo.mjs` (because `foo`'s hooks will have already been added to the chain). This allows for things like writing hooks in non-JavaScript languages, so long as earlier registered hooks transpile into JavaScript. -The `register` method cannot be called from within the module that defines the -hooks. +The `register()` method cannot be called from the thread running the hook module that +exports the asynchronous hooks or its dependencies. -Chaining of `registerHooks` work similarly. If synchronous and asynchronous -hooks are mixed, the synchronous hooks are always run first before the asynchronous -hooks start running, that is, in the last synchronous hook being run, its next -hook includes invocation of the asynchronous hooks. - -```mjs -// entrypoint.mjs -import { registerHooks } from 'node:module'; - -const hook1 = { /* implementation of hooks */ }; -const hook2 = { /* implementation of hooks */ }; -// hook2 run before hook1. -registerHooks(hook1); -registerHooks(hook2); -``` - -```cjs -// entrypoint.cjs -const { registerHooks } = require('node:module'); - -const hook1 = { /* implementation of hooks */ }; -const hook2 = { /* implementation of hooks */ }; -// hook2 run before hook1. -registerHooks(hook1); -registerHooks(hook2); -``` - -### Communication with module customization hooks +#### Communication with asynchronous module customization hooks Asynchronous hooks run on a dedicated thread, separate from the main thread that runs application code. This means mutating global variables won't @@ -896,13 +1214,27 @@ register('./my-hooks.mjs', { }); ``` -Synchronous module hooks are run on the same thread where the application code is -run. They can directly mutate the globals of the context accessed by the main thread. - -### Hooks - #### Asynchronous hooks accepted by `module.register()` + + The [`register`][] method can be used to register a module that exports a set of hooks. The hooks are functions that are called by Node.js to customize the module resolution and loading process. The exported functions must have specific @@ -928,54 +1260,6 @@ may be terminated by the main thread at any time, so do not depend on asynchronous operations (like `console.log`) to complete. They are inherited into child workers by default. -#### Synchronous hooks accepted by `module.registerHooks()` - - - -> Stability: 1.1 - Active development - -The `module.registerHooks()` method accepts synchronous hook functions. -`initialize()` is not supported nor necessary, as the hook implementer -can simply run the initialization code directly before the call to -`module.registerHooks()`. - -```mjs -function resolve(specifier, context, nextResolve) { - // Take an `import` or `require` specifier and resolve it to a URL. -} - -function load(url, context, nextLoad) { - // Take a resolved URL and return the source code to be evaluated. -} -``` - -Synchronous hooks are run in the same thread and the same [realm][] where the modules -are loaded. Unlike the asynchronous hooks they are not inherited into child worker -threads by default, though if the hooks are registered using a file preloaded by -[`--import`][] or [`--require`][], child worker threads can inherit the preloaded scripts -via `process.execArgv` inheritance. See [the documentation of `Worker`][] for detail. - -In synchronous hooks, users can expect `console.log()` to complete in the same way that -they expect `console.log()` in module code to complete. - -#### Conventions of hooks - -Hooks are part of a [chain][], even if that chain consists of only one -custom (user-provided) hook and the default hook, which is always present. Hook -functions nest: each one must always return a plain object, and chaining happens -as a result of each function calling `next()`, which is a reference to -the subsequent loader's hook (in LIFO order). - -A hook that returns a value lacking a required property triggers an exception. A -hook that returns without calling `next()` _and_ without returning -`shortCircuit: true` also triggers an exception. These errors are to help -prevent unintentional breaks in the chain. Return `shortCircuit: true` from a -hook to signal that the chain is intentionally ending at your hook. - #### `initialize()` -> Stability: 1.2 - Release candidate - * `data` {any} The data from `register(loader, import.meta.url, { data })`. The `initialize` hook is only accepted by [`register`][]. `registerHooks()` does @@ -1058,15 +1340,10 @@ register('./path-to-my-hooks.js', { }); ``` -#### `resolve(specifier, context, nextResolve)` +#### Asynchronous `resolve(specifier, context, nextResolve)` +> **Warning** In the CommonJS modules that are customized by the asynchronous customization hooks, +> `require.resolve()` and `require()` will use `"import"` export condition instead of +> `"require"`, which may cause unexpected behaviors when loading dual packages. ```mjs -// Asynchronous version accepted by module.register(). export async function resolve(specifier, context, nextResolve) { - const { parentURL = null } = context; - - if (Math.random() > 0.5) { // Some condition. - // For some or all specifiers, do some custom logic for resolving. - // Always return an object of the form {url: }. - return { - shortCircuit: true, - url: parentURL ? - new URL(specifier, parentURL).href : - new URL(specifier).href, - }; - } - - if (Math.random() < 0.5) { // Another condition. - // When calling `defaultResolve`, the arguments can be modified. In this - // case it's adding another value for matching conditional exports. + // When calling `defaultResolve`, the arguments can be modified. For example, + // to change the specifier or add conditions. + if (specifier.includes('foo')) { + specifier = specifier.replace('foo', 'bar'); return nextResolve(specifier, { ...context, conditions: [...context.conditions, 'another-condition'], }); } - // Defer to the next hook in the chain, which would be the + // The hook can also skips default resolution and provide a custom URL. + if (specifier === 'special-module') { + return { + url: 'file:///path/to/special-module.mjs', + format: 'module', + shortCircuit: true, // This is mandatory if not calling nextResolve(). + }; + } + + // If no customization is needed, defer to the next hook in the chain which would be the // Node.js default resolve if this is the last user-specified loader. return nextResolve(specifier); } ``` -```mjs -// Synchronous version accepted by module.registerHooks(). -function resolve(specifier, context, nextResolve) { - // Similar to the asynchronous resolve() above, since that one does not have - // any asynchronous logic. -} -``` - -#### `load(url, context, nextLoad)` +#### Asynchronous `load(url, context, nextLoad)` -* Returns: {Object|Promise} The asynchronous version takes either an object containing the - following properties, or a `Promise` that will resolve to such an object. The - synchronous version only accepts an object returned synchronously. +* Returns: {Promise} The asynchronous version takes either an object containing the + following properties, or a `Promise` that will resolve to such an object. * `format` {string} * `shortCircuit` {undefined|boolean} A signal that this hook intends to terminate the chain of `load` hooks. **Default:** `false` * `source` {string|ArrayBuffer|TypedArray} The source for Node.js to evaluate -The `load` hook provides a way to define a custom method of determining how a -URL should be interpreted, retrieved, and parsed. It is also in charge of -validating the import attributes. - -The final value of `format` must be one of the following: - -| `format` | Description | Acceptable types for `source` returned by `load` | -| ----------------------- | ----------------------------------------------------- | -------------------------------------------------- | -| `'addon'` | Load a Node.js addon | {null} | -| `'builtin'` | Load a Node.js builtin module | {null} | -| `'commonjs-typescript'` | Load a Node.js CommonJS module with TypeScript syntax | {string\|ArrayBuffer\|TypedArray\|null\|undefined} | -| `'commonjs'` | Load a Node.js CommonJS module | {string\|ArrayBuffer\|TypedArray\|null\|undefined} | -| `'json'` | Load a JSON file | {string\|ArrayBuffer\|TypedArray} | -| `'module-typescript'` | Load an ES module with TypeScript syntax | {string\|ArrayBuffer\|TypedArray} | -| `'module'` | Load an ES module | {string\|ArrayBuffer\|TypedArray} | -| `'wasm'` | Load a WebAssembly module | {ArrayBuffer\|TypedArray} | - -The value of `source` is ignored for type `'builtin'` because currently it is -not possible to replace the value of a Node.js builtin (core) module. - -##### Caveat in the asynchronous `load` hook +> **Warning**: The asynchronous `load` hook and namespaced exports from CommonJS +> modules are incompatible. Attempting to use them together will result in an empty +> object from the import. This may be addressed in the future. This does not apply +> to the synchronous `load` hook, in which case exports can be used as usual. -When using the asynchronous `load` hook, omitting vs providing a `source` for +The asynchronous version works similarly to the synchronous version, though +when using the asynchronous `load` hook, omitting vs providing a `source` for `'commonjs'` has very different effects: * When a `source` is provided, all `require` calls from this module will be @@ -1296,60 +1523,6 @@ This doesn't apply to the synchronous `load` hook either, in which case the `source` returned contains source code loaded by the next hook, regardless of module format. -> **Warning**: The asynchronous `load` hook and namespaced exports from CommonJS -> modules are incompatible. Attempting to use them together will result in an empty -> object from the import. This may be addressed in the future. This does not apply -> to the synchronous `load` hook, in which case exports can be used as usual. - -> These types all correspond to classes defined in ECMAScript. - -* The specific {ArrayBuffer} object is a {SharedArrayBuffer}. -* The specific {TypedArray} object is a {Uint8Array}. - -If the source value of a text-based format (i.e., `'json'`, `'module'`) -is not a string, it is converted to a string using [`util.TextDecoder`][]. - -The `load` hook provides a way to define a custom method for retrieving the -source code of a resolved URL. This would allow a loader to potentially avoid -reading files from disk. It could also be used to map an unrecognized format to -a supported one, for example `yaml` to `module`. - -```mjs -// Asynchronous version accepted by module.register(). -export async function load(url, context, nextLoad) { - const { format } = context; - - if (Math.random() > 0.5) { // Some condition - /* - For some or all URLs, do some custom logic for retrieving the source. - Always return an object of the form { - format: , - source: , - }. - */ - return { - format, - shortCircuit: true, - source: '...', - }; - } - - // Defer to the next hook in the chain. - return nextLoad(url); -} -``` - -```mjs -// Synchronous version accepted by module.registerHooks(). -function load(url, context, nextLoad) { - // Similar to the asynchronous load() above, since that one does not have - // any asynchronous logic. -} -``` - -In a more advanced scenario, this can also be used to transform an unsupported -source to a supported one (see [Examples](#examples) below). - ### Examples The various module customization hooks can be used together to accomplish @@ -1845,20 +2018,25 @@ returned object contains the following keys: [`module.enableCompileCache()`]: #moduleenablecompilecacheoptions [`module.flushCompileCache()`]: #moduleflushcompilecache [`module.getCompileCacheDir()`]: #modulegetcompilecachedir +[`module.registerHooks()`]: #moduleregisterhooksoptions [`module.setSourceMapsSupport()`]: #modulesetsourcemapssupportenabled-options [`module`]: #the-module-object [`os.tmpdir()`]: os.md#ostmpdir -[`registerHooks`]: #moduleregisterhooksoptions [`register`]: #moduleregisterspecifier-parenturl-options [`util.TextDecoder`]: util.md#class-utiltextdecoder -[chain]: #chaining +[accepted final formats]: #accepted-final-formats-returned-by-load +[asynchronous `load` hook]: #asynchronous-loadurl-context-nextload +[asynchronous `resolve` hook]: #asynchronous-resolvespecifier-context-nextresolve +[asynchronous hook functions]: #asynchronous-hooks-accepted-by-moduleregister +[caveats of asynchronous customization hooks]: #caveats-of-asynchronous-customization-hooks [hooks]: #customization-hooks -[load hook]: #loadurl-context-nextload +[load hook]: #synchronous-loadurl-context-nextload [module compile cache]: #module-compile-cache [module wrapper]: modules.md#the-module-wrapper [realm]: https://tc39.es/ecma262/#realm -[resolve hook]: #resolvespecifier-context-nextresolve +[resolve hook]: #synchronous-resolvespecifier-context-nextresolve [source map include directives]: https://tc39.es/ecma426/#sec-linking-generated-code +[synchronous hook functions]: #hook-functions-accepted-by-moduleregisterhooks [the documentation of `Worker`]: worker_threads.md#new-workerfilename-options [transferable objects]: worker_threads.md#portpostmessagevalue-transferlist [transform TypeScript features]: typescript.md#typescript-features From 6ae5d4faf0d43912a6bedec51d13ac830f50f0b0 Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Wed, 3 Dec 2025 13:26:47 +0800 Subject: [PATCH 2/3] doc: mark sync module hooks as release candidate --- doc/api/module.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/api/module.md b/doc/api/module.md index 61d302b610b549..81df4e711e61d5 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -229,7 +229,7 @@ changes: description: Synchronous and in-thread hooks are now release candidate. --> -> Stability: 1.1 - Active development +> Stability: 1.2 - Release candidate * `options` {Object} * `load` {Function|undefined} See [load hook][]. **Default:** `undefined`. @@ -602,6 +602,10 @@ changes: - -Node.js' default ← `hook1.resolve` ← `hook2.resolve` - - +Node.js default `resolve` ← `hook1.resolve` ← `hook2.resolve` The same applies to all the other hooks. @@ -1016,7 +1012,7 @@ is not a string, it is converted to a string using [`util.TextDecoder`][]. #### Caveats of asynchronous customization hooks -The asynchronous customization hooks have many caveats and the it is uncertain if their +The asynchronous customization hooks have many caveats and it is uncertain if their issues can be resolved. Users are encouraged to use the synchronous customization hooks via `module.registerHooks()` instead to avoid these caveats. @@ -1151,11 +1147,7 @@ import('./my-app.mjs'); If `foo.mjs` and `bar.mjs` define a `resolve` hook, they will be called like so (note the right-to-left, starting with `./bar.mjs`, then `./foo.mjs`, then the Node.js default): - - -The Node.js' default ← `./foo.mjs` ← `./bar.mjs` - - +Node.js default ← `./foo.mjs` ← `./bar.mjs` When using the asynchronous hooks, the registered hooks also affect subsequent `register` calls, which takes care of loading hook modules. In the example above,