Skip to content

Commit b4c3f5c

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 b4c3f5c

20 files changed

+686
-253
lines changed

.eslintignore

Lines changed: 2 additions & 0 deletions
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

Lines changed: 17 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 106 additions & 44 deletions
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,15 @@ 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,
80+
makeRequireWithPolicy,
7981
};
8082

83+
const is_main_symbol = Symbol('is_main_symbol');
84+
8185
const { BuiltinModule } = require('internal/bootstrap/realm');
8286
const {
8387
maybeCacheSourceMap,
@@ -98,7 +102,6 @@ const {
98102
containsModuleSyntax,
99103
compileFunctionForCJSLoader,
100104
} = internalBinding('contextify');
101-
102105
const assert = require('internal/assert');
103106
const fs = require('fs');
104107
const path = require('path');
@@ -107,7 +110,6 @@ const { safeGetenv } = internalBinding('credentials');
107110
const {
108111
privateSymbols: {
109112
require_private_symbol,
110-
host_defined_option_symbol,
111113
},
112114
} = internalBinding('util');
113115
const {
@@ -396,6 +398,10 @@ function initializeCJS() {
396398
// TODO(joyeecheung): deprecate this in favor of a proper hook?
397399
Module.runMain =
398400
require('internal/modules/run_main').executeUserEntryPoint;
401+
402+
if (getOptionValue('--experimental-require-module')) {
403+
Module._extensions['.mjs'] = loadESMFromCJS;
404+
}
399405
}
400406

401407
// Given a module name, and a list of paths to test, returns the first
@@ -988,7 +994,7 @@ Module._load = function(request, parent, isMain) {
988994
if (cachedModule !== undefined) {
989995
updateChildren(parent, cachedModule, true);
990996
if (!cachedModule.loaded) {
991-
const parseCachedModule = cjsParseCache.get(cachedModule);
997+
const parseCachedModule = cjsSourceCache.get(cachedModule);
992998
if (!parseCachedModule || parseCachedModule.loaded) {
993999
return getExportsForCircularRequire(cachedModule);
9941000
}
@@ -1010,6 +1016,9 @@ Module._load = function(request, parent, isMain) {
10101016
setOwnProperty(process, 'mainModule', module);
10111017
setOwnProperty(module.require, 'main', process.mainModule);
10121018
module.id = '.';
1019+
module[is_main_symbol] = true;
1020+
} else {
1021+
module[is_main_symbol] = false;
10131022
}
10141023

10151024
reportModuleToWatchMode(filename);
@@ -1270,57 +1279,96 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
12701279
);
12711280

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

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

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-
}
1295+
const result = compileFunctionForCJSLoader(content, filename);
12901296

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

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;
1304+
// Cache the source map for the module if present.
1305+
if (result.sourceMapURL) {
1306+
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
1307+
}
1308+
1309+
return result;
1310+
}
1311+
1312+
// Resolve and evaluate as ESM, synchronously.
1313+
function loadESMFromCJS(mod, filename) {
1314+
const source = getMaybeCachedSource(mod, filename);
1315+
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
1316+
// We are still using the CJS's resolution here.
1317+
const url = pathToFileURL(filename).href;
1318+
const isMain = mod[is_main_symbol];
1319+
// TODO(joyeecheung): maybe we can do some special handling for default here. Maybe we don't.
1320+
mod.exports = cascadedLoader.importSyncForRequire(url, source, isMain);
1321+
}
1322+
1323+
/**
1324+
* Create a require function for this module, apply policy if necessary.
1325+
* @param {Module} module
1326+
* @param {string} moduleURL
1327+
* @returns {Function}
1328+
*/
1329+
function makeRequireWithPolicy(module, moduleURL) {
1330+
const manifest = policy()?.manifest;
1331+
let redirects;
1332+
if (manifest) {
1333+
redirects = manifest.getDependencyMapper(moduleURL);
13031334
}
1335+
return makeRequireFunction(module, redirects);
13041336
}
13051337

13061338
/**
13071339
* Run the file contents in the correct scope or sandbox. Expose the correct helper variables (`require`, `module`,
13081340
* `exports`) to the file. Returns exception, if any.
13091341
* @param {string} content The source code of the module
13101342
* @param {string} filename The file path of the module
1343+
* @param {boolean} loadAsESM Whether it's known to be ESM - i.e. suffix is .mjs.
13111344
*/
1312-
Module.prototype._compile = function(content, filename) {
1345+
Module.prototype._compile = function(content, filename, loadAsESM = false) {
13131346
let moduleURL;
1314-
let redirects;
13151347
const manifest = policy()?.manifest;
13161348
if (manifest) {
13171349
moduleURL = pathToFileURL(filename);
1318-
redirects = manifest.getDependencyMapper(moduleURL);
13191350
manifest.assertIntegrity(moduleURL, content);
13201351
}
13211352

1322-
const compiledWrapper = wrapSafe(filename, content, this);
1353+
// TODO(joyeecheung): when the module is the entry point, consider allowing TLA.
1354+
// Only modules being require()'d really need to avoid TLA.
1355+
let compiledWrapper;
1356+
if (!loadAsESM) {
1357+
const result = wrapSafe(filename, content, this);
1358+
compiledWrapper = result.function;
1359+
loadAsESM = result.retryAsESM;
1360+
}
13231361

1362+
if (loadAsESM) {
1363+
// Pass the source into the .mjs extension handler indirectly through the cache.
1364+
cjsSourceCache.set(this, content);
1365+
loadESMFromCJS(this, filename);
1366+
return;
1367+
}
1368+
1369+
// TODO(joyeecheung): the detection below is unnecessarily complex. Maybe just
1370+
// use the is_main_symbol, or a break_on_start_symbol that gets passed from
1371+
// higher level instead of doing hacky detecion here.
13241372
let inspectorWrapper = null;
13251373
if (getOptionValue('--inspect-brk') && process._eval == null) {
13261374
if (!resolvedArgv) {
@@ -1344,8 +1392,9 @@ Module.prototype._compile = function(content, filename) {
13441392
inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
13451393
}
13461394
}
1395+
13471396
const dirname = path.dirname(filename);
1348-
const require = makeRequireFunction(this, redirects);
1397+
const require = makeRequireWithPolicy(this, moduleURL);
13491398
let result;
13501399
const exports = this.exports;
13511400
const thisValue = exports;
@@ -1363,25 +1412,37 @@ Module.prototype._compile = function(content, filename) {
13631412
return result;
13641413
};
13651414

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);
1415+
function getMaybeCachedSource(mod, filename) {
1416+
const cached = cjsSourceCache.get(mod);
13741417
let content;
13751418
if (cached?.source) {
13761419
content = cached.source;
13771420
cached.source = undefined;
13781421
} else {
1422+
// TODO(joyeecheung): read a buffer.
13791423
content = fs.readFileSync(filename, 'utf8');
13801424
}
1425+
return content;
1426+
}
1427+
1428+
/**
1429+
* Native handler for `.js` files.
1430+
* @param {Module} module The module to compile
1431+
* @param {string} filename The file path of the module
1432+
*/
1433+
Module._extensions['.js'] = function(module, filename) {
1434+
// If already analyzed the source, then it will be cached.
1435+
const content = getMaybeCachedSource(module, filename);
1436+
13811437
if (StringPrototypeEndsWith(filename, '.js')) {
13821438
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
13831439
// Function require shouldn't be used in ES modules.
13841440
if (pkg?.data.type === 'module') {
1441+
if (getOptionValue('--experimental-require-module')) {
1442+
module._compile(content, filename, true);
1443+
return;
1444+
}
1445+
13851446
// This is an error path because `require` of a `.js` file in a `"type": "module"` scope is not allowed.
13861447
const parent = moduleParentCache.get(module);
13871448
const parentPath = parent?.filename;
@@ -1414,7 +1475,8 @@ Module._extensions['.js'] = function(module, filename) {
14141475
throw err;
14151476
}
14161477
}
1417-
module._compile(content, filename);
1478+
1479+
module._compile(content, filename, false);
14181480
};
14191481

14201482
/**

0 commit comments

Comments
 (0)