From e5bb6c23039ea33cf929e0eb8e3c3b2e05b39210 Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Tue, 29 Oct 2024 16:08:12 +0100 Subject: [PATCH] module: implement module.registerHooks() PR-URL: https://github.com/nodejs/node/pull/55698 Reviewed-By: Geoffrey Booth Reviewed-By: Chengzhong Wu Reviewed-By: Guy Bedford --- .github/label-pr-config.yml | 2 + doc/api/module.md | 374 ++++++++++++++++-- lib/internal/modules/cjs/loader.js | 253 ++++++++++-- lib/internal/modules/customization_hooks.js | 366 +++++++++++++++++ lib/internal/modules/esm/loader.js | 63 ++- lib/internal/modules/esm/module_job.js | 3 +- lib/internal/modules/helpers.js | 5 +- test/fixtures/module-hooks/add-hook.js | 30 ++ test/fixtures/module-hooks/get-stats.js | 20 + .../module-hooks/load-from-this-dir.js | 4 + test/fixtures/module-hooks/log-user.cts | 3 + test/fixtures/module-hooks/log-user.mts | 4 + .../node_modules/bar-esm/bar-esm.js | 1 + .../node_modules/bar-esm/package.json | 6 + .../module-hooks/node_modules/bar/bar.js | 3 + .../node_modules/bar/package.json | 6 + .../node_modules/foo-esm/foo-esm.js | 1 + .../node_modules/foo-esm/package.json | 7 + .../module-hooks/node_modules/foo/foo.js | 3 + .../node_modules/foo/package.json | 6 + .../module-hooks/redirected-assert.js | 1 + test/fixtures/module-hooks/redirected-fs.js | 1 + test/fixtures/module-hooks/redirected-zlib.js | 1 + .../module-hooks/register-typescript-hooks.js | 4 + .../module-hooks/typescript-transpiler.js | 71 ++++ test/fixtures/module-hooks/user.ts | 18 + test/module-hooks/module-hooks.status | 7 + .../test-module-hooks-import-wasm.mjs | 35 ++ .../test-module-hooks-load-buffers.js | 50 +++ .../test-module-hooks-load-builtin-import.mjs | 29 ++ .../test-module-hooks-load-builtin-require.js | 29 ++ .../test-module-hooks-load-chained.js | 34 ++ .../test-module-hooks-load-detection.js | 21 + .../test-module-hooks-load-esm-mock.js | 51 +++ .../test-module-hooks-load-esm.js | 51 +++ .../test-module-hooks-load-invalid.js | 39 ++ .../test-module-hooks-load-mock.js | 48 +++ ...ooks-load-short-circuit-required-middle.js | 33 ++ ...hooks-load-short-circuit-required-start.js | 29 ++ .../test-module-hooks-load-short-circuit.js | 28 ++ .../module-hooks/test-module-hooks-preload.js | 49 +++ .../test-module-hooks-require-wasm.js | 34 ++ ...e-hooks-resolve-builtin-builtin-import.mjs | 27 ++ ...e-hooks-resolve-builtin-builtin-require.js | 26 ++ ...e-hooks-resolve-builtin-on-disk-import.mjs | 36 ++ ...e-hooks-resolve-builtin-on-disk-require.js | 29 ++ .../test-module-hooks-resolve-invalid.js | 36 ++ ...load-import-inline-typescript-override.mjs | 11 + ...-resolve-load-import-inline-typescript.mjs | 11 + ...load-require-inline-typescript-override.js | 13 + ...-resolve-load-require-inline-typescript.js | 12 + ...s-resolve-short-circuit-required-middle.js | 32 ++ ...ks-resolve-short-circuit-required-start.js | 28 ++ ...test-module-hooks-resolve-short-circuit.js | 29 ++ test/module-hooks/testcfg.py | 6 + test/parallel/test-bootstrap-modules.js | 1 + test/parallel/test-repl.js | 18 +- 57 files changed, 2044 insertions(+), 94 deletions(-) create mode 100644 lib/internal/modules/customization_hooks.js create mode 100644 test/fixtures/module-hooks/add-hook.js create mode 100644 test/fixtures/module-hooks/get-stats.js create mode 100644 test/fixtures/module-hooks/load-from-this-dir.js create mode 100644 test/fixtures/module-hooks/log-user.cts create mode 100644 test/fixtures/module-hooks/log-user.mts create mode 100644 test/fixtures/module-hooks/node_modules/bar-esm/bar-esm.js create mode 100644 test/fixtures/module-hooks/node_modules/bar-esm/package.json create mode 100644 test/fixtures/module-hooks/node_modules/bar/bar.js create mode 100644 test/fixtures/module-hooks/node_modules/bar/package.json create mode 100644 test/fixtures/module-hooks/node_modules/foo-esm/foo-esm.js create mode 100644 test/fixtures/module-hooks/node_modules/foo-esm/package.json create mode 100644 test/fixtures/module-hooks/node_modules/foo/foo.js create mode 100644 test/fixtures/module-hooks/node_modules/foo/package.json create mode 100644 test/fixtures/module-hooks/redirected-assert.js create mode 100644 test/fixtures/module-hooks/redirected-fs.js create mode 100644 test/fixtures/module-hooks/redirected-zlib.js create mode 100644 test/fixtures/module-hooks/register-typescript-hooks.js create mode 100644 test/fixtures/module-hooks/typescript-transpiler.js create mode 100644 test/fixtures/module-hooks/user.ts create mode 100644 test/module-hooks/module-hooks.status create mode 100644 test/module-hooks/test-module-hooks-import-wasm.mjs create mode 100644 test/module-hooks/test-module-hooks-load-buffers.js create mode 100644 test/module-hooks/test-module-hooks-load-builtin-import.mjs create mode 100644 test/module-hooks/test-module-hooks-load-builtin-require.js create mode 100644 test/module-hooks/test-module-hooks-load-chained.js create mode 100644 test/module-hooks/test-module-hooks-load-detection.js create mode 100644 test/module-hooks/test-module-hooks-load-esm-mock.js create mode 100644 test/module-hooks/test-module-hooks-load-esm.js create mode 100644 test/module-hooks/test-module-hooks-load-invalid.js create mode 100644 test/module-hooks/test-module-hooks-load-mock.js create mode 100644 test/module-hooks/test-module-hooks-load-short-circuit-required-middle.js create mode 100644 test/module-hooks/test-module-hooks-load-short-circuit-required-start.js create mode 100644 test/module-hooks/test-module-hooks-load-short-circuit.js create mode 100644 test/module-hooks/test-module-hooks-preload.js create mode 100644 test/module-hooks/test-module-hooks-require-wasm.js create mode 100644 test/module-hooks/test-module-hooks-resolve-builtin-builtin-import.mjs create mode 100644 test/module-hooks/test-module-hooks-resolve-builtin-builtin-require.js create mode 100644 test/module-hooks/test-module-hooks-resolve-builtin-on-disk-import.mjs create mode 100644 test/module-hooks/test-module-hooks-resolve-builtin-on-disk-require.js create mode 100644 test/module-hooks/test-module-hooks-resolve-invalid.js create mode 100644 test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript-override.mjs create mode 100644 test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript.mjs create mode 100644 test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript-override.js create mode 100644 test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript.js create mode 100644 test/module-hooks/test-module-hooks-resolve-short-circuit-required-middle.js create mode 100644 test/module-hooks/test-module-hooks-resolve-short-circuit-required-start.js create mode 100644 test/module-hooks/test-module-hooks-resolve-short-circuit.js create mode 100644 test/module-hooks/testcfg.py diff --git a/.github/label-pr-config.yml b/.github/label-pr-config.yml index ea3f1d145e414c..809ec49eb029e7 100644 --- a/.github/label-pr-config.yml +++ b/.github/label-pr-config.yml @@ -125,6 +125,8 @@ exlusiveLabels: /^test\/es-module\//: test, esm /^test\/fixtures\/wpt\/streams\//: test, web streams /^test\/fixtures\/typescript/: test, strip-types + /^test\/module-hooks\//: test, module, loaders + /^test\/fixtures/module-hooks\//: test, module, loaders /^test\//: test diff --git a/doc/api/module.md b/doc/api/module.md index 42809cea3b9565..b7c204ff75c6e0 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -199,6 +199,21 @@ changes: Register a module that exports [hooks][] that customize Node.js module resolution and loading behavior. See [Customization hooks][]. +### `module.registerHooks(options)` + + + +> Stability: 1.1 - Active development + +* `options` {Object} + * `load` {Function|undefined} See [load hook][]. **Default:** `undefined`. + * `resolve` {Function|undefined} See [resolve hook][]. **Default:** `undefined`. + +Register [hooks][] that customize Node.js module resolution and loading behavior. +See [Customization hooks][]. + ### `module.stripTypeScriptTypes(code[, options])` -> Stability: 1.2 - Release candidate - +> Stability: 1.2 - Release candidate (asynchronous version) +> Stability: 1.1 - Active development (synchronous version) + +There are two types of module customization hooks that are currently supported: + +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. + ### Enabling -Module resolution and loading can be customized by registering a file which -exports a set of hooks. This can be done using the [`register`][] method -from `node:module`, which you can run before your application code by -using the `--import` flag: +Module resolution and loading can be customized by: + +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: ```bash node --import ./register-hooks.js ./my-app.js +node --require ./register-hooks.js ./my-app.js ``` ```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); ``` @@ -556,24 +590,46 @@ register('./hooks.mjs', import.meta.url); // 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)); ``` -The file passed to `--import` can also be an export from a dependency: +```mjs +// Use module.registerHooks() to register synchronous hooks in the main thread. +import { registerHooks } from 'node:module'; +registerHooks({ + resolve(specifier, context, nextResolve) { /* implementation */ }, + load(url, context, nextLoad) { /* implementation */ }, +}); +``` + +```cjs +// Use module.registerHooks() to register synchronous hooks in the main thread. +const { registerHooks } = require('node:module'); +registerHooks({ + resolve(specifier, context, nextResolve) { /* implementation */ }, + load(url, context, nextLoad) { /* implementation */ }, +}); +``` + +The file passed to `--import` or `--require` can also be an export from a dependency: ```bash node --import some-package/register ./my-app.js +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. -Using `--import` ensures that the hooks are registered before any application -files are imported, including the entry point of the application. Alternatively, -`register` can be called from the entry point, but dynamic `import()` must be -used for any code that should be run after the hooks are registered: +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 +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. ```mjs import { register } from 'node:module'; @@ -596,18 +652,52 @@ register('http-to-https', pathToFileURL(__filename)); import('./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. + 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` (and optionally `require`). If the -`import('./my-app.js')` had instead been a static `import './my-app.js'`, the +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. + +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. -`my-app.js` can also be CommonJS. Customization hooks will run for any -modules that it references via `import` (and optionally `require`). +If synchronous hooks are used, both `import`, `require` and user `require` created +using `createRequire()` are supported. + +```mjs +import { registerHooks, createRequire } from 'node:module'; + +registerHooks({ /* implementation of synchronous hooks */ }); + +const require = createRequire(import.meta.url); + +// The synchronous hooks affect import, require() and user require() function +// created through createRequire(). +await import('./my-app.js'); +require('./my-app-2.js'); +``` + +```cjs +const { register, registerHooks } = require('node:module'); +const { pathToFileURL } = require('node:url'); + +registerHooks({ /* implementation of synchronous hooks */ }); + +const userRequire = createRequire(__filename); + +// 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'); +``` 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:` @@ -657,9 +747,36 @@ earlier registered hooks transpile into JavaScript. The `register` method cannot be called from within the module that defines the hooks. +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 -Module customization hooks run on a dedicated thread, separate from the main +Asynchronous hooks run on a dedicated thread, separate from the main thread that runs application code. This means mutating global variables won't affect the other thread(s), and message channels must be used to communicate between the threads. @@ -708,8 +825,13 @@ 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 @@ -729,6 +851,46 @@ export async function load(url, context, nextLoad) { } ``` +Asynchronous hooks are run in a separate thread, isolated from the main thread where +application code runs. That means it is a different [realm][]. The hooks thread +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 @@ -741,11 +903,6 @@ hook that returns without calling `next()` _and_ without returning prevent unintentional breaks in the chain. Return `shortCircuit: true` from a hook to signal that the chain is intentionally ending at your hook. -Hooks are run in a separate thread, isolated from the main thread where -application code runs. That means it is a different [realm][]. The hooks thread -may be terminated by the main thread at any time, so do not depend on -asynchronous operations (like `console.log`) to complete. - #### `initialize()` -> Stability: 1.2 - Release candidate +> Stability: 1.2 - Release candidate (asynchronous version) +> Stability: 1.1 - Active development (synchronous version) * `specifier` {string} * `context` {Object} @@ -863,7 +1028,9 @@ changes: Node.js default `resolve` hook after the last user-supplied `resolve` hook * `specifier` {string} * `context` {Object} -* Returns: {Object|Promise} +* 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. * `format` {string|null|undefined} A hint to the load hook (it might be ignored) `'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'` @@ -873,8 +1040,9 @@ changes: terminate the chain of `resolve` hooks. **Default:** `false` * `url` {string} The absolute URL to which this input resolves -> **Warning** Despite support for returning promises and async functions, calls -> to `resolve` may block the main thread which can impact performance. +> **Warning** In the case of the asynchronous version, despite support for returning +> promises and async functions, calls to `resolve` may still block the main thread which +> can impact performance. 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 @@ -889,8 +1057,8 @@ 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 for -[package exports conditions][Conditional exports] that apply to this resolution +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. @@ -900,7 +1068,11 @@ 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 +// Asynchronous version accepted by module.register(). export async function resolve(specifier, context, nextResolve) { const { parentURL = null } = context; @@ -930,10 +1102,21 @@ export async function resolve(specifier, context, nextResolve) { } ``` +```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)` -> Stability: 1.2 - Release candidate +> Stability: 1.2 - Release candidate (asynchronous version) +> Stability: 1.1 - Active development (synchronous version) * `url` {string} The URL returned by the `resolve` chain * `context` {Object} @@ -958,7 +1142,9 @@ changes: Node.js default `load` hook after the last user-supplied `load` hook * `url` {string} * `context` {Object} -* Returns: {Object} +* 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. * `format` {string} * `shortCircuit` {undefined|boolean} A signal that this hook intends to terminate the chain of `load` hooks. **Default:** `false` @@ -981,7 +1167,10 @@ The final value of `format` must be one of the following: 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. -Omitting vs providing a `source` for `'commonjs'` has very different effects: +##### Caveat in the asynchronous `load` hook + +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 processed by the ESM loader with registered `resolve` and `load` hooks; all @@ -995,7 +1184,12 @@ Omitting vs providing a `source` for `'commonjs'` has very different effects: registered hooks. This behavior for nullish `source` is temporary — in the future, nullish `source` will not be supported. -The Node.js internal `load` implementation, which is the value of `next` for the +These caveats do not apply to the synchronous `load` hook, in which case +the complete set of CommonJS APIs available to the customized CommonJS +modules, and `require`/`require.resolve` always go through the registered +hooks. + +The Node.js internal asynchronous `load` implementation, which is the value of `next` for the last hook in the `load` chain, returns `null` for `source` when `format` is `'commonjs'` for backward compatibility. Here is an example hook that would opt-in to using the non-default behavior: @@ -1003,6 +1197,8 @@ opt-in to using the non-default behavior: ```mjs import { readFile } from 'node:fs/promises'; +// Asynchronous version accepted by module.register(). This fix is not needed +// for the synchronous version accepted by module.registerSync(). export async function load(url, context, nextLoad) { const result = await nextLoad(url, context); if (result.format === 'commonjs') { @@ -1012,9 +1208,14 @@ export async function load(url, context, nextLoad) { } ``` -> **Warning**: The ESM `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 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. @@ -1030,6 +1231,7 @@ 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; @@ -1053,6 +1255,14 @@ export async function load(url, context, nextLoad) { } ``` +```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). @@ -1111,6 +1321,10 @@ With the preceding hooks module, running prints the current version of CoffeeScript per the module at the URL in `main.mjs`. + + #### Transpilation Sources that are in formats Node.js doesn't understand can be converted into @@ -1119,6 +1333,8 @@ JavaScript using the [`load` hook][load hook]. This is less performant than transpiling source files before running Node.js; transpiler hooks should only be used for development and testing purposes. +##### Asynchronous version + ```mjs // coffeescript-hooks.mjs import { readFile } from 'node:fs/promises'; @@ -1184,6 +1400,57 @@ async function getPackageType(url) { } ``` +##### Synchronous version + +```mjs +// coffeescript-sync-hooks.mjs +import { readFileSync } from 'node:fs/promises'; +import { registerHooks } from 'node:module'; +import { dirname, extname, resolve as resolvePath } from 'node:path'; +import { cwd } from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import coffeescript from 'coffeescript'; + +const extensionsRegex = /\.(coffee|litcoffee|coffee\.md)$/; + +function load(url, context, nextLoad) { + if (extensionsRegex.test(url)) { + const format = getPackageType(url); + + const { source: rawSource } = nextLoad(url, { ...context, format }); + const transformedSource = coffeescript.compile(rawSource.toString(), url); + + return { + format, + shortCircuit: true, + source: transformedSource, + }; + } + + return nextLoad(url); +} + +function getPackageType(url) { + const isFilePath = !!extname(url); + const dir = isFilePath ? dirname(fileURLToPath(url)) : url; + const packagePath = resolvePath(dir, 'package.json'); + + let type; + try { + const filestring = readFileSync(packagePath, { encoding: 'utf8' }); + type = JSON.parse(filestring).type; + } catch (err) { + if (err?.code !== 'ENOENT') console.error(err); + } + if (type) return type; + return dir.length > 1 && getPackageType(resolvePath(dir, '..')); +} + +registerHooks({ load }); +``` + +#### Running hooks + ```coffee # main.coffee import { scream } from './scream.coffee' @@ -1198,8 +1465,9 @@ console.log "Brought to you by Node.js version #{version}" export scream = (str) -> str.toUpperCase() ``` -With the preceding hooks module, running +With the preceding hooks modules, running `node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(pathToFileURL("./coffeescript-hooks.mjs"));' ./main.coffee` +or `node --import ./coffeescript-sync-hooks.mjs ./main.coffee` causes `main.coffee` to be turned into JavaScript after its source code is loaded from disk but before Node.js executes it; and so on for any `.coffee`, `.litcoffee` or `.coffee.md` files referenced via `import` statements of any @@ -1212,6 +1480,8 @@ The previous two examples defined `load` hooks. This is an example of a which specifiers to override to other URLs (this is a very simplistic implementation of a small subset of the "import maps" specification). +##### Asynchronous version + ```mjs // import-map-hooks.js import fs from 'node:fs/promises'; @@ -1227,6 +1497,28 @@ export async function resolve(specifier, context, nextResolve) { } ``` +##### Synchronous version + +```mjs +// import-map-sync-hooks.js +import fs from 'node:fs/promises'; +import module from 'node:module'; + +const { imports } = JSON.parse(fs.readFileSync('import-map.json', 'utf-8')); + +function resolve(specifier, context, nextResolve) { + if (Object.hasOwn(imports, specifier)) { + return nextResolve(imports[specifier], context); + } + + return nextResolve(specifier, context); +} + +module.registerHooks({ resolve }); +``` + +##### Using the hooks + With these files: ```mjs @@ -1249,6 +1541,7 @@ console.log('some module!'); ``` Running `node --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(pathToFileURL("./import-map-hooks.js"));' main.js` +or `node --import ./import-map-sync-hooks.js main.js` should print `some module!`. ## Source map v3 support @@ -1404,6 +1697,8 @@ returned object contains the following keys: [V8 code cache]: https://v8.dev/blog/code-caching-for-devs [`"exports"`]: packages.md#exports [`--enable-source-maps`]: cli.md#--enable-source-maps +[`--import`]: cli.md#--importmodule +[`--require`]: cli.md#-r---require-module [`ArrayBuffer`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer [`NODE_COMPILE_CACHE=dir`]: cli.md#node_compile_cachedir [`NODE_DISABLE_COMPILE_CACHE=1`]: cli.md#node_disable_compile_cache1 @@ -1419,6 +1714,7 @@ returned object contains the following keys: [`module.getCompileCacheDir()`]: #modulegetcompilecachedir [`module`]: #the-module-object [`os.tmpdir()`]: os.md#ostmpdir +[`registerHooks`]: #moduleregisterhooksoptions [`register`]: #moduleregisterspecifier-parenturl-options [`string`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String [`util.TextDecoder`]: util.md#class-utiltextdecoder @@ -1429,7 +1725,9 @@ returned object contains the following keys: [module wrapper]: modules.md#the-module-wrapper [prefix-only modules]: modules.md#built-in-modules-with-mandatory-node-prefix [realm]: https://tc39.es/ecma262/#realm +[resolve hook]: #resolvespecifier-context-nextresolve [source map include directives]: https://sourcemaps.info/spec.html#h.lmz475t4mvbx +[the documentation of `Worker`]: worker_threads.md#new-workerfilename-options [transferable objects]: worker_threads.md#portpostmessagevalue-transferlist [transform TypeScript features]: typescript.md#typescript-features [type-stripping]: typescript.md#type-stripping diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index f99d0fc2a7a0eb..1aadb45f936d6c 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -102,6 +102,7 @@ const kIsCachedByESMLoader = Symbol('kIsCachedByESMLoader'); const kRequiredModuleSymbol = Symbol('kRequiredModuleSymbol'); const kIsExecuting = Symbol('kIsExecuting'); +const kURL = Symbol('kURL'); const kFormat = Symbol('kFormat'); // Set first due to cycle with ESM loader functions. @@ -112,6 +113,9 @@ module.exports = { kModuleCircularVisited, initializeCJS, Module, + findLongestRegisteredExtension, + resolveForCJSWithHooks, + loadSourceForCJSWithHooks: loadSource, wrapSafe, wrapModuleLoad, kIsMainSymbol, @@ -157,6 +161,15 @@ const { stripBOM, toRealPath, } = require('internal/modules/helpers'); +const { + convertCJSFilenameToURL, + convertURLToCJSFilename, + loadHooks, + loadWithHooks, + registerHooks, + resolveHooks, + resolveWithHooks, +} = require('internal/modules/customization_hooks'); const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); const packageJsonReader = require('internal/modules/package_json_reader'); const { getOptionValue, getEmbedderOptions } = require('internal/options'); @@ -173,6 +186,7 @@ const { ERR_REQUIRE_CYCLE_MODULE, ERR_REQUIRE_ESM, ERR_UNKNOWN_BUILTIN_MODULE, + ERR_UNKNOWN_MODULE_FORMAT, }, setArrowMessage, } = require('internal/errors'); @@ -585,7 +599,7 @@ function trySelfParentPath(parent) { * @param {string} parentPath The path of the parent module * @param {string} request The module request to resolve */ -function trySelf(parentPath, request) { +function trySelf(parentPath, request, conditions) { if (!parentPath) { return false; } const pkg = packageJsonReader.getNearestParentPackageJSON(parentPath); @@ -606,7 +620,7 @@ function trySelf(parentPath, request) { const { packageExportsResolve } = require('internal/modules/esm/resolve'); return finalizeEsmResolution(packageExportsResolve( pathToFileURL(pkg.path), expansion, pkg.data, - pathToFileURL(parentPath), getCjsConditions()), parentPath, pkg.path); + pathToFileURL(parentPath), conditions), parentPath, pkg.path); } catch (e) { if (e.code === 'ERR_MODULE_NOT_FOUND') { throw createEsmNotFoundErr(request, pkg.path); @@ -627,7 +641,7 @@ const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/; * @param {string} nmPath The path to the module. * @param {string} request The request for the module. */ -function resolveExports(nmPath, request) { +function resolveExports(nmPath, request, conditions) { // The implementation's behavior is meant to mirror resolution in ESM. const { 1: name, 2: expansion = '' } = RegExpPrototypeExec(EXPORTS_PATTERN, request) || kEmptyObject; @@ -639,7 +653,7 @@ function resolveExports(nmPath, request) { const { packageExportsResolve } = require('internal/modules/esm/resolve'); return finalizeEsmResolution(packageExportsResolve( pathToFileURL(pkgPath + '/package.json'), '.' + expansion, pkg, null, - getCjsConditions()), null, pkgPath); + conditions), null, pkgPath); } catch (e) { if (e.code === 'ERR_MODULE_NOT_FOUND') { throw createEsmNotFoundErr(request, pkgPath + '/package.json'); @@ -681,7 +695,7 @@ function getDefaultExtensions() { * @param {boolean} isMain Whether the request is the main app entry point * @returns {string | false} */ -Module._findPath = function(request, paths, isMain) { +Module._findPath = function(request, paths, isMain, conditions = getCjsConditions()) { const absoluteRequest = path.isAbsolute(request); if (absoluteRequest) { paths = ['']; @@ -736,7 +750,7 @@ Module._findPath = function(request, paths, isMain) { } if (!absoluteRequest) { - const exportsResolved = resolveExports(curPath, request); + const exportsResolved = resolveExports(curPath, request, conditions); if (exportsResolved) { return exportsResolved; } @@ -1017,6 +1031,153 @@ function getExportsForCircularRequire(module) { return module.exports; } +/** + * Resolve a module request for CommonJS, invoking hooks from module.registerHooks() + * if necessary. + * @param {string} specifier + * @param {Module|undefined} parent + * @param {boolean} isMain + * @returns {{url?: string, format?: string, parentURL?: string, filename: string}} + */ +function resolveForCJSWithHooks(specifier, parent, isMain) { + let defaultResolvedURL; + let defaultResolvedFilename; + let format; + + function defaultResolveImpl(specifier, parent, isMain, options) { + // For backwards compatibility, when encountering requests starting with node:, + // throw ERR_UNKNOWN_BUILTIN_MODULE on failure or return the normalized ID on success + // without going into Module._resolveFilename. + let normalized; + if (StringPrototypeStartsWith(specifier, 'node:')) { + normalized = BuiltinModule.normalizeRequirableId(specifier); + if (!normalized) { + throw new ERR_UNKNOWN_BUILTIN_MODULE(specifier); + } + defaultResolvedURL = specifier; + format = 'builtin'; + return normalized; + } + return Module._resolveFilename(specifier, parent, isMain, options).toString(); + } + + // Fast path: no hooks, just return simple results. + if (!resolveHooks.length) { + const filename = defaultResolveImpl(specifier, parent, isMain); + return { __proto__: null, url: defaultResolvedURL, filename, format }; + } + + // Slow path: has hooks, do the URL conversions and invoke hooks with contexts. + let parentURL; + if (parent) { + if (!parent[kURL] && parent.filename) { + parent[kURL] = convertCJSFilenameToURL(parent.filename); + } + parentURL = parent[kURL]; + } + + // This is used as the last nextResolve for the resolve hooks. + function defaultResolve(specifier, context) { + // TODO(joyeecheung): parent and isMain should be part of context, then we + // no longer need to use a different defaultResolve for every resolution. + defaultResolvedFilename = defaultResolveImpl(specifier, parent, isMain, { + __proto__: null, + conditions: context.conditions, + }); + + defaultResolvedURL = convertCJSFilenameToURL(defaultResolvedFilename); + return { __proto__: null, url: defaultResolvedURL }; + } + + const resolveResult = resolveWithHooks(specifier, parentURL, /* importAttributes */ undefined, + getCjsConditions(), defaultResolve); + const { url } = resolveResult; + format = resolveResult.format; + + let filename; + if (url === defaultResolvedURL) { // Not overridden, skip the re-conversion. + filename = defaultResolvedFilename; + } else { + filename = convertURLToCJSFilename(url); + } + + return { __proto__: null, url, format, filename, parentURL }; +} + +/** + * @typedef {import('internal/modules/customization_hooks').ModuleLoadContext} ModuleLoadContext; + * @typedef {import('internal/modules/customization_hooks').ModuleLoadResult} ModuleLoadResult; + */ + +/** + * Load the source code of a module based on format. + * @param {string} filename Filename of the module. + * @param {string|undefined|null} format Format of the module. + * @returns {string|null} + */ +function defaultLoadImpl(filename, format) { + switch (format) { + case undefined: + case null: + case 'module': + case 'commonjs': + case 'json': + case 'module-typescript': + case 'commonjs-typescript': + case 'typescript': { + return fs.readFileSync(filename, 'utf8'); + } + case 'builtin': + return null; + default: + // URL is not necessarily necessary/available - convert it on the spot for errors. + throw new ERR_UNKNOWN_MODULE_FORMAT(format, convertCJSFilenameToURL(filename)); + } +} + +/** + * Construct a last nextLoad() for load hooks invoked for the CJS loader. + * @param {string} url URL passed from the hook. + * @param {string} filename Filename inferred from the URL. + * @returns {(url: string, context: ModuleLoadContext) => ModuleLoadResult} + */ +function getDefaultLoad(url, filename) { + return function defaultLoad(urlFromHook, context) { + // If the url is the same as the original one, save the conversion. + const isLoadingOriginalModule = (urlFromHook === url); + const filenameFromHook = isLoadingOriginalModule ? filename : convertURLToCJSFilename(url); + const source = defaultLoadImpl(filenameFromHook, context.format); + // Format from context is directly returned, because format detection should only be + // done after the entire load chain is completed. + return { source, format: context.format }; + }; +} + +/** + * Load a specified builtin module, invoking load hooks if necessary. + * @param {string} id The module ID (without the node: prefix) + * @param {string} url The module URL (with the node: prefix) + * @param {string} format Format from resolution. + * @returns {any} If there are no load hooks or the load hooks do not override the format of the + * builtin, load and return the exports of the builtin. Otherwise, return undefined. + */ +function loadBuiltinWithHooks(id, url, format) { + if (loadHooks.length) { + url ??= `node:${id}`; + // TODO(joyeecheung): do we really want to invoke the load hook for the builtins? + const loadResult = loadWithHooks(url, format || 'builtin', /* importAttributes */ undefined, + getCjsConditions(), getDefaultLoad(url, id)); + if (loadResult.format && loadResult.format !== 'builtin') { + return undefined; // Format has been overridden, return undefined for the caller to continue loading. + } + } + + // No hooks or the hooks have not overridden the format. Load it as a builtin module and return the + // exports. + const mod = loadBuiltinModule(id); + return mod.exports; +} + /** * Load a module from cache if it exists, otherwise create a new module instance. * 1. If a module already exists in the cache: return its exports object. @@ -1051,19 +1212,18 @@ Module._load = function(request, parent, isMain) { } } - if (StringPrototypeStartsWith(request, 'node:')) { - // Slice 'node:' prefix - const id = StringPrototypeSlice(request, 5); + const { url, format, filename } = resolveForCJSWithHooks(request, parent, isMain); - if (!BuiltinModule.canBeRequiredByUsers(id)) { - throw new ERR_UNKNOWN_BUILTIN_MODULE(request); + // For backwards compatibility, if the request itself starts with node:, load it before checking + // Module._cache. Otherwise, load it after the check. + if (StringPrototypeStartsWith(request, 'node:')) { + const result = loadBuiltinWithHooks(filename, url, format); + if (result) { + return result; } - - const module = loadBuiltinModule(id, request); - return module.exports; + // The format of the builtin has been overridden by user hooks. Continue loading. } - const filename = Module._resolveFilename(request, parent, isMain); const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) { updateChildren(parent, cachedModule, true); @@ -1088,8 +1248,11 @@ Module._load = function(request, parent, isMain) { } if (BuiltinModule.canBeRequiredWithoutScheme(filename)) { - const mod = loadBuiltinModule(filename, request); - return mod.exports; + const result = loadBuiltinWithHooks(filename, url, format); + if (result) { + return result; + } + // The format of the builtin has been overridden by user hooks. Continue loading. } // Don't call updateChildren(), Module constructor already does. @@ -1108,6 +1271,10 @@ Module._load = function(request, parent, isMain) { reportModuleToWatchMode(filename); Module._cache[filename] = module; module[kIsCachedByESMLoader] = false; + // If there are resolve hooks, carry the context information into the + // load hooks for the module keyed by the (potentially customized) filename. + module[kURL] = url; + module[kFormat] = format; } if (parent !== undefined) { @@ -1150,11 +1317,13 @@ Module._load = function(request, parent, isMain) { * @param {ResolveFilenameOptions} options Options object * @typedef {object} ResolveFilenameOptions * @property {string[]} paths Paths to search for modules in + * @property {string[]} conditions Conditions used for resolution. */ Module._resolveFilename = function(request, parent, isMain, options) { if (BuiltinModule.normalizeRequirableId(request)) { return request; } + const conditions = (options?.conditions) || getCjsConditions(); let paths; @@ -1200,7 +1369,7 @@ Module._resolveFilename = function(request, parent, isMain, options) { try { const { packageImportsResolve } = require('internal/modules/esm/resolve'); return finalizeEsmResolution( - packageImportsResolve(request, pathToFileURL(parentPath), getCjsConditions()), + packageImportsResolve(request, pathToFileURL(parentPath), conditions), parentPath, pkg.path, ); @@ -1215,7 +1384,7 @@ Module._resolveFilename = function(request, parent, isMain, options) { // Try module self resolution first const parentPath = trySelfParentPath(parent); - const selfResolved = trySelf(parentPath, request); + const selfResolved = trySelf(parentPath, request, conditions); if (selfResolved) { const cacheKey = request + '\x00' + (paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00')); @@ -1224,7 +1393,7 @@ Module._resolveFilename = function(request, parent, isMain, options) { } // Look up the filename first, since that's the cache key. - const filename = Module._findPath(request, paths, isMain); + const filename = Module._findPath(request, paths, isMain, conditions); if (filename) { return filename; } const requireStack = []; for (let cursor = parent; @@ -1291,8 +1460,8 @@ Module.prototype.load = function(filename) { debug('load %j for module %j', filename, this.id); assert(!this.loaded); - this.filename = filename; - this.paths = Module._nodeModulePaths(path.dirname(filename)); + this.filename ??= filename; + this.paths ??= Module._nodeModulePaths(path.dirname(filename)); const extension = findLongestRegisteredExtension(filename); @@ -1572,27 +1741,41 @@ Module.prototype._compile = function(content, filename, format) { }; /** - * Get the source code of a module, using cached ones if it's cached. + * Get the source code of a module, using cached ones if it's cached. This is used + * for TypeScript, JavaScript and JSON loading. * After this returns, mod[kFormat], mod[kModuleSource] and mod[kURL] will be set. * @param {Module} mod Module instance whose source is potentially already cached. * @param {string} filename Absolute path to the file of the module. * @returns {{source: string, format?: string}} */ function loadSource(mod, filename, formatFromNode) { - if (formatFromNode !== undefined) { + if (mod[kFormat] === undefined) { mod[kFormat] = formatFromNode; } - const format = mod[kFormat]; + // If the module was loaded before, just return. + if (mod[kModuleSource] !== undefined) { + return { source: mod[kModuleSource], format: mod[kFormat] }; + } - let source = mod[kModuleSource]; - if (source !== undefined) { - mod[kModuleSource] = undefined; - } else { - // TODO(joyeecheung): we can read a buffer instead to speed up - // compilation. - source = fs.readFileSync(filename, 'utf8'); + // Fast path: no hooks, just load it and return. + if (!loadHooks.length) { + const source = defaultLoadImpl(filename, formatFromNode); + return { source, format: formatFromNode }; + } + + if (mod[kURL] === undefined) { + mod[kURL] = convertCJSFilenameToURL(filename); } - return { source, format }; + + const loadResult = loadWithHooks(mod[kURL], mod[kFormat], /* importAttributes */ undefined, getCjsConditions(), + getDefaultLoad(mod[kURL], filename)); + + // Reset the module properties with load hook results. + if (loadResult.format !== undefined) { + mod[kFormat] = loadResult.format; + } + mod[kModuleSource] = loadResult.source; + return { source: mod[kModuleSource], format: mod[kFormat] }; } /** @@ -1610,7 +1793,6 @@ function loadMTS(mod, filename) { * @param {Module} module CJS module instance * @param {string} filename The file path of the module */ - function loadCTS(module, filename) { const loadResult = loadSource(module, filename, 'commonjs-typescript'); module._compile(loadResult.source, filename, loadResult.format); @@ -1724,7 +1906,7 @@ Module._extensions['.js'] = function(module, filename) { * @param {string} filename The file path of the module */ Module._extensions['.json'] = function(module, filename) { - const content = fs.readFileSync(filename, 'utf8'); + const { source: content } = loadSource(module, filename, 'json'); try { setOwnProperty(module, 'exports', JSONParse(stripBOM(content))); @@ -1878,3 +2060,4 @@ ObjectDefineProperty(Module.prototype, 'constructor', { // Backwards compatibility Module.Module = Module; +Module.registerHooks = registerHooks; diff --git a/lib/internal/modules/customization_hooks.js b/lib/internal/modules/customization_hooks.js new file mode 100644 index 00000000000000..c7a7a6d53dffd8 --- /dev/null +++ b/lib/internal/modules/customization_hooks.js @@ -0,0 +1,366 @@ +'use strict'; + +const { + ArrayPrototypeFindIndex, + ArrayPrototypePush, + ArrayPrototypeSplice, + ObjectFreeze, + StringPrototypeStartsWith, + Symbol, +} = primordials; +const { + isAnyArrayBuffer, + isArrayBufferView, +} = require('internal/util/types'); + +const { BuiltinModule } = require('internal/bootstrap/realm'); +const { + ERR_INVALID_RETURN_PROPERTY_VALUE, +} = require('internal/errors').codes; +const { validateFunction } = require('internal/validators'); +const { isAbsolute } = require('path'); +const { pathToFileURL, fileURLToPath } = require('internal/url'); + +let debug = require('internal/util/debuglog').debuglog('module_hooks', (fn) => { + debug = fn; +}); + +/** @typedef {import('internal/modules/cjs/loader.js').Module} Module */ +/** + * @typedef {(specifier: string, context: ModuleResolveContext, nextResolve: ResolveHook) + * => ModuleResolveResult} ResolveHook + * @typedef {(url: string, context: ModuleLoadContext, nextLoad: LoadHook) + * => ModuleLoadResult} LoadHook + */ + +// Use arrays for better insertion and iteration performance, we don't care +// about deletion performance as much. +const resolveHooks = []; +const loadHooks = []; +const hookId = Symbol('kModuleHooksIdKey'); +let nextHookId = 0; + +class ModuleHooks { + /** + * @param {ResolveHook|undefined} resolve User-provided hook. + * @param {LoadHook|undefined} load User-provided hook. + */ + constructor(resolve, load) { + this[hookId] = Symbol(`module-hook-${nextHookId++}`); + // Always initialize all hooks, if it's unspecified it'll be an owned undefined. + this.resolve = resolve; + this.load = load; + + if (resolve) { + ArrayPrototypePush(resolveHooks, this); + } + if (load) { + ArrayPrototypePush(loadHooks, this); + } + + ObjectFreeze(this); + } + // TODO(joyeecheung): we may want methods that allow disabling/enabling temporarily + // which just sets the item in the array to undefined temporarily. + // TODO(joyeecheung): this can be the [Symbol.dispose] implementation to pair with + // `using` when the explicit resource management proposal is shipped by V8. + /** + * Deregister the hook instance. + */ + deregister() { + const id = this[hookId]; + let index = ArrayPrototypeFindIndex(resolveHooks, (hook) => hook[hookId] === id); + if (index !== -1) { + ArrayPrototypeSplice(resolveHooks, index, 1); + } + index = ArrayPrototypeFindIndex(loadHooks, (hook) => hook[hookId] === id); + if (index !== -1) { + ArrayPrototypeSplice(loadHooks, index, 1); + } + } +}; + +/** + * TODO(joyeecheung): taken an optional description? + * @param {{ resolve?: ResolveHook, load?: LoadHook }} hooks User-provided hooks + * @returns {ModuleHooks} + */ +function registerHooks(hooks) { + const { resolve, load } = hooks; + if (resolve) { + validateFunction(resolve, 'hooks.resolve'); + } + if (load) { + validateFunction(load, 'hooks.load'); + } + return new ModuleHooks(resolve, load); +} + +/** + * @param {string} filename + * @returns {string} + */ +function convertCJSFilenameToURL(filename) { + if (!filename) { return filename; } + const builtinId = BuiltinModule.normalizeRequirableId(filename); + if (builtinId) { + return `node:${builtinId}`; + } + // Handle the case where filename is neither a path, nor a built-in id, + // which is possible via monkey-patching. + if (isAbsolute(filename)) { + return pathToFileURL(filename).href; + } + return filename; +} + +/** + * @param {string} url + * @returns {string} + */ +function convertURLToCJSFilename(url) { + if (!url) { return url; } + const builtinId = BuiltinModule.normalizeRequirableId(url); + if (builtinId) { + return builtinId; + } + if (StringPrototypeStartsWith(url, 'file://')) { + return fileURLToPath(url); + } + return url; +} + +/** + * Convert a list of hooks into a function that can be used to do an operation through + * a chain of hooks. If any of the hook returns without calling the next hook, it + * must return shortCircuit: true to stop the chain from continuing to avoid + * forgetting to invoke the next hook by mistake. + * @param {ModuleHooks[]} hooks A list of hooks whose last argument is `nextHook`. + * @param {'load'|'resolve'} name Name of the hook in ModuleHooks. + * @param {Function} defaultStep The default step in the chain. + * @param {Function} validate A function that validates and sanitize the result returned by the chain. + * @returns {Function} + */ +function buildHooks(hooks, name, defaultStep, validate) { + let lastRunIndex = hooks.length; + function wrapHook(index, userHook, next) { + return function wrappedHook(...args) { + lastRunIndex = index; + const hookResult = userHook(...args, next); + if (lastRunIndex > 0 && lastRunIndex === index && !hookResult.shortCircuit) { + throw new ERR_INVALID_RETURN_PROPERTY_VALUE('true', name, 'shortCircuit', + hookResult.shortCircuit); + } + return validate(...args, hookResult); + }; + } + const chain = [wrapHook(0, defaultStep)]; + for (let i = 0; i < hooks.length; ++i) { + const wrappedHook = wrapHook(i + 1, hooks[i][name], chain[i]); + ArrayPrototypePush(chain, wrappedHook); + } + return chain[chain.length - 1]; +} + +/** + * @typedef {object} ModuleResolveResult + * @property {string} url Resolved URL of the module. + * @property {string|undefined} format Format of the module. + * @property {ImportAttributes|undefined} importAttributes Import attributes for the request. + * @property {boolean|undefined} shortCircuit Whether the next hook has been skipped. + */ + +/** + * Validate the result returned by a chain of resolve hook. + * @param {string} specifier Specifier passed into the hooks. + * @param {ModuleResolveContext} context Context passed into the hooks. + * @param {ModuleResolveResult} result Result produced by resolve hooks. + * @returns {ModuleResolveResult} + */ +function validateResolve(specifier, context, result) { + const { url, format, importAttributes } = result; + if (typeof url !== 'string') { + throw new ERR_INVALID_RETURN_PROPERTY_VALUE( + 'a URL string', + 'resolve', + 'url', + url, + ); + } + + if (format && typeof format !== 'string') { + throw new ERR_INVALID_RETURN_PROPERTY_VALUE( + 'a string', + 'resolve', + 'format', + format, + ); + } + + if (importAttributes && typeof importAttributes !== 'object') { + throw new ERR_INVALID_RETURN_PROPERTY_VALUE( + 'an object', + 'resolve', + 'importAttributes', + importAttributes, + ); + } + + return { + __proto__: null, + url, + format, + importAttributes, + }; +} + +/** + * @typedef {object} ModuleLoadResult + * @property {string|undefined} format Format of the loaded module. + * @property {string|ArrayBuffer|TypedArray} source Source code of the module. + * @property {boolean|undefined} shortCircuit Whether the next hook has been skipped. + */ + +/** + * Validate the result returned by a chain of resolve hook. + * @param {string} url URL passed into the hooks. + * @param {ModuleLoadContext} context Context passed into the hooks. + * @param {ModuleLoadResult} result Result produced by load hooks. + * @returns {ModuleLoadResult} + */ +function validateLoad(url, context, result) { + const { source, format } = result; + // To align with module.register(), the load hooks are still invoked for + // the builtins even though the default load step only provides null as source, + // and any source content for builtins provided by the user hooks are ignored. + if (!StringPrototypeStartsWith(url, 'node:') && + typeof result.source !== 'string' && + !isAnyArrayBuffer(source) && + !isArrayBufferView(source)) { + throw new ERR_INVALID_RETURN_PROPERTY_VALUE( + 'a string, an ArrayBuffer, or a TypedArray', + 'load', + 'source', + source, + ); + } + + if (typeof format !== 'string' && format !== undefined) { + throw new ERR_INVALID_RETURN_PROPERTY_VALUE( + 'a string', + 'load', + 'format', + format, + ); + } + + return { + __proto__: null, + format, + source, + }; +} + +class ModuleResolveContext { + /** + * Context for the resolve hook. + * @param {string|undefined} parentURL Parent URL. + * @param {ImportAttributes|undefined} importAttributes Import attributes. + * @param {string[]} conditions Conditions. + */ + constructor(parentURL, importAttributes, conditions) { + this.parentURL = parentURL; + this.importAttributes = importAttributes; + this.conditions = conditions; + // TODO(joyeecheung): a field to differentiate between require and import? + } +}; + +class ModuleLoadContext { + /** + * Context for the load hook. + * @param {string|undefined} format URL. + * @param {ImportAttributes|undefined} importAttributes Import attributes. + * @param {string[]} conditions Conditions. + */ + constructor(format, importAttributes, conditions) { + this.format = format; + this.importAttributes = importAttributes; + this.conditions = conditions; + } +}; + +let decoder; +/** + * Load module source for a url, through a hooks chain if it exists. + * @param {string} url + * @param {string|undefined} originalFormat + * @param {ImportAttributes|undefined} importAttributes + * @param {string[]} conditions + * @param {(url: string, context: ModuleLoadContext) => ModuleLoadResult} defaultLoad + * @returns {ModuleLoadResult} + */ +function loadWithHooks(url, originalFormat, importAttributes, conditions, defaultLoad) { + debug('loadWithHooks', url, originalFormat); + const context = new ModuleLoadContext(originalFormat, importAttributes, conditions); + if (loadHooks.length === 0) { + return defaultLoad(url, context); + } + + const runner = buildHooks(loadHooks, 'load', defaultLoad, validateLoad); + + const result = runner(url, context); + const { source, format } = result; + if (!isAnyArrayBuffer(source) && !isArrayBufferView(source)) { + return result; + } + + switch (format) { + // Text formats: + case undefined: + case 'module': + case 'commonjs': + case 'json': + case 'module-typescript': + case 'commonjs-typescript': + case 'typescript': { + decoder ??= new (require('internal/encoding').TextDecoder)(); + result.source = decoder.decode(source); + break; + } + default: + break; + } + return result; +} + +/** + * Resolve module request to a url, through a hooks chain if it exists. + * @param {string} specifier + * @param {string|undefined} parentURL + * @param {ImportAttributes|undefined} importAttributes + * @param {string[]} conditions + * @param {(specifier: string, context: ModuleResolveContext) => ModuleResolveResult} defaultResolve + * @returns {ModuleResolveResult} + */ +function resolveWithHooks(specifier, parentURL, importAttributes, conditions, defaultResolve) { + debug('resolveWithHooks', specifier, parentURL, importAttributes); + const context = new ModuleResolveContext(parentURL, importAttributes, conditions); + if (resolveHooks.length === 0) { + return defaultResolve(specifier, context); + } + + const runner = buildHooks(resolveHooks, 'resolve', defaultResolve, validateResolve); + + return runner(specifier, context); +} + +module.exports = { + convertCJSFilenameToURL, + convertURLToCJSFilename, + loadHooks, + loadWithHooks, + registerHooks, + resolveHooks, + resolveWithHooks, +}; diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index c5594e07d667c3..c52f388754d5f1 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -42,6 +42,12 @@ const { ModuleWrap, kEvaluating, kEvaluated } = internalBinding('module_wrap'); const { urlToFilename, } = require('internal/modules/helpers'); +const { + resolveHooks, + resolveWithHooks, + loadHooks, + loadWithHooks, +} = require('internal/modules/customization_hooks'); let defaultResolve, defaultLoad, defaultLoadSync, importMetaInitializer; const { tracingChannel } = require('diagnostics_channel'); @@ -137,7 +143,7 @@ class ModuleLoader { /** * Customizations to pass requests to. - * + * @type {import('./hooks.js').Hooks} * Note that this value _MUST_ be set with `setCustomizations` * because it needs to copy `customizations.allowImportMetaResolve` * to this property and failure to do so will cause undefined @@ -350,7 +356,7 @@ class ModuleLoader { // TODO(joyeecheung): consolidate cache behavior and use resolveSync() and // loadSync() here. - const resolveResult = this.#cachedDefaultResolve(specifier, parentURL, importAttributes); + const resolveResult = this.#cachedResolveSync(specifier, parentURL, importAttributes); const { url, format } = resolveResult; if (!getOptionValue('--experimental-require-module')) { throw new ERR_REQUIRE_ESM(url, true); @@ -375,8 +381,7 @@ class ModuleLoader { return job; } - defaultLoadSync ??= require('internal/modules/esm/load').defaultLoadSync; - const loadResult = defaultLoadSync(url, { format, importAttributes }); + const loadResult = this.#loadSync(url, { format, importAttributes }); // Use the synchronous commonjs translator which can deal with cycles. const finalFormat = loadResult.format === 'commonjs' ? 'commonjs-sync' : loadResult.format; @@ -580,6 +585,10 @@ class ModuleLoader { */ resolve(specifier, parentURL, importAttributes) { specifier = `${specifier}`; + if (resolveHooks.length) { + // Has module.registerHooks() hooks, use the synchronous variant that can handle both hooks. + return this.resolveSync(specifier, parentURL, importAttributes); + } if (this.#customizations) { // Only has module.register hooks. return this.#customizations.resolve(specifier, parentURL, importAttributes); } @@ -606,7 +615,26 @@ class ModuleLoader { } /** - * This is the default resolve step for future synchronous hooks, which incorporates asynchronous hooks + * Either return a cached resolution, or perform the synchronous resolution, and + * cache the result. + * @param {string} specifier See {@link resolve}. + * @param {string} [parentURL] See {@link resolve}. + * @param {ImportAttributes} importAttributes See {@link resolve}. + * @returns {{ format: string, url: string }} + */ + #cachedResolveSync(specifier, parentURL, importAttributes) { + const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes); + const cachedResult = this.#resolveCache.get(requestKey, parentURL); + if (cachedResult != null) { + return cachedResult; + } + const result = this.resolveSync(specifier, parentURL, importAttributes); + this.#resolveCache.set(requestKey, parentURL, result); + return result; + } + + /** + * This is the default resolve step for module.registerHooks(), which incorporates asynchronous hooks * from module.register() which are run in a blocking fashion for it to be synchronous. * @param {string|URL} specifier See {@link resolveSync}. * @param {{ parentURL?: string, importAttributes: ImportAttributes}} context See {@link resolveSync}. @@ -624,7 +652,7 @@ class ModuleLoader { * asynchronous resolve hooks from module.register(), it will block until the results are returned * from the loader thread for this to be synchornous. * This is here to support `import.meta.resolve()`, `require()` in imported CJS, and - * future synchronous hooks. + * `module.registerHooks()` hooks. * * TODO(joyeecheung): consolidate the cache behavior and use this in require(esm). * @param {string|URL} specifier See {@link resolve}. @@ -633,7 +661,13 @@ class ModuleLoader { * @returns {{ format: string, url: string }} */ resolveSync(specifier, parentURL, importAttributes = { __proto__: null }) { - return this.#resolveAndMaybeBlockOnLoaderThread(`${specifier}`, { parentURL, importAttributes }); + specifier = `${specifier}`; + if (resolveHooks.length) { + // Has module.registerHooks() hooks, chain the asynchronous hooks in the default step. + return resolveWithHooks(specifier, parentURL, importAttributes, this.#defaultConditions, + this.#resolveAndMaybeBlockOnLoaderThread.bind(this)); + } + return this.#resolveAndMaybeBlockOnLoaderThread(specifier, { parentURL, importAttributes }); } /** @@ -662,6 +696,10 @@ class ModuleLoader { * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>} */ async load(url, context) { + if (loadHooks.length) { + // Has module.registerHooks() hooks, use the synchronous variant that can handle both hooks. + return this.#loadSync(url, context); + } if (this.#customizations) { return this.#customizations.load(url, context); } @@ -671,7 +709,7 @@ class ModuleLoader { } /** - * This is the default load step for future synchronous hooks, which incorporates asynchronous hooks + * This is the default load step for module.registerHooks(), which incorporates asynchronous hooks * from module.register() which are run in a blocking fashion for it to be synchronous. * @param {string} url See {@link load} * @param {object} context See {@link load} @@ -689,7 +727,7 @@ class ModuleLoader { * Similar to {@link load} but this is always run synchronously. If there are asynchronous hooks * from module.register(), this blocks on the loader thread for it to return synchronously. * - * This is here to support `require()` in imported CJS and future synchronous hooks. + * This is here to support `require()` in imported CJS and `module.registerHooks()` hooks. * * TODO(joyeecheung): consolidate the cache behavior and use this in require(esm). * @param {string} url See {@link load} @@ -697,6 +735,13 @@ class ModuleLoader { * @returns {{ format: ModuleFormat, source: ModuleSource }} */ #loadSync(url, context) { + if (loadHooks.length) { + // Has module.registerHooks() hooks, chain the asynchronous hooks in the default step. + // TODO(joyeecheung): construct the ModuleLoadContext in the loaders directly instead + // of converting them from plain objects in the hooks. + return loadWithHooks(url, context.format, context.importAttributes, this.#defaultConditions, + this.#loadAndMaybeBlockOnLoaderThread.bind(this)); + } return this.#loadAndMaybeBlockOnLoaderThread(url, context); } diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 8fba05e7b8f699..8039e2f57a500f 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -131,7 +131,8 @@ class ModuleJob extends ModuleJobBase { // Iterate with index to avoid calling into userspace with `Symbol.iterator`. for (let idx = 0; idx < moduleRequests.length; idx++) { const { specifier, attributes } = moduleRequests[idx]; - + // TODO(joyeecheung): resolve all requests first, then load them in another + // loop so that hooks can pre-fetch sources off-thread. const dependencyJobPromise = this.#loader.getModuleJobForImport( specifier, this.url, attributes, ); diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 1e4b623af77877..c3122118cab75d 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -97,15 +97,14 @@ function getCjsConditions() { /** * Provide one of Node.js' public modules to user code. * @param {string} id - The identifier/specifier of the builtin module to load - * @param {string} request - The module requiring or importing the builtin module */ -function loadBuiltinModule(id, request) { +function loadBuiltinModule(id) { if (!BuiltinModule.canBeRequiredByUsers(id)) { return; } /** @type {import('internal/bootstrap/realm.js').BuiltinModule} */ const mod = BuiltinModule.map.get(id); - debug('load built-in module %s', request); + debug('load built-in module %s', id); // compileForPublicLoader() throws if canBeRequiredByUsers is false: mod.compileForPublicLoader(); return mod; diff --git a/test/fixtures/module-hooks/add-hook.js b/test/fixtures/module-hooks/add-hook.js new file mode 100644 index 00000000000000..807a73953c3d6b --- /dev/null +++ b/test/fixtures/module-hooks/add-hook.js @@ -0,0 +1,30 @@ +'use strict'; +const { fileURLToPath } = require('url'); +const { registerHooks } = require('module'); + +// This is a simplified version of the pirates package API to +// check that a similar API can be built on top of the public +// hooks. +function addHook(hook, options) { + function load(url, context, nextLoad) { + const result = nextLoad(url, context); + const index = url.lastIndexOf('.'); + const ext = url.slice(index); + if (!options.exts.includes(ext)) { + return result; + } + const filename = fileURLToPath(url); + if (!options.matcher(filename)) { + return result; + } + return { ...result, source: hook(result.source.toString(), filename) } + } + + const registered = registerHooks({ load }); + + return function revert() { + registered.deregister(); + }; +} + +module.exports = { addHook }; diff --git a/test/fixtures/module-hooks/get-stats.js b/test/fixtures/module-hooks/get-stats.js new file mode 100644 index 00000000000000..fa5869a455cea1 --- /dev/null +++ b/test/fixtures/module-hooks/get-stats.js @@ -0,0 +1,20 @@ +'use strict'; + +const path = require('path'); + +// Adapted from https://github.com/watson/module-details-from-path/blob/master/index.js +// used by require-in-the-middle to check the logic is still compatible with our new hooks. +exports.getStats = function getStats(filepath) { + const segments = filepath.split(path.sep); + const index = segments.lastIndexOf('node_modules'); + if (index === -1) return {}; + if (!segments[index + 1]) return {}; + const scoped = segments[index + 1][0] === '@'; + const name = scoped ? segments[index + 1] + '/' + segments[index + 2] : segments[index + 1]; + const offset = scoped ? 3 : 2; + return { + name: name, + basedir: segments.slice(0, index + offset).join(path.sep), + path: segments.slice(index + offset).join(path.sep) + } +}; diff --git a/test/fixtures/module-hooks/load-from-this-dir.js b/test/fixtures/module-hooks/load-from-this-dir.js new file mode 100644 index 00000000000000..e1c51d2f43db32 --- /dev/null +++ b/test/fixtures/module-hooks/load-from-this-dir.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.require = require; +exports.import = (id) => import(id); diff --git a/test/fixtures/module-hooks/log-user.cts b/test/fixtures/module-hooks/log-user.cts new file mode 100644 index 00000000000000..2b2754f48c4c74 --- /dev/null +++ b/test/fixtures/module-hooks/log-user.cts @@ -0,0 +1,3 @@ +const { UserAccount, UserType } = require('./user.ts'); +const account: typeof UserAccount = new UserAccount('john', 100, UserType.Admin); +console.log(account); diff --git a/test/fixtures/module-hooks/log-user.mts b/test/fixtures/module-hooks/log-user.mts new file mode 100644 index 00000000000000..9e2c3bfe1a3bb8 --- /dev/null +++ b/test/fixtures/module-hooks/log-user.mts @@ -0,0 +1,4 @@ +import { UserAccount, UserType } from './user.ts'; +import { log } from 'node:console'; +const account: UserAccount = new UserAccount('john', 100, UserType.Admin); +log(account); diff --git a/test/fixtures/module-hooks/node_modules/bar-esm/bar-esm.js b/test/fixtures/module-hooks/node_modules/bar-esm/bar-esm.js new file mode 100644 index 00000000000000..2130577ddf4b51 --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/bar-esm/bar-esm.js @@ -0,0 +1 @@ +export const $key = 'bar-esm'; diff --git a/test/fixtures/module-hooks/node_modules/bar-esm/package.json b/test/fixtures/module-hooks/node_modules/bar-esm/package.json new file mode 100644 index 00000000000000..3c3282814fb87c --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/bar-esm/package.json @@ -0,0 +1,6 @@ +{ + "name": "bar-esm", + "main": "bar-esm.js", + "type": "module", + "version": "1.0.0" +} diff --git a/test/fixtures/module-hooks/node_modules/bar/bar.js b/test/fixtures/module-hooks/node_modules/bar/bar.js new file mode 100644 index 00000000000000..4d1a1e6dc010fd --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/bar/bar.js @@ -0,0 +1,3 @@ +module.exports = { + $key: 'bar' +}; diff --git a/test/fixtures/module-hooks/node_modules/bar/package.json b/test/fixtures/module-hooks/node_modules/bar/package.json new file mode 100644 index 00000000000000..0a2e2f7d1dad6b --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/bar/package.json @@ -0,0 +1,6 @@ +{ + "name": "bar", + "main": "bar.js", + "version": "1.0.0" +} + diff --git a/test/fixtures/module-hooks/node_modules/foo-esm/foo-esm.js b/test/fixtures/module-hooks/node_modules/foo-esm/foo-esm.js new file mode 100644 index 00000000000000..caf20f7cf2b78e --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/foo-esm/foo-esm.js @@ -0,0 +1 @@ +export const $key = 'foo-esm'; \ No newline at end of file diff --git a/test/fixtures/module-hooks/node_modules/foo-esm/package.json b/test/fixtures/module-hooks/node_modules/foo-esm/package.json new file mode 100644 index 00000000000000..2a98229ba262a3 --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/foo-esm/package.json @@ -0,0 +1,7 @@ +{ + "name": "foo-esm", + "type": "module", + "main": "foo-esm.js", + "version": "1.0.0" +} + diff --git a/test/fixtures/module-hooks/node_modules/foo/foo.js b/test/fixtures/module-hooks/node_modules/foo/foo.js new file mode 100644 index 00000000000000..91592faf7ce0a6 --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/foo/foo.js @@ -0,0 +1,3 @@ +module.exports = { + $key: 'foo' +}; diff --git a/test/fixtures/module-hooks/node_modules/foo/package.json b/test/fixtures/module-hooks/node_modules/foo/package.json new file mode 100644 index 00000000000000..53416530e84f2f --- /dev/null +++ b/test/fixtures/module-hooks/node_modules/foo/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "main": "foo.js", + "version": "1.0.0" +} + diff --git a/test/fixtures/module-hooks/redirected-assert.js b/test/fixtures/module-hooks/redirected-assert.js new file mode 100644 index 00000000000000..9855afd7ee3a3c --- /dev/null +++ b/test/fixtures/module-hooks/redirected-assert.js @@ -0,0 +1 @@ +exports.exports_for_test = 'redirected assert' diff --git a/test/fixtures/module-hooks/redirected-fs.js b/test/fixtures/module-hooks/redirected-fs.js new file mode 100644 index 00000000000000..84631b34c3539a --- /dev/null +++ b/test/fixtures/module-hooks/redirected-fs.js @@ -0,0 +1 @@ +export const exports_for_test = 'redirected fs'; diff --git a/test/fixtures/module-hooks/redirected-zlib.js b/test/fixtures/module-hooks/redirected-zlib.js new file mode 100644 index 00000000000000..9c2fcd5ac75b40 --- /dev/null +++ b/test/fixtures/module-hooks/redirected-zlib.js @@ -0,0 +1 @@ +exports.exports_for_test = 'redirected zlib'; diff --git a/test/fixtures/module-hooks/register-typescript-hooks.js b/test/fixtures/module-hooks/register-typescript-hooks.js new file mode 100644 index 00000000000000..2f9177124ab304 --- /dev/null +++ b/test/fixtures/module-hooks/register-typescript-hooks.js @@ -0,0 +1,4 @@ +'use strict'; + +const { registerHooks } = require('node:module'); +registerHooks(require('./typescript-transpiler')); diff --git a/test/fixtures/module-hooks/typescript-transpiler.js b/test/fixtures/module-hooks/typescript-transpiler.js new file mode 100644 index 00000000000000..b8cb638332ce85 --- /dev/null +++ b/test/fixtures/module-hooks/typescript-transpiler.js @@ -0,0 +1,71 @@ +'use strict'; + +const ts = require('../snapshot/typescript'); +const extensions = { + '.cts': 'commonjs-typescript', + '.mts': 'module-typescript', + '.ts': 'typescript', +}; + +const output = { + 'commonjs-typescript': { + options: { module: ts.ModuleKind.CommonJS }, + format: 'commonjs', + }, + 'module-typescript': { + options: { module: ts.ModuleKind.ESNext }, + format: 'module', + }, + 'typescript': { + options: { module: ts.ModuleKind.NodeNext }, + format: 'commonjs', + }, +}; + +function resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context); + const index = resolved.url.lastIndexOf('.'); + if (index === -1) { + return resolved; + } + const ext = resolved.url.slice(index); + const supportedFormat = extensions[ext]; + if (!supportedFormat) { + return resolved; + } + const result = { + ...resolved, + format: supportedFormat, + }; + return result; +} + +let decoder; +function load(url, context, nextLoad) { + const loadResult = nextLoad(url, context); + const { source, format } = loadResult; + + if (!format || !format.includes('typescript')) { + return { format, source }; + } + + let str = source; + if (typeof str !== 'string') { + decoder ??= new TextDecoder(); + str = decoder.decode(source); + } + const transpiled = ts.transpileModule(str, { + compilerOptions: output[format].options + }); + + const result = { + ...loadResult, + format: output[format].format, + source: transpiled.outputText, + }; + + return result; +} + +exports.load = load; +exports.resolve = resolve; diff --git a/test/fixtures/module-hooks/user.ts b/test/fixtures/module-hooks/user.ts new file mode 100644 index 00000000000000..f4e064b2739345 --- /dev/null +++ b/test/fixtures/module-hooks/user.ts @@ -0,0 +1,18 @@ +enum UserType { + Staff, + Admin, +}; + +class UserAccount { + name: string; + id: number; + type: UserType; + + constructor(name: string, id: number, type: UserType) { + this.name = name; + this.id = id; + this.type = type; + } +} + +export { UserAccount, UserType }; diff --git a/test/module-hooks/module-hooks.status b/test/module-hooks/module-hooks.status new file mode 100644 index 00000000000000..cb697c3ae80155 --- /dev/null +++ b/test/module-hooks/module-hooks.status @@ -0,0 +1,7 @@ +prefix module-hooks + +# To mark a test as flaky, list the test name in the appropriate section +# below, without ".js", followed by ": PASS,FLAKY". Example: +# sample-test : PASS,FLAKY + +[true] # This section applies to all platforms diff --git a/test/module-hooks/test-module-hooks-import-wasm.mjs b/test/module-hooks/test-module-hooks-import-wasm.mjs new file mode 100644 index 00000000000000..f2c357cd50390c --- /dev/null +++ b/test/module-hooks/test-module-hooks-import-wasm.mjs @@ -0,0 +1,35 @@ +// Flags: --no-experimental-wasm-modules +// This tests that module.registerHooks() can be used to support unknown formats, like +// import(wasm) (without --experimental-wasm-modules). +import '../common/index.mjs'; + +import assert from 'node:assert'; +import { registerHooks, createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; + +registerHooks({ + load(url, context, nextLoad) { + assert.match(url, /simple\.wasm$/); + const source = + `const buf = Buffer.from([${Array.from(readFileSync(new URL(url))).join(',')}]); + const compiled = new WebAssembly.Module(buf); + const { exports } = new WebAssembly.Instance(compiled); + export default exports; + export { exports as 'module.exports' }; + `; + return { + shortCircuit: true, + source, + format: 'module', + }; + }, +}); + +// Checks that it works with require. +const require = createRequire(import.meta.url); +const { add } = require('../fixtures/simple.wasm'); +assert.strictEqual(add(1, 2), 3); + +// Checks that it works with import. +const { default: { add: add2 } } = await import('../fixtures/simple.wasm'); +assert.strictEqual(add2(1, 2), 3); diff --git a/test/module-hooks/test-module-hooks-load-buffers.js b/test/module-hooks/test-module-hooks-load-buffers.js new file mode 100644 index 00000000000000..07f7374fd96161 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-buffers.js @@ -0,0 +1,50 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// This tests that the source in the load hook can be returned as +// array buffers or array buffer views. +const arrayBufferSource = 'module.exports = "arrayBuffer"'; +const arrayBufferViewSource = 'module.exports = "arrayBufferView"'; + +const encoder = new TextEncoder(); + +const hook1 = registerHooks({ + resolve(specifier, context, nextResolve) { + return { shortCircuit: true, url: `test://${specifier}` }; + }, + load(url, context, nextLoad) { + const result = nextLoad(url, context); + if (url === 'test://array_buffer') { + assert.deepStrictEqual(result.source, encoder.encode(arrayBufferSource).buffer); + } else if (url === 'test://array_buffer_view') { + assert.deepStrictEqual(result.source, encoder.encode(arrayBufferViewSource)); + } + return result; + }, +}); + +const hook2 = registerHooks({ + load(url, context, nextLoad) { + if (url === 'test://array_buffer') { + return { + shortCircuit: true, + source: encoder.encode(arrayBufferSource).buffer, + }; + } else if (url === 'test://array_buffer_view') { + return { + shortCircuit: true, + source: encoder.encode(arrayBufferViewSource), + }; + } + assert.fail('unreachable'); + }, +}); + +assert.strictEqual(require('array_buffer'), 'arrayBuffer'); +assert.strictEqual(require('array_buffer_view'), 'arrayBufferView'); + +hook1.deregister(); +hook2.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-builtin-import.mjs b/test/module-hooks/test-module-hooks-load-builtin-import.mjs new file mode 100644 index 00000000000000..f78c69692fe04b --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-builtin-import.mjs @@ -0,0 +1,29 @@ +import { mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import { registerHooks } from 'node:module'; +import process from 'node:process'; + +// This tests that imported builtins get null as source from default +// step, and the source returned are ignored. +// TODO(joyeecheung): this is to align with the module.register() behavior +// but perhaps the load hooks should not be invoked for builtins at all. + +// Pick a builtin that's unlikely to be loaded already - like zlib. +assert(!process.moduleLoadList.includes('NativeModule zlib')); + +const hook = registerHooks({ + load: mustCall(function load(url, context, nextLoad) { + assert.strictEqual(url, 'node:zlib'); + const result = nextLoad(url, context); + assert.strictEqual(result.source, null); + return { + source: 'throw new Error("I should not be thrown")', + format: 'builtin', + }; + }), +}); + +const ns = await import('node:zlib'); +assert.strictEqual(typeof ns.createGzip, 'function'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-builtin-require.js b/test/module-hooks/test-module-hooks-load-builtin-require.js new file mode 100644 index 00000000000000..78f732d2dd9207 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-builtin-require.js @@ -0,0 +1,29 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// This tests that required builtins get null as source from default +// step, and the source returned are ignored. +// TODO(joyeecheung): this is to align with the module.register() behavior +// but perhaps the load hooks should not be invoked for builtins at all. + +// Pick a builtin that's unlikely to be loaded already - like zlib. +assert(!process.moduleLoadList.includes('NativeModule zlib')); + +const hook = registerHooks({ + load: common.mustCall(function load(url, context, nextLoad) { + assert.strictEqual(url, 'node:zlib'); + const result = nextLoad(url, context); + assert.strictEqual(result.source, null); + return { + source: 'throw new Error("I should not be thrown")', + format: 'builtin', + }; + }), +}); + +assert.strictEqual(typeof require('zlib').createGzip, 'function'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-chained.js b/test/module-hooks/test-module-hooks-load-chained.js new file mode 100644 index 00000000000000..5227658262a752 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-chained.js @@ -0,0 +1,34 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that multiple loaders works together. +const hook1 = registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + assert.strictEqual(result.source, ''); + return { + source: 'exports.hello = "world"', + format: 'commonjs', + }; + }, +}); + +const hook2 = registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + assert.strictEqual(result.source, 'exports.hello = "world"'); + return { + source: 'export const hello = "world"', + format: 'module', + }; + }, +}); + +const mod = require('../fixtures/empty.js'); +assert.strictEqual(mod.hello, 'world'); + +hook1.deregister(); +hook2.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-detection.js b/test/module-hooks/test-module-hooks-load-detection.js new file mode 100644 index 00000000000000..9915b98440355b --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-detection.js @@ -0,0 +1,21 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that module syntax detection works. +const hook = registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + assert.strictEqual(result.source, ''); + return { + source: 'export const hello = "world"', + }; + }, +}); + +const mod = require('../fixtures/empty.js'); +assert.strictEqual(mod.hello, 'world'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-esm-mock.js b/test/module-hooks/test-module-hooks-load-esm-mock.js new file mode 100644 index 00000000000000..88941b5d685f07 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-esm-mock.js @@ -0,0 +1,51 @@ +'use strict'; + +// This tests a pirates-like load hook works. + +const common = require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const { readFileSync } = require('fs'); + +const loader = require('../fixtures/module-hooks/load-from-this-dir'); +const { addHook } = require('../fixtures/module-hooks/add-hook'); + +const matcherArgs = []; +function matcher(filename) { + matcherArgs.push(filename); + return true; +} + +const hookArgs = []; +function hook(code, filename) { + hookArgs.push({ code, filename }); + return code.replace('$key', 'hello'); +} + +(async () => { + const revert = addHook(hook, { exts: ['.js'], matcher }); + + { + const foo = await loader.import('foo-esm'); + const filename = fixtures.path('module-hooks', 'node_modules', 'foo-esm', 'foo-esm.js'); + assert.deepStrictEqual(matcherArgs, [filename]); + const code = readFileSync(filename, 'utf-8'); + assert.deepStrictEqual(hookArgs, [{ code, filename }]); + assert.deepStrictEqual({ ...foo }, { hello: 'foo-esm' }); + } + + matcherArgs.splice(0, 1); + hookArgs.splice(0, 1); + + revert(); + + // Later loads are unaffected. + + { + const bar = await loader.import('bar-esm'); + assert.deepStrictEqual(matcherArgs, []); + assert.deepStrictEqual(hookArgs, []); + assert.deepStrictEqual({ ...bar }, { $key: 'bar-esm' }); + } + +})().catch(common.mustNotCall()); diff --git a/test/module-hooks/test-module-hooks-load-esm.js b/test/module-hooks/test-module-hooks-load-esm.js new file mode 100644 index 00000000000000..88941b5d685f07 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-esm.js @@ -0,0 +1,51 @@ +'use strict'; + +// This tests a pirates-like load hook works. + +const common = require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const { readFileSync } = require('fs'); + +const loader = require('../fixtures/module-hooks/load-from-this-dir'); +const { addHook } = require('../fixtures/module-hooks/add-hook'); + +const matcherArgs = []; +function matcher(filename) { + matcherArgs.push(filename); + return true; +} + +const hookArgs = []; +function hook(code, filename) { + hookArgs.push({ code, filename }); + return code.replace('$key', 'hello'); +} + +(async () => { + const revert = addHook(hook, { exts: ['.js'], matcher }); + + { + const foo = await loader.import('foo-esm'); + const filename = fixtures.path('module-hooks', 'node_modules', 'foo-esm', 'foo-esm.js'); + assert.deepStrictEqual(matcherArgs, [filename]); + const code = readFileSync(filename, 'utf-8'); + assert.deepStrictEqual(hookArgs, [{ code, filename }]); + assert.deepStrictEqual({ ...foo }, { hello: 'foo-esm' }); + } + + matcherArgs.splice(0, 1); + hookArgs.splice(0, 1); + + revert(); + + // Later loads are unaffected. + + { + const bar = await loader.import('bar-esm'); + assert.deepStrictEqual(matcherArgs, []); + assert.deepStrictEqual(hookArgs, []); + assert.deepStrictEqual({ ...bar }, { $key: 'bar-esm' }); + } + +})().catch(common.mustNotCall()); diff --git a/test/module-hooks/test-module-hooks-load-invalid.js b/test/module-hooks/test-module-hooks-load-invalid.js new file mode 100644 index 00000000000000..7836a864ca57b9 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-invalid.js @@ -0,0 +1,39 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// This tests that the invalid return values in load hooks are not accepted. + +const hook = registerHooks({ + resolve(specifier, context, nextResolve) { + return { shortCircuit: true, url: `test://${specifier}` }; + }, + load(url, context, nextLoad) { + const result = { shortCircuit: true }; + if (url.endsWith('array')) { + result.source = []; + } else if (url.endsWith('null')) { + result.source = null; + } else if (url.endsWith('number')) { + result.source = 1; + } else if (url.endsWith('boolean')) { + result.source = true; + } else if (url.endsWith('function')) { + result.source = () => {}; + } else if (url.endsWith('object')) { + result.source = {}; + } + return result; + }, +}); + +for (const item of ['undefined', 'array', 'null', 'number', 'boolean', 'function', 'object']) { + assert.throws(() => { require(item); }, { + code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', + message: /"source" from the "load" hook/, + }); +} + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-mock.js b/test/module-hooks/test-module-hooks-load-mock.js new file mode 100644 index 00000000000000..bf00182bc32bb4 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-mock.js @@ -0,0 +1,48 @@ +'use strict'; + +// This tests a pirates-like load hook works. + +require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const { readFileSync } = require('fs'); + +const loader = require('../fixtures/module-hooks/load-from-this-dir'); +const { addHook } = require('../fixtures/module-hooks/add-hook'); + +const matcherArgs = []; +function matcher(filename) { + matcherArgs.push(filename); + return true; +} + +const hookArgs = []; +function hook(code, filename) { + hookArgs.push({ code, filename }); + return code.replace('$key', 'hello'); +} + +const revert = addHook(hook, { exts: ['.js'], matcher }); + +{ + const foo = loader.require('foo'); + const filename = fixtures.path('module-hooks', 'node_modules', 'foo', 'foo.js'); + assert.deepStrictEqual(matcherArgs, [filename]); + const code = readFileSync(filename, 'utf-8'); + assert.deepStrictEqual(hookArgs, [{ code, filename }]); + assert.deepStrictEqual(foo, { hello: 'foo' }); +} + +matcherArgs.splice(0, 1); +hookArgs.splice(0, 1); + +revert(); + +// Later loads are unaffected. + +{ + const bar = loader.require('bar'); + assert.deepStrictEqual(matcherArgs, []); + assert.deepStrictEqual(hookArgs, []); + assert.deepStrictEqual(bar, { $key: 'bar' }); +} diff --git a/test/module-hooks/test-module-hooks-load-short-circuit-required-middle.js b/test/module-hooks/test-module-hooks-load-short-circuit-required-middle.js new file mode 100644 index 00000000000000..a3d7d9c28cc50d --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-short-circuit-required-middle.js @@ -0,0 +1,33 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that shortCircuit is required in a middle hook when nextLoad is not called. +const hook1 = registerHooks({ + load(url, context, nextLoad) { + return nextLoad(url, context); + }, +}); +const hook2 = registerHooks({ + load(url, context, nextLoad) { + if (url.includes('empty')) { + return { + format: 'commonjs', + source: 'module.exports = "modified"', + }; + } + return nextLoad(url, context); + }, +}); + +assert.throws(() => { + require('../fixtures/empty.js'); +}, { + code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', + message: /shortCircuit/, +}); + +hook1.deregister(); +hook2.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-short-circuit-required-start.js b/test/module-hooks/test-module-hooks-load-short-circuit-required-start.js new file mode 100644 index 00000000000000..7de85018427bc3 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-short-circuit-required-start.js @@ -0,0 +1,29 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that shortCircuit is required in the starting hook when nextLoad is not called. +const hook = registerHooks({ + load(url, context, nextLoad) { + if (url.includes('empty')) { + return { + format: 'commonjs', + source: 'module.exports = "modified"', + }; + } + return nextLoad(url, context); + }, +}); + +assert.throws(() => { + require('../fixtures/empty.js'); +}, { + code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', + message: /shortCircuit/, +}); + +const baz = require('../fixtures/baz.js'); +assert.strictEqual(baz, 'perhaps I work'); +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-load-short-circuit.js b/test/module-hooks/test-module-hooks-load-short-circuit.js new file mode 100644 index 00000000000000..d4f3d2f2341cb7 --- /dev/null +++ b/test/module-hooks/test-module-hooks-load-short-circuit.js @@ -0,0 +1,28 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that shortCircuit is required in a middle hook when nextResolve is not called. +const hook1 = registerHooks({ + load: common.mustNotCall(), +}); +const hook2 = registerHooks({ + load(url, context, nextLoad) { + if (url.includes('empty')) { + return { + format: 'commonjs', + source: 'module.exports = "modified"', + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); + +const value = require('../fixtures/empty.js'); +assert.strictEqual(value, 'modified'); + +hook1.deregister(); +hook2.deregister(); diff --git a/test/module-hooks/test-module-hooks-preload.js b/test/module-hooks/test-module-hooks-preload.js new file mode 100644 index 00000000000000..a88cd672a59a78 --- /dev/null +++ b/test/module-hooks/test-module-hooks-preload.js @@ -0,0 +1,49 @@ +'use strict'; + +require('../common'); +const fixtures = require('../common/fixtures.js'); +const { spawnSyncAndAssert } = require('../common/child_process.js'); + +spawnSyncAndAssert(process.execPath, + [ + '--require', + fixtures.path('module-hooks', 'register-typescript-hooks.js'), + fixtures.path('module-hooks', 'log-user.cts'), + ], { + trim: true, + stdout: 'UserAccount { name: \'john\', id: 100, type: 1 }', + }); + +spawnSyncAndAssert(process.execPath, + [ + '--experimental-strip-types', + '--no-experimental-transform-types', + '--require', + fixtures.path('module-hooks', 'register-typescript-hooks.js'), + fixtures.path('module-hooks', 'log-user.cts'), + ], { + trim: true, + stdout: 'UserAccount { name: \'john\', id: 100, type: 1 }', + }); + +spawnSyncAndAssert(process.execPath, + [ + '--import', + fixtures.fileURL('module-hooks', 'register-typescript-hooks.js'), + fixtures.path('module-hooks', 'log-user.mts'), + ], { + trim: true, + stdout: 'UserAccount { name: \'john\', id: 100, type: 1 }', + }); + +spawnSyncAndAssert(process.execPath, + [ + '--experimental-strip-types', + '--no-experimental-transform-types', + '--import', + fixtures.fileURL('module-hooks', 'register-typescript-hooks.js'), + fixtures.path('module-hooks', 'log-user.mts'), + ], { + trim: true, + stdout: 'UserAccount { name: \'john\', id: 100, type: 1 }', + }); diff --git a/test/module-hooks/test-module-hooks-require-wasm.js b/test/module-hooks/test-module-hooks-require-wasm.js new file mode 100644 index 00000000000000..b4276bcc749a01 --- /dev/null +++ b/test/module-hooks/test-module-hooks-require-wasm.js @@ -0,0 +1,34 @@ +// Flags: --no-experimental-wasm-modules +'use strict'; + +// This tests that module.registerHooks() can be used to support unknown formats, like +// require(wasm) and import(wasm) (without --experimental-wasm-modules). +const common = require('../common'); + +const assert = require('assert'); +const { registerHooks } = require('module'); +const { readFileSync } = require('fs'); + +registerHooks({ + load(url, context, nextLoad) { + assert.match(url, /simple\.wasm$/); + const source = + `const buf = Buffer.from([${Array.from(readFileSync(new URL(url))).join(',')}]); + const compiled = new WebAssembly.Module(buf); + module.exports = (new WebAssembly.Instance(compiled)).exports;`; + return { + shortCircuit: true, + source, + format: 'commonjs', + }; + }, +}); + +// Checks that it works with require. +const { add } = require('../fixtures/simple.wasm'); +assert.strictEqual(add(1, 2), 3); + +(async () => { // Checks that it works with import. + const { default: { add } } = await import('../fixtures/simple.wasm'); + assert.strictEqual(add(1, 2), 3); +})().then(common.mustCall()); diff --git a/test/module-hooks/test-module-hooks-resolve-builtin-builtin-import.mjs b/test/module-hooks/test-module-hooks-resolve-builtin-builtin-import.mjs new file mode 100644 index 00000000000000..b7c31678137e5e --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-builtin-builtin-import.mjs @@ -0,0 +1,27 @@ +import '../common/index.mjs'; +import assert from 'node:assert'; +import { registerHooks } from 'node:module'; +import process from 'node:process'; + +// This tests that builtins can be redirected to another builtin. +// Pick a builtin that's unlikely to be loaded already - like zlib. +assert(!process.moduleLoadList.includes('NativeModule zlib')); + +const hook = registerHooks({ + resolve(specifier, context, nextLoad) { + if (specifier === 'node:assert') { + return { + url: 'node:zlib', + shortCircuit: true, + }; + } + }, +}); + + +// Check assert, which is already loaded. +// zlib.createGzip is a function. +const redirected = await import('node:assert'); +assert.strictEqual(typeof redirected.createGzip, 'function'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-builtin-builtin-require.js b/test/module-hooks/test-module-hooks-resolve-builtin-builtin-require.js new file mode 100644 index 00000000000000..6de7b0d23d6675 --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-builtin-builtin-require.js @@ -0,0 +1,26 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// This tests that builtins can be redirected to another builtin. +// Pick a builtin that's unlikely to be loaded already - like zlib. +assert(!process.moduleLoadList.includes('NativeModule zlib')); + +const hook = registerHooks({ + resolve(specifier, context, nextLoad) { + if (specifier === 'assert') { + return { + url: 'node:zlib', + shortCircuit: true, + }; + } + }, +}); + +// Check assert, which is already loaded. +// zlib.createGzip is a function. +assert.strictEqual(typeof require('assert').createGzip, 'function'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-builtin-on-disk-import.mjs b/test/module-hooks/test-module-hooks-resolve-builtin-on-disk-import.mjs new file mode 100644 index 00000000000000..0afd294298c814 --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-builtin-on-disk-import.mjs @@ -0,0 +1,36 @@ +import '../common/index.mjs'; +import { fileURL } from '../common/fixtures.mjs'; +import assert from 'node:assert'; +import { registerHooks } from 'node:module'; +import process from 'node:process'; + +// This tests that builtins can be redirected to a local file. +// Pick a builtin that's unlikely to be loaded already - like zlib. +assert(!process.moduleLoadList.includes('NativeModule zlib')); + +const hook = registerHooks({ + resolve(specifier, context, nextLoad) { + // FIXME(joyeecheung): when it gets redirected to a CommonJS module, the + // ESM loader invokes the CJS loader with the resolved URL again even when + // it already has the url and source code. Fix it so that the hooks are + // skipped during the second loading. + if (!specifier.startsWith('node:')) { + return nextLoad(specifier, context); + } + return { + url: fileURL( + 'module-hooks', + `redirected-${specifier.replace('node:', '')}.js`).href, + shortCircuit: true, + }; + }, +}); + +// Check assert, which is already loaded. +assert.strictEqual((await import('node:assert')).exports_for_test, 'redirected assert'); +// Check zlib, which is not yet loaded. +assert.strictEqual((await import('node:zlib')).exports_for_test, 'redirected zlib'); +// Check fs, which is redirected to an ESM +assert.strictEqual((await import('node:fs')).exports_for_test, 'redirected fs'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-builtin-on-disk-require.js b/test/module-hooks/test-module-hooks-resolve-builtin-on-disk-require.js new file mode 100644 index 00000000000000..0006975867ce9c --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-builtin-on-disk-require.js @@ -0,0 +1,29 @@ +'use strict'; + +require('../common'); + +const assert = require('assert'); +const { registerHooks } = require('module'); +const fixtures = require('../common/fixtures'); + +// This tests that builtins can be redirected to a local file. +// Pick a builtin that's unlikely to be loaded already - like zlib. +assert(!process.moduleLoadList.includes('NativeModule zlib')); + +const hook = registerHooks({ + resolve(specifier, context, nextLoad) { + return { + url: fixtures.fileURL('module-hooks', `redirected-${specifier}.js`).href, + shortCircuit: true, + }; + }, +}); + +// Check assert, which is already loaded. +assert.strictEqual(require('assert').exports_for_test, 'redirected assert'); +// Check zlib, which is not yet loaded. +assert.strictEqual(require('zlib').exports_for_test, 'redirected zlib'); +// Check fs, which is redirected to an ESM +assert.strictEqual(require('fs').exports_for_test, 'redirected fs'); + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-invalid.js b/test/module-hooks/test-module-hooks-resolve-invalid.js new file mode 100644 index 00000000000000..48f121dfe70b31 --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-invalid.js @@ -0,0 +1,36 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// This tests that the invalid return values in resolve hooks are not accepted. + +const hook = registerHooks({ + resolve(specifier, context, nextLoad) { + const result = { shortCircuit: true }; + if (specifier === 'array') { + result.url = []; + } else if (specifier === 'null') { + result.url = null; + } else if (specifier === 'number') { + result.url = 1; + } else if (specifier === 'boolean') { + result.url = true; + } else if (specifier === 'function') { + result.url = () => {}; + } else if (specifier === 'object') { + result.url = {}; + } + return result; + }, +}); + +for (const item of ['undefined', 'array', 'null', 'number', 'boolean', 'function', 'object']) { + assert.throws(() => { require(item); }, { + code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', + message: /"url" from the "resolve" hook/, + }); +} + +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript-override.mjs b/test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript-override.mjs new file mode 100644 index 00000000000000..18e8d20ef2d93b --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript-override.mjs @@ -0,0 +1,11 @@ +// Flags: --experimental-strip-types --no-experimental-transform-types +// This tests that a mini TypeScript loader works with resolve and +// load hooks when overriding --experimental-strip-types in ESM. +import '../common/index.mjs'; +import assert from 'node:assert'; + +await import('../fixtures/module-hooks/register-typescript-hooks.js'); +// Test inline import(), if override fails, this should fail too because enum is +// not supported when --experimental-transform-types is disabled. +const { UserAccount, UserType } = await import('../fixtures/module-hooks/user.ts'); +assert.strictEqual((new UserAccount('foo', 1, UserType.Admin).name), 'foo'); diff --git a/test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript.mjs b/test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript.mjs new file mode 100644 index 00000000000000..797597764308c2 --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-load-import-inline-typescript.mjs @@ -0,0 +1,11 @@ +// Flags: --no-experimental-strip-types --no-experimental-transform-types +// This tests that a mini TypeScript loader works with resolve and +// load hooks when TypeScript support is disabled. +import '../common/index.mjs'; +import assert from 'node:assert'; + +await import('../fixtures/module-hooks/register-typescript-hooks.js'); +// Test inline import(), if override fails, this should fail too because enum is +// not supported when --experimental-transform-types is disabled. +const { UserAccount, UserType } = await import('../fixtures/module-hooks/user.ts'); +assert.strictEqual((new UserAccount('foo', 1, UserType.Admin).name), 'foo'); diff --git a/test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript-override.js b/test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript-override.js new file mode 100644 index 00000000000000..967e362c70413f --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript-override.js @@ -0,0 +1,13 @@ +'use strict'; +// Flags: --experimental-strip-types --no-experimental-transform-types +// This tests that a mini TypeScript loader works with resolve and +// load hooks when overriding --experimental-strip-types in CJS. + +require('../common'); +const assert = require('assert'); + +require('../fixtures/module-hooks/register-typescript-hooks.js'); +// Test inline require(), if override fails, this should fail too because enum is +// not supported when --experimental-transform-types is disabled. +const { UserAccount, UserType } = require('../fixtures/module-hooks/user.ts'); +assert.strictEqual((new UserAccount('foo', 1, UserType.Admin).name), 'foo'); diff --git a/test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript.js b/test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript.js new file mode 100644 index 00000000000000..4366438f684262 --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-load-require-inline-typescript.js @@ -0,0 +1,12 @@ +'use strict'; +// Flags: --no-experimental-strip-types --no-experimental-transform-types +// This tests that a mini TypeScript loader works with resolve and +// load hooks when TypeScript support is disabled. + +require('../common'); +const assert = require('assert'); + +// Test inline require(). +require('../fixtures/module-hooks/register-typescript-hooks.js'); +const { UserAccount, UserType } = require('../fixtures/module-hooks/user.ts'); +assert.strictEqual((new UserAccount('foo', 1, UserType.Admin).name), 'foo'); diff --git a/test/module-hooks/test-module-hooks-resolve-short-circuit-required-middle.js b/test/module-hooks/test-module-hooks-resolve-short-circuit-required-middle.js new file mode 100644 index 00000000000000..1275304f997d9f --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-short-circuit-required-middle.js @@ -0,0 +1,32 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that shortCircuit is required in a middle hook when nextResolve is not called. +const hook1 = registerHooks({ + resolve(specifier, context, nextResolve) { + return nextResolve(specifier, context); + }, +}); +const hook2 = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'bar') { + return { + url: 'node:bar', + }; + } + return nextResolve(specifier, context); + }, +}); + +assert.throws(() => { + require('bar'); +}, { + code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', + message: /shortCircuit/, +}); + +hook1.deregister(); +hook2.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-short-circuit-required-start.js b/test/module-hooks/test-module-hooks-resolve-short-circuit-required-start.js new file mode 100644 index 00000000000000..69c68212b0d025 --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-short-circuit-required-start.js @@ -0,0 +1,28 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that shortCircuit is required in the starting hook when nextResolve is not called. +const hook = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'foo') { + return { + url: 'node:foo', + }; + } + return nextResolve(specifier, context); + }, +}); + +assert.throws(() => { + require('foo'); +}, { + code: 'ERR_INVALID_RETURN_PROPERTY_VALUE', + message: /shortCircuit/, +}); + +const baz = require('../fixtures/baz.js'); +assert.strictEqual(baz, 'perhaps I work'); +hook.deregister(); diff --git a/test/module-hooks/test-module-hooks-resolve-short-circuit.js b/test/module-hooks/test-module-hooks-resolve-short-circuit.js new file mode 100644 index 00000000000000..83e7057fa1ab7a --- /dev/null +++ b/test/module-hooks/test-module-hooks-resolve-short-circuit.js @@ -0,0 +1,29 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { registerHooks } = require('module'); + +// Test that shortCircuit works for the resolve hook. +const source1 = 'module.exports = "modified"'; +const hook1 = registerHooks({ + load: common.mustNotCall(), +}); +const hook2 = registerHooks({ + load(url, context, nextLoad) { + if (url.includes('empty')) { + return { + format: 'commonjs', + source: source1, + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); + +const value = require('../fixtures/empty.js'); +assert.strictEqual(value, 'modified'); + +hook1.deregister(); +hook2.deregister(); diff --git a/test/module-hooks/testcfg.py b/test/module-hooks/testcfg.py new file mode 100644 index 00000000000000..f904b1e9170fde --- /dev/null +++ b/test/module-hooks/testcfg.py @@ -0,0 +1,6 @@ +import sys, os +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import testpy + +def GetConfiguration(context, root): + return testpy.ParallelTestConfiguration(context, root, 'module-hooks') diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index 12adfaa7f5c5e1..c0ba01d3891477 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -98,6 +98,7 @@ expected.beforePreExec = new Set([ 'Internal Binding contextify', 'NativeModule internal/vm', 'NativeModule internal/modules/helpers', + 'NativeModule internal/modules/customization_hooks', 'NativeModule internal/modules/package_json_reader', 'Internal Binding module_wrap', 'NativeModule internal/modules/cjs/loader', diff --git a/test/parallel/test-repl.js b/test/parallel/test-repl.js index 610c7813e0439c..c2670c6cc942b4 100644 --- a/test/parallel/test-repl.js +++ b/test/parallel/test-repl.js @@ -51,6 +51,7 @@ async function runReplTests(socket, prompt, tests) { // Expect can be a single line or multiple lines const expectedLines = Array.isArray(expect) ? expect : [ expect ]; + console.error('\n------------'); console.error('out:', JSON.stringify(send)); socket.write(`${send}\n`); @@ -593,17 +594,18 @@ const errorTests = [ // REPL should get a normal require() function, not one that allows // access to internal modules without the --expose-internals flag. { - send: 'require("internal/repl")', + // Shrink the stack trace to avoid having to update this test whenever the + // implementation of require() changes. It's set to 4 because somehow setting it + // to a lower value breaks the error formatting and the message becomes + // "Uncaught [Error...", which is probably a bug(?). + send: 'Error.stackTraceLimit = 4; require("internal/repl")', expect: [ /^Uncaught Error: Cannot find module 'internal\/repl'/, /^Require stack:/, - /^- /, - /^ {4}at .*/, // at Module._resolveFilename - /^ {4}at .*/, // at Module._load - /^ {4}at .*/, // at TracingChannel.traceSync - /^ {4}at .*/, // at wrapModuleLoad - /^ {4}at .*/, // at Module.require - /^ {4}at .*/, // at require + /^- /, // This just tests MODULE_NOT_FOUND so let's skip the stack trace + /^ {4}at .*/, // Some stack frame that we have to capture otherwise error message is buggy. + /^ {4}at .*/, // Some stack frame that we have to capture otherwise error message is buggy. + /^ {4}at .*/, // Some stack frame that we have to capture otherwise error message is buggy. " code: 'MODULE_NOT_FOUND',", " requireStack: [ '' ]", '}',