From 4cafd99ee0b22dc49da7959996580c1cd97958e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 5 May 2026 09:36:43 +0200 Subject: [PATCH] docs: add module customization hooks reference page Document module.register() and module.registerHooks() APIs that allow customizing module resolution and loading in Deno. These APIs follow the Node.js module customization hooks specification and enable use cases like virtual modules, custom transpilation, module aliasing, and test mocking. Also removes the outdated "register is a non-functional stub" note from the node:module compatibility description. --- reference_gen/node_descriptions/module.yaml | 2 - runtime/_data.ts | 4 + runtime/reference/module_hooks.md | 409 ++++++++++++++++++++ 3 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 runtime/reference/module_hooks.md diff --git a/reference_gen/node_descriptions/module.yaml b/reference_gen/node_descriptions/module.yaml index 818733326..fedba463d 100644 --- a/reference_gen/node_descriptions/module.yaml +++ b/reference_gen/node_descriptions/module.yaml @@ -1,3 +1 @@ status: good -symbols: - Module: The `register` method is a non-functional stub. diff --git a/runtime/_data.ts b/runtime/_data.ts index 54632587b..e1eca984d 100644 --- a/runtime/_data.ts +++ b/runtime/_data.ts @@ -326,6 +326,10 @@ export const sidebar = [ title: "Lint plugins", href: "/runtime/reference/lint_plugins/", }, + { + title: "Module customization hooks", + href: "/runtime/reference/module_hooks/", + }, { title: "WebAssembly", href: "/runtime/reference/wasm/", diff --git a/runtime/reference/module_hooks.md b/runtime/reference/module_hooks.md new file mode 100644 index 000000000..08a3d9fb2 --- /dev/null +++ b/runtime/reference/module_hooks.md @@ -0,0 +1,409 @@ +--- +last_modified: 2026-05-05 +title: "Module Customization Hooks" +description: "Customize module resolution and loading in Deno using Node.js-compatible module.register() and module.registerHooks() APIs. Create virtual modules, transpile custom formats, and intercept imports." +--- + +Deno supports Node.js module customization hooks, allowing you to intercept and +customize how modules are resolved and loaded. This enables powerful use cases +like virtual modules, custom transpilation, module aliasing, and more. + +Two APIs are available: + +- **`module.registerHooks()`** - Synchronous, in-thread hooks for both CommonJS + and ESM +- **`module.register()`** - Asynchronous hook modules for ESM + +## module.registerHooks() + +The `registerHooks()` API lets you register synchronous hooks that run in the +same thread as your application code. It works with both CommonJS (`require()`) +and ES modules (`import`). + +```js title="main.mjs" +import { registerHooks } from "node:module"; + +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "virtual:greet") { + return { url: "file:///virtual_greet.js", shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "file:///virtual_greet.js") { + return { + source: 'export const msg = "hello from hooks";', + format: "module", + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); + +const { msg } = await import("virtual:greet"); +console.log(msg); // "hello from hooks" + +// Remove hooks when no longer needed +hooks.deregister(); +``` + +```sh +deno run --allow-all main.mjs +``` + +### resolve hook + +The `resolve` hook intercepts module resolution, allowing you to map specifiers +to URLs. + +```js +resolve(specifier, context, nextResolve) +``` + +**Parameters:** + +| Parameter | Type | Description | +| ------------- | -------- | ------------------------------------------------- | +| `specifier` | `string` | The module specifier being resolved | +| `context` | `object` | Resolution context (see below) | +| `nextResolve` | `function` | Call to delegate to the next hook or default resolver | + +**Context object:** + +| Property | Type | Description | +| ------------------ | ---------- | ----------------------------------------------------------- | +| `conditions` | `string[]` | Import conditions (e.g., `["node", "import"]` for ESM) | +| `parentURL` | `string` | URL of the importing module | +| `importAttributes` | `object` | Import attributes from the import statement | + +**Return value:** + +Must return an object with: + +| Property | Type | Description | +| -------------- | --------- | ----------------------------------------------- | +| `url` | `string` | The resolved URL for the module | +| `shortCircuit` | `boolean` | If `true`, skip remaining hooks in the chain | + +Either call `nextResolve()` to delegate, or return with `shortCircuit: true` to +provide the final result. You must do one or the other. + +### load hook + +The `load` hook intercepts module loading, allowing you to provide custom source +code. + +```js +load(url, context, nextLoad) +``` + +**Parameters:** + +| Parameter | Type | Description | +| ---------- | -------- | ------------------------------------------------- | +| `url` | `string` | The resolved module URL | +| `context` | `object` | Load context (see below) | +| `nextLoad` | `function` | Call to delegate to the next hook or default loader | + +**Context object:** + +| Property | Type | Description | +| ------------------ | ---------- | ------------------------------------------------- | +| `format` | `string` | Module format hint (e.g., `"module"`, `"commonjs"`) | +| `conditions` | `string[]` | Import conditions | +| `importAttributes` | `object` | Import attributes | + +**Return value:** + +Must return an object with: + +| Property | Type | Description | +| -------------- | -------------------------- | ------------------------------------------ | +| `source` | `string \| Buffer \| null` | The module source code | +| `format` | `string` | Module format: `"module"`, `"commonjs"`, `"json"` | +| `shortCircuit` | `boolean` | If `true`, skip remaining hooks in the chain | + +### Deregistering hooks + +`registerHooks()` returns an object with a `deregister()` method to remove the +hooks: + +```js +const hooks = registerHooks({ /* ... */ }); + +// Later, remove hooks +hooks.deregister(); +``` + +### Hook chaining + +Multiple hooks can be registered and form a chain. Hooks run in LIFO (last +registered, first called) order. Each hook can call `nextResolve()`/`nextLoad()` +to pass control to the previous hook in the chain: + +```js +import { registerHooks } from "node:module"; + +// Hook 1: registered first, runs second +const hook1 = registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + if (url.includes("target.js")) { + return { source: 'export default "from hook1"', format: "module", shortCircuit: true }; + } + return result; + }, +}); + +// Hook 2: registered second, runs first +const hook2 = registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); // Calls hook1 + if (url.includes("target.js")) { + return { source: 'export default "from hook2"', format: "module", shortCircuit: true }; + } + return result; + }, +}); + +// Result comes from hook2 since it runs first (LIFO) +``` + +### CommonJS example + +Hooks also work with `require()`: + +```js title="main.cjs" +const { registerHooks } = require("module"); + +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "virtual-module") { + return { url: "file:///virtual.js", shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "file:///virtual.js") { + return { + source: 'module.exports = { value: 42 }', + format: "commonjs", + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); + +const mod = require("virtual-module"); +console.log(mod.value); // 42 + +hooks.deregister(); +``` + +## module.register() + +The `register()` API loads a hook module that exports async `resolve` and `load` +functions. This follows the Node.js customization hooks specification and is +suitable for ESM. + +```js title="main.mjs" +import { register } from "node:module"; + +register("./hooks.mjs", import.meta.url); + +// Allow the hook module to initialize +await new Promise((resolve) => setTimeout(resolve, 50)); + +const { greeting } = await import("virtual:hello"); +console.log(greeting); // "hello from register hooks" +``` + +```js title="hooks.mjs" +export async function resolve(specifier, context, nextResolve) { + if (specifier === "virtual:hello") { + return { url: "file:///virtual_hello.js", shortCircuit: true }; + } + return nextResolve(specifier, context); +} + +export async function load(url, context, nextLoad) { + if (url === "file:///virtual_hello.js") { + return { + source: 'export const greeting = "hello from register hooks";', + format: "module", + shortCircuit: true, + }; + } + return nextLoad(url, context); +} +``` + +### Passing data to hooks + +You can pass initialization data to hook modules using the `data` option and an +`initialize` export: + +```js title="main.mjs" +import { register } from "node:module"; +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +register("./hooks.mjs", { + parentURL: import.meta.url, + data: { port: port2 }, + transferList: [port2], +}); +``` + +```js title="hooks.mjs" +let port; + +export async function initialize(data) { + port = data.port; + port.postMessage("hooks initialized"); +} + +export async function resolve(specifier, context, nextResolve) { + port.postMessage(`resolving: ${specifier}`); + return nextResolve(specifier, context); +} + +export async function load(url, context, nextLoad) { + return nextLoad(url, context); +} +``` + +### Options + +```js +register(specifier, parentURL) +register(specifier, options) +register(specifier, parentURL, options) +``` + +| Option | Type | Description | +| -------------- | -------- | ------------------------------------------------- | +| `parentURL` | `string \| URL` | Base URL for resolving relative hook module specifiers | +| `data` | `any` | Data passed to the hook module's `initialize()` function | +| `transferList` | `any[]` | Objects to transfer to the hook module | + +### Hook execution order + +When both `registerHooks()` and `register()` are used, synchronous hooks +(`registerHooks`) always run before async hooks (`register`). Within each +category, hooks run in LIFO order (last registered runs first). + +## Use cases + +### Custom transpilation + +Transform non-standard file formats on the fly: + +```js +import { registerHooks } from "node:module"; + +registerHooks({ + load(url, context, nextLoad) { + if (url.endsWith(".coffee")) { + const result = nextLoad(url, context); + const compiled = compileCoffeeScript(result.source); + return { source: compiled, format: "module", shortCircuit: true }; + } + return nextLoad(url, context); + }, +}); +``` + +### Module aliasing + +Redirect imports to different modules: + +```js +import { registerHooks } from "node:module"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + // Redirect lodash to lodash-es + if (specifier === "lodash") { + return nextResolve("lodash-es", context); + } + return nextResolve(specifier, context); + }, +}); +``` + +### Virtual modules + +Create modules that exist only in memory: + +```js +import { registerHooks } from "node:module"; + +const virtualModules = new Map([ + ["virtual:config", 'export default { debug: true, version: "1.0.0" };'], + ["virtual:env", `export const NODE_ENV = "${process.env.NODE_ENV}";`], +]); + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (virtualModules.has(specifier)) { + return { url: `file:///virtual/${specifier}`, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + for (const [name, source] of virtualModules) { + if (url === `file:///virtual/${name}`) { + return { source, format: "module", shortCircuit: true }; + } + } + return nextLoad(url, context); + }, +}); +``` + +### Mocking for tests + +Replace modules with mocks during testing: + +```js +import { registerHooks } from "node:module"; + +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "./database.js") { + return { url: "file:///mock_database.js", shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "file:///mock_database.js") { + return { + source: 'export const query = () => [{ id: 1, name: "mock" }];', + format: "module", + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); + +// Run tests... + +hooks.deregister(); // Clean up after tests +``` + +## Compatibility with Node.js + +Deno's implementation follows the Node.js module customization hooks +specification. Key implementation details: + +- Both sync and async hooks run in the same thread (Node.js runs `register()` + hooks in a separate loader thread) +- `registerHooks()` works with both CommonJS and ESM +- `register()` works with ESM only +- The `transferList` option passes items by reference (same-thread model)