Skip to content

Commit

Permalink
esm: add initialize hook
Browse files Browse the repository at this point in the history
  • Loading branch information
izaakschroeder committed Jul 31, 2023
1 parent a955c53 commit 8c749ea
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 24 deletions.
15 changes: 15 additions & 0 deletions doc/api/deprecations.md
Original file line number Diff line number Diff line change
Expand Up @@ -3380,6 +3380,21 @@ In a future version of Node.js, [`assert.CallTracker`][],
will be removed.
Consider using alternatives such as the [`mock`][] helper function.

### DEP0174: the `globalPreload` hook

<!-- YAML
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/48842
description: Superseded be `initialize`.
-->

Type: Runtime

In a future version of Node.js, the [`globalPreload`][] hook,
will be removed. Consider using alternatives such as the
[`initialize`][] hook.

[NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
[RFC 6066]: https://tools.ietf.org/html/rfc6066#section-3
[RFC 8247 Section 2.4]: https://www.rfc-editor.org/rfc/rfc8247#section-2.4
Expand Down
72 changes: 70 additions & 2 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,12 @@ of Node.js applications.
<!-- YAML
added: v8.8.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/48842
description: Added `initialize` hook and deprecated `getGlobalPreload`.
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/48559
description: Allow loaders to use `import()` and unflag `Module.register`.
- version:
- v18.6.0
- v16.17.0
Expand Down Expand Up @@ -938,19 +944,81 @@ export async function load(url, context, nextLoad) {
In a more advanced scenario, this can also be used to transform an unsupported
source to a supported one (see [Examples](#examples) below).
#### `initialize()`
<!-- YAML
added: REPLACEME
-->
> The loaders API is being redesigned. This hook may disappear or its
> signature may change. Do not rely on the API described below.
> In a previous version of this API, this was split across 3 separate, now
> deprecated, hooks (`getFormat`, `getSource`, and `transformSource`).
* `data` {any} The data provided via `Module.register(loader, parentUrl, data)`
* Returns: {any} The data returned to the caller of `Module.register`
The `initialize` hook provides a way to define a custom method of that runs
in the loader's thread when the loader is initialized. This hook can send
and receive data from a `Module.register` invocation, including ports and
other transferrable objects.
Loader code:
```js
// In the below example this file is referenced as
// '/path-to-my-loader.js'

export function initialize({ number, port }) {
port.postMessage(`increment: ${number + 1}`);
return 'ok';
}
```
Caller code:
```js
import assert from 'node:assert';
import { register } from 'node:module';
import { MessageChannel } from 'node:worker_threads';

// In this example '/path-to-my-loader.js' is replaced with the
// path to the file containing the loader contents above.

// This example showcases how a message channel can be used to
// communicate to the loader, by sending `port2` to the loader.
const { port1, port2 } = new MessageChannel();

port1.on('message', (msg) => {
assert(msg === 'increment: 2');
});

const result = register('/path-to-my-loader.js', import.meta.url, {
data: { number: 1, port: port2 },
transferList: [port2],
});

assert(result === 'ok');
```
#### `globalPreload()`
<!-- YAML
deprecated: REPLACEME
changes:
- version:
- REPLACEME
pr-url: https://github.com/nodejs/node/pull/48842
description: Deprecated in favor of `initialize` hook.
- version:
- v18.6.0
- v16.17.0
pr-url: https://github.com/nodejs/node/pull/42623
description: Add support for chaining globalPreload hooks.
-->
> The loaders API is being redesigned. This hook may disappear or its
> signature may change. Do not rely on the API described below.
> Deprecated: Use `initialize` instead.
> In a previous version of this API, this hook was named
> `getGlobalPreloadCode`.
Expand Down
21 changes: 21 additions & 0 deletions doc/api/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,27 @@ globalPreload: http-to-https
globalPreload: unpkg
```
This function can also be used to pass data to the loader's `initialize`
hook include transferrable objects like ports.
```mjs
import { register } from 'node:module';
import { MessageChannel } from 'node:worker_threads';

// This example showcases how a message channel can be used to
// communicate to the loader, by sending `port2` to the loader.
const { port1, port2 } = new MessageChannel();

port1.on('message', (msg) => {
console.log(msg);
});

register('./my-programmatic-loader.mjs', import.meta.url, {
data: { number: 1, port: port2 },
transferList: [port2],
});
```
### `module.syncBuiltinESMExports()`
<!-- YAML
Expand Down
61 changes: 52 additions & 9 deletions lib/internal/modules/esm/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const {
ArrayPrototypePush,
ArrayPrototypePushApply,
FunctionPrototypeCall,
Int32Array,
ObjectAssign,
Expand Down Expand Up @@ -83,6 +84,7 @@ let importMetaInitializer;

// [2] `validate...()`s throw the wrong error

let globalPreloadWarned = false;
class Hooks {
#chains = {
/**
Expand Down Expand Up @@ -127,31 +129,44 @@ class Hooks {
* Import and register custom/user-defined module loader hook(s).
* @param {string} urlOrSpecifier
* @param {string} parentURL
* @param {any} [data] Arbitrary data to be passed from the custom
* loader (user-land) to the worker.
*/
async register(urlOrSpecifier, parentURL) {
async register(urlOrSpecifier, parentURL, data) {
const moduleLoader = require('internal/process/esm_loader').esmLoader;
const keyedExports = await moduleLoader.import(
urlOrSpecifier,
parentURL,
kEmptyObject,
);
this.addCustomLoader(urlOrSpecifier, keyedExports);
return this.addCustomLoader(urlOrSpecifier, keyedExports, data);
}

/**
* Collect custom/user-defined module loader hook(s).
* After all hooks have been collected, the global preload hook(s) must be initialized.
* @param {string} url Custom loader specifier
* @param {Record<string, unknown>} exports
* @param {any} [data] Arbitrary data to be passed from the custom loader (user-land)
* to the worker.
*/
addCustomLoader(url, exports) {
addCustomLoader(url, exports, data) {
const {
globalPreload,
resolve,
load,
initialize,
} = pluckHooks(exports);

if (globalPreload) {
if (globalPreloadWarned === false) {
globalPreloadWarned = true;
process.emitWarning(
'`globalPreload` is deprecated. Please use `initialize` instead.',
'DeprecationWarning',
'DEP0174',
);
}
ArrayPrototypePush(this.#chains.globalPreload, { __proto__: null, fn: globalPreload, url });
}
if (resolve) {
Expand All @@ -162,6 +177,7 @@ class Hooks {
const next = this.#chains.load[this.#chains.load.length - 1];
ArrayPrototypePush(this.#chains.load, { __proto__: null, fn: load, url, next });
}
return initialize?.(data);
}

/**
Expand Down Expand Up @@ -553,15 +569,30 @@ class HooksProxy {
}
}

async makeAsyncRequest(method, ...args) {
/**
* Invoke a remote method asynchronously.
* @param {any[]|undefined} transferList Objects in `args` to be transferred
* @param {string} method Method to invoke
* @param {any[]} args Arguments to pass to `method`
* @returns {Promise<any>}
*/
async makeAsyncRequest(transferList, method, ...args) {
this.waitForWorker();

MessageChannel ??= require('internal/worker/io').MessageChannel;
const asyncCommChannel = new MessageChannel();

// Pass work to the worker.
debug('post async message to worker', { method, args });
this.#worker.postMessage({ method, args, port: asyncCommChannel.port2 }, [asyncCommChannel.port2]);
debug('post async message to worker', { method, args, transferList });
const finalTransferList = [asyncCommChannel.port2];
if (transferList) {
ArrayPrototypePushApply(finalTransferList, transferList);
}
this.#worker.postMessage({
__proto__: null,
method, args,
port: asyncCommChannel.port2,
}, finalTransferList);

if (this.#numberOfPendingAsyncResponses++ === 0) {
// On the next lines, the main thread will await a response from the worker thread that might
Expand Down Expand Up @@ -593,12 +624,19 @@ class HooksProxy {
return body;
}

makeSyncRequest(method, ...args) {
/**
* Invoke a remote method synchronously.
* @param {any[]|undefined} transferList Objects in `args` to be transferred
* @param {string} method Method to invoke
* @param {any[]} args Arguments to pass to `method`
* @returns {any}
*/
makeSyncRequest(transferList, method, ...args) {
this.waitForWorker();

// Pass work to the worker.
debug('post sync message to worker', { method, args });
this.#worker.postMessage({ method, args });
debug('post sync message to worker', { method, args, transferList });
this.#worker.postMessage({ __proto__: null, method, args }, transferList);

let response;
do {
Expand Down Expand Up @@ -710,6 +748,7 @@ function pluckHooks({
globalPreload,
resolve,
load,
initialize,
}) {
const acceptedHooks = { __proto__: null };

Expand All @@ -723,6 +762,10 @@ function pluckHooks({
acceptedHooks.load = load;
}

if (initialize) {
acceptedHooks.initialize = initialize;
}

return acceptedHooks;
}

Expand Down
30 changes: 20 additions & 10 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,18 @@ class ModuleLoader {
return module.getNamespace();
}

register(specifier, parentUrl) {
/**
* @see {@link CustomizedModuleLoader.register}
*/
register(specifier, parentUrl, data, transferList) {
if (!this.#customizations) {
// `CustomizedModuleLoader` is defined at the bottom of this file and
// available well before this line is ever invoked. This is here in
// order to preserve the git diff instead of moving the class.
// eslint-disable-next-line no-use-before-define
this.setCustomizations(new CustomizedModuleLoader());
}
return this.#customizations.register(specifier, parentUrl);
return this.#customizations.register(specifier, parentUrl, data, transferList);
}

/**
Expand Down Expand Up @@ -426,10 +429,13 @@ class CustomizedModuleLoader {
* be registered.
* @param {string} parentURL The parent URL from where the loader will be
* registered if using it package name as specifier
* @param {any} [data] Arbitrary data to be passed from the custom loader
* (user-land) to the worker.
* @param {any[]} [transferList] Objects in `data` that are changing ownership
* @returns {{ format: string, url: URL['href'] }}
*/
register(originalSpecifier, parentURL) {
return hooksProxy.makeSyncRequest('register', originalSpecifier, parentURL);
register(originalSpecifier, parentURL, data, transferList) {
return hooksProxy.makeSyncRequest(transferList, 'register', originalSpecifier, parentURL, data);
}

/**
Expand All @@ -442,12 +448,12 @@ class CustomizedModuleLoader {
* @returns {{ format: string, url: URL['href'] }}
*/
resolve(originalSpecifier, parentURL, importAssertions) {
return hooksProxy.makeAsyncRequest('resolve', originalSpecifier, parentURL, importAssertions);
return hooksProxy.makeAsyncRequest(undefined, 'resolve', originalSpecifier, parentURL, importAssertions);
}

resolveSync(originalSpecifier, parentURL, importAssertions) {
// This happens only as a result of `import.meta.resolve` calls, which must be sync per spec.
return hooksProxy.makeSyncRequest('resolve', originalSpecifier, parentURL, importAssertions);
return hooksProxy.makeSyncRequest(undefined, 'resolve', originalSpecifier, parentURL, importAssertions);
}

/**
Expand All @@ -457,7 +463,7 @@ class CustomizedModuleLoader {
* @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>}
*/
load(url, context) {
return hooksProxy.makeAsyncRequest('load', url, context);
return hooksProxy.makeAsyncRequest(undefined, 'load', url, context);
}

importMetaInitialize(meta, context, loader) {
Expand Down Expand Up @@ -514,18 +520,22 @@ function getHooksProxy() {
* Register a single loader programmatically.
* @param {string} specifier
* @param {string} [parentURL]
* @returns {void}
* @param {any} [data] Arbitrary data passed to loader's `initialize` hook
* @param {any[]} transferList Objects in `data` that are changing ownership
* @returns {any}
* @example
* ```js
* register('./myLoader.js');
* register('ts-node/esm', import.meta.url);
* register('./myLoader.js', import.meta.url);
* register(new URL('./myLoader.js', import.meta.url));
* register('./myLoader.js', import.meta.url, {banana: 'tasty'});
* register('./myLoader.js', import.meta.url, someArrayBuffer, [someArrayBuffer]);
* ```
*/
function register(specifier, parentURL = 'data:') {
function register(specifier, parentURL = 'data:', data, transferList) {
const moduleLoader = require('internal/process/esm_loader').esmLoader;
moduleLoader.register(`${specifier}`, parentURL);
return moduleLoader.register(`${specifier}`, parentURL, data, transferList);
}

module.exports = {
Expand Down
Loading

0 comments on commit 8c749ea

Please sign in to comment.