Skip to content

Commit 077965a

Browse files
committed
module: implement synchronous module evaluate hooks
This patch adds an `evaluate` hook that can be used as a supported alternative to monkey-patching the CJS module loader, when the primary use case is to modify the exports of modules, with the goal of reducing dependency on CJS loader monkey-patching in the wild. The `evaluate` hook is run after the `resolve` and `load` hook, abstracting the final execution of the code in the module. It is only available to `module.registerHooks`. It is currently only run for the execution of the following modules: 1. CommonJS modules, either being `require`d or `import`ed. In this case, `context.module` equals to the `module` object in the CommonJS module being evaluated, and `context.module.exports` is mutable. 2. ECMAScript modules that are `require`d. This hook would only be run for the evaluation of the module being directly `require`d, but could not be run for each of its inner modules being `import`ed. In this case, `context.module` is a `Module` wrapper around the ECMAScript modules. Reassigning `context.module.exports` to something else only affects the result of `require()` call, but would not affect access within the ECMAScript module. Properties of `context.module.exports` (exports of the ECMAScript module) are not mutable. In future versions it may cover more module types, but the following are unlikely to be supported due to restrictions in the ECMAScript specifications: 1. The ability to skip evaluation of an inner ECMAScript module being `import`ed by ECMAScript modules. 2. The ability to mutate the exports of a ECMAScript module. For the ability to customize execution and exports of all the ECMAScript modules in the graph, consider patching the source code of the ECMAScript modules using the `load` hook as an imperfect workaround.
1 parent baa60ce commit 077965a

File tree

45 files changed

+922
-43
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+922
-43
lines changed

doc/api/errors.md

+6
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,12 @@ The V8 platform used by this instance of Node.js does not support creating
22562256
Workers. This is caused by lack of embedder support for Workers. In particular,
22572257
this error will not occur with standard builds of Node.js.
22582258

2259+
<a id="ERR_MODULE_ALREADY_EVALUATED"></a>
2260+
2261+
### `ERR_MODULE_ALREADY_EVALUATED`
2262+
2263+
A module cannot be evaluated twice using the `evalaute` customization hook.
2264+
22592265
<a id="ERR_MODULE_NOT_FOUND"></a>
22602266

22612267
### `ERR_MODULE_NOT_FOUND`

doc/api/module.md

+55
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,61 @@ hook that returns without calling `next<hookName>()` _and_ without returning
923923
prevent unintentional breaks in the chain. Return `shortCircuit: true` from a
924924
hook to signal that the chain is intentionally ending at your hook.
925925
926+
#### `evaluate(context, nextEvaluate)`
927+
928+
<!-- YAML
929+
added:
930+
- REPLACEME
931+
-->
932+
933+
> Stability: 1.1 - Active Development
934+
935+
* `context` {Object} An object that will be used along the evaluation of a module.
936+
It contains the following immutable properties.
937+
* `module` {Object} The `Module` instance of the module being evaluated.
938+
* `format` {string} The format of the module, which may be one of the list of
939+
acceptable values described in the [`load` hook][load hook].
940+
* `nextEvaluate` {Function} The subsequent `evaluate` hook in the chain, or the
941+
Node.js default `evaluate` hook after the last user-supplied `evaluate` hook.
942+
This hook does not take any arguments.
943+
* Returns: {Object}
944+
* `returned` {any} When the module is being required and it is a CommonJS module,
945+
this contains the value that the CommonJS module returns, if
946+
it uses any top-level `return` statement.
947+
* `shortCircuit` {undefined|boolean} A signal that this hook intends to
948+
terminate the chain of `evaluate` hooks. **Default:** `false`
949+
950+
The `evaluate` hook is run after the `resolve` and `load` hook,
951+
abstracting the final execution of the code in the module. It is only
952+
available to `module.registerHooks`. It is currently only run for the
953+
execution of the following modules:
954+
955+
1. CommonJS modules, either being `require`d or `import`ed. In this
956+
case, `context.module` equals to the `module` object in the CommonJS
957+
module being evaluated, and `context.module.exports` is mutable.
958+
2. ECMAScript modules that are `require`d. This hook would only be run
959+
for the evaluation of the module being directly `require`d, but
960+
could not be run for each of its inner modules being `import`ed. In
961+
this case, `context.module` is a `Module` wrapper around the
962+
ECMAScript modules. Reassigning `context.module.exports` to
963+
something else only affects the result of `require()` call, but
964+
would not affect access within the ECMAScript module. Properties of
965+
`context.module.exports` (exports of the ECMAScript module) are not
966+
mutable.
967+
968+
In future versions it may cover more module types, but the following
969+
are unlikely to be supported due to restrictions in the ECMAScript
970+
specifications:
971+
972+
1. The ability to skip evaluation of an inner ECMAScript module being
973+
`import`ed by ECMAScript modules.
974+
2. The ability to mutate the exports of a ECMAScript module.
975+
976+
For the ability to customize execution and exports of all the
977+
ECMAScript modules in the graph, consider patching the source code of
978+
the ECMAScript modules using the [`load` hook][load hook] as an imperfect
979+
workaround.
980+
926981
#### `initialize()`
927982
928983
<!-- YAML

