Skip to content

Commit 9905e2d

Browse files
committed
module: support require()ing synchronous ESM graphs
This patch adds `require()` support for synchronous ESM graphs under the flag --experimental-require-module. This is based on the the following design aspect of ESM: - The resolution can be synchronous (up to the host) - The evaluation of a synchronous graph (without top-level await) is also synchronous, and, by the time the module graph is instantiated (before evaluation starts), this is is already known. When the module being require()ed has .mjs extension or there are other explicit indicators that it's an ES module, we load it as an ES module. If the graph is synchronous, we return the module namespace as the exports. If the graph contains top-level await, we throw an error before evaluating the module. If an additional flag --print-pending-tla is passed, we proceeds to evaluation but do not run the microtasks, only to find out where the TLA is and print their location to help users fix them. If there are not explicit indicators whether a .js file is CJS or ESM, we parse it as CJS first. If the parse error indicates that it contains ESM syntax, we parse it again as ESM. If the second parsing succeeds, we continue to treat it as ESM.
1 parent 38c74d3 commit 9905e2d

20 files changed

+639
-159
lines changed

.eslintignore

+2
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ doc/changelogs/CHANGELOG_v1*.md
1313
!doc/changelogs/CHANGELOG_v18.md
1414
!doc/api_assets/*.js
1515
!.eslintrc.js
16+
test/es-module/test-require-module-entry-point.js
17+
test/es-module/test-require-module-entry-point-aou.js

doc/api/cli.md

+17
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,22 @@ added: v11.8.0
871871

872872
Use the specified file as a security policy.
873873

874+
### `--experimental-require-module`
875+
876+
<!-- YAML
877+
added: REPLACEME
878+
-->
879+
880+
> Stability: 1.1 - Active Developement
881+
882+
Supports loading a synchronous ES module graph in `require()`. If the module
883+
graph is not synchronous (contains top-level await), it throws an error.
884+
885+
By default, a `.js` file will be parsed as a CommonJS module first. If it
886+
contains ES module syntax, Node.js will try to parse and evaluate the module
887+
again as an ES module. If it turns out to be synchronous and can be evaluated
888+
successfully, the module namespace object will be returned by `require()`.
889+
874890
### `--experimental-sea-config`
875891

876892
<!-- YAML
@@ -2523,6 +2539,7 @@ Node.js options that are allowed are:
25232539
* `--experimental-network-imports`
25242540
* `--experimental-permission`
25252541
* `--experimental-policy`
2542+
* `--experimental-require-module`
25262543
* `--experimental-shadow-realm`
25272544
* `--experimental-specifier-resolution`
25282545
* `--experimental-top-level-await`

lib/internal/errors.js

+3
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class NodeAggregateError extends AggregateError {
210210
}
211211

212212
const assert = require('internal/assert');
213+
const { getOptionValue } = require('internal/options');
213214

214215
// Lazily loaded
215216
let util;
@@ -1686,6 +1687,8 @@ E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError);
16861687
E('ERR_REQUIRE_ESM',
16871688
function(filename, hasEsmSyntax, parentPath = null, packageJsonPath = null) {
16881689
hideInternalStackFrames(this);
1690+
// TODO(joyeecheung): mention --experimental-require-module here.
1691+
assert(!getOptionValue('--experimental-require-module'));
16891692
let msg = `require() of ES Module ${filename}${parentPath ? ` from ${
16901693
parentPath}` : ''} not supported.`;
16911694
if (!packageJsonPath) {

lib/internal/modules/cjs/loader.js

+89-41
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ const {
6060
StringPrototypeSlice,
6161
StringPrototypeSplit,
6262
StringPrototypeStartsWith,
63+
Symbol,
6364
} = primordials;
6465

65-
// Map used to store CJS parsing data.
66-
const cjsParseCache = new SafeWeakMap();
66+
// Map used to store CJS parsing data or for ESM loading.
67+
const cjsSourceCache = new SafeWeakMap();
6768
/**
6869
* Map of already-loaded CJS modules to use.
6970
*/
@@ -72,12 +73,14 @@ const cjsExportsCache = new SafeWeakMap();
7273
// Set first due to cycle with ESM loader functions.
7374
module.exports = {
7475
cjsExportsCache,
75-
cjsParseCache,
76+
cjsSourceCache,
7677
initializeCJS,
7778
Module,
7879
wrapSafe,
7980
};
8081