lib/internal/errors.js

+1
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,7 @@ E('ERR_MISSING_ARGS',
15781578
return `${msg} must be specified`;
15791579
}, TypeError);
15801580
E('ERR_MISSING_OPTION', '%s is required', TypeError);
1581+
E('ERR_MODULE_ALREADY_EVALUATED', 'Module cannot be evaluated twice', Error);
15811582
E('ERR_MODULE_NOT_FOUND', function(path, base, exactUrl) {
15821583
if (exactUrl) {
15831584
lazyInternalUtil().setOwnProperty(this, 'url', `${exactUrl}`);

lib/internal/modules/cjs/loader.js

+53-18
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const kIsMainSymbol = Symbol('kIsMainSymbol');
101101
const kIsCachedByESMLoader = Symbol('kIsCachedByESMLoader');
102102
const kRequiredModuleSymbol = Symbol('kRequiredModuleSymbol');
103103
const kIsExecuting = Symbol('kIsExecuting');
104+
const kHasBeenEvaluated = Symbol('kHasBeenEvaluated');
104105

105106
const kURL = Symbol('kURL');
106107
const kFormat = Symbol('kFormat');
@@ -122,6 +123,7 @@ module.exports = {
122123
kIsCachedByESMLoader,
123124
kRequiredModuleSymbol,
124125
kIsExecuting,
126+
kHasBeenEvaluated,
125127
};
126128

127129
const { BuiltinModule } = require('internal/bootstrap/realm');
@@ -169,6 +171,8 @@ const {
169171
registerHooks,
170172
resolveHooks,
171173
resolveWithHooks,
174+
evaluateWithHooks,
175+
evaluateHooks,
172176
} = require('internal/modules/customization_hooks');
173177
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
174178
const packageJsonReader = require('internal/modules/package_json_reader');
@@ -184,6 +188,7 @@ const {
184188
ERR_INVALID_ARG_TYPE,
185189
ERR_INVALID_ARG_VALUE,
186190
ERR_INVALID_MODULE_SPECIFIER,
191+
ERR_MODULE_ALREADY_EVALUATED,
187192
ERR_REQUIRE_CYCLE_MODULE,
188193
ERR_REQUIRE_ESM,
189194
ERR_UNKNOWN_BUILTIN_MODULE,
@@ -1712,31 +1717,59 @@ Module.prototype._compile = function(content, filename, format) {
17121717
}
17131718
}
17141719

1715-
if (format === 'module') {
1716-
loadESMFromCJS(this, filename, format, content);
1717-
return;
1720+
function defaultEvaluate(context) {
1721+
const mod = context.module;
1722+
if (mod[kHasBeenEvaluated]) {
1723+
throw new ERR_MODULE_ALREADY_EVALUATED();
1724+
}
1725+
const filename = mod.filename;
1726+
if (context.format === 'module') {
1727+
// TODO(joyeecheung): consider putting content in the context and this doesn't
1728+
// need to be a closure.
1729+
loadESMFromCJS(mod, filename, format, content);
1730+
mod[kHasBeenEvaluated] = true;
1731+
// ESM do not have top-level returns.
1732+
return { __proto__: null, returned: undefined };
1733+
}
1734+
1735+
const dirname = path.dirname(filename);
1736+
const require = makeRequireFunction(mod, redirects);
1737+
const expts = mod.exports;
1738+
const thisValue = expts;
1739+
if (requireDepth === 0) { statCache = new SafeMap(); }
1740+
const args = [expts, require, mod, filename, dirname];
1741+
1742+
// TODO(joyeecheung): consider putting compiledWrapper in the context and this doesn't
1743+
// need to be a closure.
1744+
// This is whatever returned by the wrapped function, note that users might not assign it to exports
1745+
// in case they put a return statement directly in CJS.
1746+
let returned;
1747+
if (mod[kIsMainSymbol] && getOptionValue('--inspect-brk')) {
1748+
const { callAndPauseOnStart } = internalBinding('inspector');
1749+
returned = callAndPauseOnStart(compiledWrapper, thisValue, ...args);
1750+
} else {
1751+
returned = ReflectApply(compiledWrapper, thisValue, args);
1752+
}
1753+
mod[kHasBeenEvaluated] = true;
1754+
1755+
if (requireDepth === 0) { statCache = null; }
1756+
return { __proto__: null, returned };
17181757
}
17191758

1720-
const dirname = path.dirname(filename);
1721-
const require = makeRequireFunction(this, redirects);
1722-
let result;
1723-
const exports = this.exports;
1724-
const thisValue = exports;
1725-
const module = this;
1726-
if (requireDepth === 0) { statCache = new SafeMap(); }
17271759
setHasStartedUserCJSExecution();
17281760
this[kIsExecuting] = true;
1729-
if (this[kIsMainSymbol] && getOptionValue('--inspect-brk')) {
1730-
const { callAndPauseOnStart } = internalBinding('inspector');
1731-
result = callAndPauseOnStart(compiledWrapper, thisValue, exports,
1732-
require, module, filename, dirname);
1761+
format ||= 'commonjs'; // At this point, it's considered CommonJS.
1762+
let returned;
1763+
if (evaluateHooks.length > 0) {
1764+
const result = evaluateWithHooks(this, format, defaultEvaluate);
1765+
returned = result?.returned;
17331766
} else {
1734-
result = ReflectApply(compiledWrapper, thisValue,
1735-
[exports, require, module, filename, dirname]);
1767+
// Avoid creating a full ModuleEvaluateContext for fully internal code.
1768+
const result = defaultEvaluate({ __proto__: null, format, module: this });
1769+
returned = result?.returned;
17361770
}
17371771
this[kIsExecuting] = false;
1738-
if (requireDepth === 0) { statCache = null; }
1739-
return result;
1772+
return returned;
17401773
};
17411774

17421775
/**
@@ -1907,6 +1940,7 @@ Module._extensions['.js'] = function(module, filename) {
19071940
Module._extensions['.json'] = function(module, filename) {
19081941
const { source: content } = loadSource(module, filename, 'json');
19091942

1943+
// TODO(joyeecheung): invoke evaluate hook with 'json' format.
19101944
try {
19111945
setOwnProperty(module, 'exports', JSONParse(stripBOM(content)));
19121946
} catch (err) {
@@ -1922,6 +1956,7 @@ Module._extensions['.json'] = function(module, filename) {
19221956
*/
19231957
Module._extensions['.node'] = function(module, filename) {
19241958
// Be aware this doesn't use `content`
1959+
// TODO(joyeecheung): invoke evaluate hook with 'addon' format.
19251960
return process.dlopen(module, path.toNamespacedPath(filename));
19261961
};
19271962

0 commit comments

Comments
 (0)