82+
const is_main_symbol = Symbol('is_main_symbol');
83+
8184
const { BuiltinModule } = require('internal/bootstrap/realm');
8285
const {
8386
maybeCacheSourceMap,
@@ -98,7 +101,6 @@ const {
98101
containsModuleSyntax,
99102
compileFunctionForCJSLoader,
100103
} = internalBinding('contextify');
101-
102104
const assert = require('internal/assert');
103105
const fs = require('fs');
104106
const path = require('path');
@@ -107,7 +109,6 @@ const { safeGetenv } = internalBinding('credentials');
107109
const {
108110
privateSymbols: {
109111
require_private_symbol,
110-
host_defined_option_symbol,
111112
},
112113
} = internalBinding('util');
113114
const {
@@ -396,6 +397,10 @@ function initializeCJS() {
396397
// TODO(joyeecheung): deprecate this in favor of a proper hook?
397398
Module.runMain =
398399
require('internal/modules/run_main').executeUserEntryPoint;
400+
401+
if (getOptionValue('--experimental-require-module')) {
402+
Module._extensions['.mjs'] = loadESMFromCJS;
403+
}
399404
}
400405

401406
// Given a module name, and a list of paths to test, returns the first
@@ -988,7 +993,7 @@ Module._load = function(request, parent, isMain) {
988993
if (cachedModule !== undefined) {
989994
updateChildren(parent, cachedModule, true);
990995
if (!cachedModule.loaded) {
991-
const parseCachedModule = cjsParseCache.get(cachedModule);
996+
const parseCachedModule = cjsSourceCache.get(cachedModule);
992997
if (!parseCachedModule || parseCachedModule.loaded) {
993998
return getExportsForCircularRequire(cachedModule);
994999
}
@@ -1010,6 +1015,9 @@ Module._load = function(request, parent, isMain) {
10101015
setOwnProperty(process, 'mainModule', module);
10111016
setOwnProperty(module.require, 'main', process.mainModule);
10121017
module.id = '.';
1018+
module[is_main_symbol] = true;
1019+
} else {
1020+
module[is_main_symbol] = false;
10131021
}
10141022

10151023
reportModuleToWatchMode(filename);
@@ -1270,46 +1278,55 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
12701278
);
12711279

12721280
// Cache the source map for the module if present.
1273-
if (script.sourceMapURL) {
1274-
maybeCacheSourceMap(filename, content, this, false, undefined, script.sourceMapURL);
1281+
const { sourceMapURL } = script;
1282+
if (sourceMapURL) {
1283+
maybeCacheSourceMap(filename, content, this, false, undefined, sourceMapURL);
12751284
}
12761285

1277-
return runScriptInThisContext(script, true, false);
1286+
return {
1287+
__proto__: null,
1288+
function: runScriptInThisContext(script, true, false),
1289+
sourceMapURL,
1290+
retryAsESM: false,
1291+
};
12781292
}
12791293

1280-
try {
1281-
const result = compileFunctionForCJSLoader(content, filename);
1282-
result.function[host_defined_option_symbol] = hostDefinedOptionId;
1283-
1284-
// cachedDataRejected is only set for cache coming from SEA.
1285-
if (codeCache &&
1286-
result.cachedDataRejected !== false &&
1287-
internalBinding('sea').isSea()) {
1288-
process.emitWarning('Code cache data rejected.');
1289-
}
1294+
const result = compileFunctionForCJSLoader(content, filename);
12901295

1291-
// Cache the source map for the module if present.
1292-
if (result.sourceMapURL) {
1293-
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
1294-
}
1296+
// cachedDataRejected is only set for cache coming from SEA.
1297+
if (codeCache &&
1298+
result.cachedDataRejected !== false &&
1299+
internalBinding('sea').isSea()) {
1300+
process.emitWarning('Code cache data rejected.');
1301+
}
12951302

1296-
return result.function;
1297-
} catch (err) {
1298-
if (process.mainModule === cjsModuleInstance) {
1299-
const { enrichCJSError } = require('internal/modules/esm/translators');
1300-
enrichCJSError(err, content, filename);
1301-
}
1302-
throw err;
1303+
// Cache the source map for the module if present.
1304+
if (result.sourceMapURL) {
1305+
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
13031306
}
1307+
1308+
return result;
1309+
}
1310+
1311+
// Resolve and evaluate as ESM, synchronously.
1312+
function loadESMFromCJS(mod, filename) {
1313+
const source = getMaybeCachedSource(mod, filename);
1314+
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
1315+
// We are still using the CJS's resolution here.
1316+
const url = pathToFileURL(filename).href;
1317+
const isMain = mod[is_main_symbol];
1318+
// TODO(joyeecheung): maybe we can do some special handling for default here. Maybe we don't.
1319+
mod.exports = cascadedLoader.importSyncForRequire(url, source, isMain);
13041320
}
13051321

13061322
/**
13071323
* Run the file contents in the correct scope or sandbox. Expose the correct helper variables (`require`, `module`,
13081324
* `exports`) to the file. Returns exception, if any.
13091325
* @param {string} content The source code of the module
13101326
* @param {string} filename The file path of the module
1327+
* @param {boolean} loadAsESM Whether it's known to be ESM - i.e. suffix is .mjs.
13111328
*/
1312-
Module.prototype._compile = function(content, filename) {
1329+
Module.prototype._compile = function(content, filename, loadAsESM = false) {
13131330
let moduleURL;
13141331
let redirects;
13151332
const manifest = policy()?.manifest;
@@ -1319,8 +1336,25 @@ Module.prototype._compile = function(content, filename) {
13191336
manifest.assertIntegrity(moduleURL, content);
13201337
}
13211338

1322-
const compiledWrapper = wrapSafe(filename, content, this);
1339+
// TODO(joyeecheung): when the module is the entry point, consider allowing TLA.
1340+
// Only modules being require()'d really need to avoid TLA.
1341+
let compiledWrapper;
1342+
if (!loadAsESM) {
1343+
const result = wrapSafe(filename, content, this);
1344+
compiledWrapper = result.function;
1345+
loadAsESM = result.retryAsESM;
1346+
}
13231347

1348+
if (loadAsESM) {
1349+
// Pass the source into the .mjs extension handler indirectly through the cache.
1350+
cjsSourceCache.set(this, content);
1351+
loadESMFromCJS(this, filename);
1352+
return;
1353+
}
1354+
1355+
// TODO(joyeecheung): the detection below is unnecessarily complex. Maybe just
1356+
// use the is_main_symbol, or a break_on_start_symbol that gets passed from
1357+
// higher level instead of doing hacky detecion here.
13241358
let inspectorWrapper = null;
13251359
if (getOptionValue('--inspect-brk') && process._eval == null) {
13261360
if (!resolvedArgv) {
@@ -1344,6 +1378,7 @@ Module.prototype._compile = function(content, filename) {
13441378
inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
13451379
}
13461380
}
1381+
13471382
const dirname = path.dirname(filename);
13481383
const require = makeRequireFunction(this, redirects);
13491384
let result;
@@ -1363,25 +1398,37 @@ Module.prototype._compile = function(content, filename) {
13631398
return result;
13641399
};
13651400

1366-
/**
1367-
* Native handler for `.js` files.
1368-
* @param {Module} module The module to compile
1369-
* @param {string} filename The file path of the module
1370-
*/
1371-
Module._extensions['.js'] = function(module, filename) {
1372-
// If already analyzed the source, then it will be cached.
1373-
const cached = cjsParseCache.get(module);
1401+
function getMaybeCachedSource(mod, filename) {
1402+
const cached = cjsSourceCache.get(mod);
13741403
let content;
13751404
if (cached?.source) {
13761405
content = cached.source;
13771406
cached.source = undefined;
13781407
} else {
1408+
// TODO(joyeecheung): read a buffer.
13791409
content = fs.readFileSync(filename, 'utf8');
13801410
}
1411+
return content;
1412+
}
1413+
1414+
/**
1415+
* Native handler for `.js` files.
1416+
* @param {Module} module The module to compile
1417+
* @param {string} filename The file path of the module
1418+
*/
1419+
Module._extensions['.js'] = function(module, filename) {
1420+
// If already analyzed the source, then it will be cached.
1421+
const content = getMaybeCachedSource(module, filename);
1422+
13811423
if (StringPrototypeEndsWith(filename, '.js')) {
13821424
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
13831425
// Function require shouldn't be used in ES modules.
13841426
if (pkg?.data.type === 'module') {
1427+
if (getOptionValue('--experimental-require-module')) {
1428+
module._compile(content, filename, true);
1429+
return;
1430+
}
1431+
13851432
// This is an error path because `require` of a `.js` file in a `"type": "module"` scope is not allowed.
13861433
const parent = moduleParentCache.get(module);
13871434
const parentPath = parent?.filename;
@@ -1414,7 +1461,8 @@ Module._extensions['.js'] = function(module, filename) {
14141461
throw err;
14151462
}
14161463
}
1417-
module._compile(content, filename);
1464+
1465+
module._compile(content, filename, false);
14181466
};
14191467

14201468
/**

0 commit comments

Comments
 (0)