Skip to content

Commit 612b870

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 ff4fb7e commit 612b870

15 files changed

+431
-134
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ 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

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/modules/cjs/loader.js

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const {
6060
StringPrototypeSlice,
6161
StringPrototypeSplit,
6262
StringPrototypeStartsWith,
63+
Symbol,
6364
} = primordials;
6465

6566
// Map used to store CJS parsing data.
@@ -107,7 +108,6 @@ const { safeGetenv } = internalBinding('credentials');
107108
const {
108109
privateSymbols: {
109110
require_private_symbol,
110-
host_defined_option_symbol,
111111
},
112112
} = internalBinding('util');
113113
const {
@@ -161,6 +161,8 @@ let requireDepth = 0;
161161
let isPreloading = false;
162162
let statCache = null;
163163

164+
const is_main_symbol = Symbol('is-main-module');
165+
164166
/**
165167
* Our internal implementation of `require`.
166168
* @param {Module} module Parent module of what is being required
@@ -271,6 +273,7 @@ function Module(id = '', parent) {
271273
setOwnProperty(this.__proto__, 'require', makeRequireFunction(this, redirects));
272274
}
273275
this[require_private_symbol] = internalRequire;
276+
this[is_main_symbol] = false; // Set to true by the entry point handler.
274277
}
275278

276279
/** @type {Record<string, Module>} */
@@ -396,6 +399,10 @@ function initializeCJS() {
396399
// TODO(joyeecheung): deprecate this in favor of a proper hook?
397400
Module.runMain =
398401
require('internal/modules/run_main').executeUserEntryPoint;
402+
403+
if (getOptionValue('--experimental-require-module')) {
404+
Module._extensions['.mjs'] = loadESMFromCJS;
405+
}
399406
}
400407

401408
// Given a module name, and a list of paths to test, returns the first
@@ -1010,6 +1017,7 @@ Module._load = function(request, parent, isMain) {
10101017
setOwnProperty(process, 'mainModule', module);
10111018
setOwnProperty(module.require, 'main', process.mainModule);
10121019
module.id = '.';
1020+
module[is_main_symbol] = true;
10131021
}
10141022

10151023
reportModuleToWatchMode(filename);
@@ -1270,46 +1278,58 @@ 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 cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
1314+
// Note that we are still using the CJS's path resolution here.
1315+
const parent = moduleParentCache.get(mod)?.filename;
1316+
const base = parent ? pathToFileURL(parent) : parent;
1317+
// console.log('loadESMFromCJS', mod, filename, base);
1318+
const specifier = mod[is_main_symbol] ? pathToFileURL(mod.filename) : mod.id;
1319+
const job = cascadedLoader.getModuleJobSync(specifier, base, kEmptyObject, 'from-cjs-error');
1320+
const { namespace } = job.runSync();
1321+
// TODO(joyeecheung): maybe we can do some special handling for default here. Maybe we don't.
1322+
mod.exports = namespace;
13041323
}
13051324

13061325
/**
13071326
* Run the file contents in the correct scope or sandbox. Expose the correct helper variables (`require`, `module`,
13081327
* `exports`) to the file. Returns exception, if any.
13091328
* @param {string} content The source code of the module
13101329
* @param {string} filename The file path of the module
1330+
* @param {boolean} loadAsESM Whether it's known to be ESM - i.e. suffix is .mjs.
13111331
*/
1312-
Module.prototype._compile = function(content, filename) {
1332+
Module.prototype._compile = function(content, filename, loadAsESM = false) {
13131333
let moduleURL;
13141334
let redirects;
13151335
const manifest = policy()?.manifest;
@@ -1319,8 +1339,21 @@ Module.prototype._compile = function(content, filename) {
13191339
manifest.assertIntegrity(moduleURL, content);
13201340
}
13211341

1322-
const compiledWrapper = wrapSafe(filename, content, this);
1342+
let compiledWrapper;
1343+
if (!loadAsESM) {
1344+
const result = wrapSafe(filename, content, this);
1345+
compiledWrapper = result.function;
1346+
loadAsESM = result.retryAsESM;
1347+
}
1348+
1349+
if (loadAsESM) {
1350+
loadESMFromCJS(this);
1351+
return;
1352+
}
13231353

1354+
// TODO(joyeecheung): the detection below is unnecessarily complex. Maybe just
1355+
// use the is_main_symbol, or a break_on_start_symbol that gets passed from
1356+
// higher level instead of doing hacky detecion here.
13241357
let inspectorWrapper = null;
13251358
if (getOptionValue('--inspect-brk') && process._eval == null) {
13261359
if (!resolvedArgv) {
@@ -1344,6 +1377,7 @@ Module.prototype._compile = function(content, filename) {
13441377
inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
13451378
}
13461379
}
1380+
13471381
const dirname = path.dirname(filename);
13481382
const require = makeRequireFunction(this, redirects);
13491383
let result;
@@ -1370,6 +1404,7 @@ Module.prototype._compile = function(content, filename) {
13701404
*/
13711405
Module._extensions['.js'] = function(module, filename) {
13721406
// If already analyzed the source, then it will be cached.
1407+
// TODO(joyeecheung): pass as buffer.
13731408
const cached = cjsParseCache.get(module);
13741409
let content;
13751410
if (cached?.source) {
@@ -1378,7 +1413,8 @@ Module._extensions['.js'] = function(module, filename) {
13781413
} else {
13791414
content = fs.readFileSync(filename, 'utf8');
13801415
}
1381-
if (StringPrototypeEndsWith(filename, '.js')) {
1416+
if (!getOptionValue('--experimental-require-module') &&
1417+
StringPrototypeEndsWith(filename, '.js')) {
13821418
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
13831419
// Function require shouldn't be used in ES modules.
13841420
if (pkg?.data.type === 'module') {
@@ -1414,7 +1450,8 @@ Module._extensions['.js'] = function(module, filename) {
14141450
throw err;
14151451
}
14161452
}
1417-
module._compile(content, filename);
1453+
1454+
module._compile(content, filename, false);
14181455
};
14191456

14201457
/**

lib/internal/modules/esm/loader.js

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
hardenRegExp,
1616
} = primordials;
1717

18+
const assert = require('internal/assert');
1819
const {
1920
ERR_REQUIRE_ESM,
2021
ERR_UNKNOWN_MODULE_FORMAT,
@@ -228,12 +229,12 @@ class ModuleLoader {
228229
return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes);
229230
}
230231

231-
getModuleJobSync(specifier, parentURL, importAttributes) {
232-
const resolveResult = this.resolveSync(specifier, parentURL, importAttributes);
233-
return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes, true);
232+
getModuleJobSync(specifier, parentURL, importAttributes, requireESMHint) {
233+
const resolveResult = this.resolveSync(specifier, parentURL, importAttributes, requireESMHint);
234+
return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes, true, requireESMHint);
234235
}
235236

236-
getJobFromResolveResult(resolveResult, parentURL, importAttributes, sync) {
237+
getJobFromResolveResult(resolveResult, parentURL, importAttributes, sync, requireESMHint) {
237238
const { url, format } = resolveResult;
238239
const resolvedImportAttributes = resolveResult.importAttributes ?? importAttributes;
239240
let job = this.loadCache.get(url, resolvedImportAttributes.type);
@@ -244,7 +245,7 @@ class ModuleLoader {
244245
}
245246

246247
if (job === undefined) {
247-
job = this.#createModuleJob(url, resolvedImportAttributes, parentURL, format, sync);
248+
job = this.#createModuleJob(url, resolvedImportAttributes, parentURL, format, sync, requireESMHint);
248249
}
249250

250251
return job;
@@ -261,7 +262,7 @@ class ModuleLoader {
261262
* `resolve` hook
262263
* @returns {Promise<ModuleJob>} The (possibly pending) module job
263264
*/
264-
#createModuleJob(url, importAttributes, parentURL, format, sync) {
265+
#createModuleJob(url, importAttributes, parentURL, format, sync, requireESMHint) {
265266
const callTranslator = ({ format: finalFormat, responseURL, source }, isMain) => {
266267
const translator = getTranslators().get(finalFormat);
267268

@@ -274,7 +275,7 @@ class ModuleLoader {
274275
const context = { format, importAttributes };
275276

276277
const moduleProvider = sync ?
277-
(url, isMain) => callTranslator(this.loadSync(url, context), isMain) :
278+
(url, isMain) => callTranslator(this.loadSync(url, context, requireESMHint), isMain) :
278279
async (url, isMain) => callTranslator(await this.load(url, context), isMain);
279280

280281
const inspectBrk = (
@@ -358,26 +359,30 @@ class ModuleLoader {
358359
* Just like `resolve` except synchronous. This is here specifically to support
359360
* `import.meta.resolve` which must happen synchronously.
360361
*/
361-
resolveSync(originalSpecifier, parentURL, importAttributes) {
362-
if (this.#customizations) {
362+
resolveSync(originalSpecifier, parentURL, importAttributes, requireESMHint) {
363+
// If this comes from the require(esm) fallback, don't apply loader hooks which are on
364+
// a separate thread. This is ignored by require(cjs) already anyway.
365+
// TODO(joyeecheung): add support in hooks for this?
366+
if (this.#customizations && !requireESMHint) {
363367
return this.#customizations.resolveSync(originalSpecifier, parentURL, importAttributes);
364368
}
365-
return this.defaultResolve(originalSpecifier, parentURL, importAttributes);
369+
return this.defaultResolve(originalSpecifier, parentURL, importAttributes, requireESMHint);
366370
}
367371

368372
/**
369373
* Our `defaultResolve` is synchronous and can be used in both
370374
* `resolve` and `resolveSync`. This function is here just to avoid
371375
* repeating the same code block twice in those functions.
372376
*/
373-
defaultResolve(originalSpecifier, parentURL, importAttributes) {
377+
defaultResolve(originalSpecifier, parentURL, importAttributes, requireESMHint) {
374378
defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;
375379

376380
const context = {
377381
__proto__: null,
378382
conditions: this.#defaultConditions,
379383
importAttributes,
380384
parentURL,
385+
requireESMHint,
381386
};
382387

383388
return defaultResolve(originalSpecifier, context);
@@ -398,14 +403,23 @@ class ModuleLoader {
398403
return result;
399404
}
400405

401-
loadSync(url, context) {
406+
loadSync(url, context, requireESMHint) {
402407
defaultLoadSync ??= require('internal/modules/esm/load').defaultLoadSync;
403-
404-
let result = this.#customizations ?
408+
const isRequireModuleAllowed = getOptionValue('--experimental-require-module');
409+
if (requireESMHint === 'from-cjs-error') {
410+
assert(isRequireModuleAllowed);
411+
context.format = 'module';
412+
}
413+
// If this comes from the require(esm) fallback, don't apply loader hooks which are on
414+
// a separate thread. This is ignored by require(cjs) already anyway.
415+
// TODO(joyeecheung): add support in hooks for this?
416+
let result = this.#customizations && !requireESMHint ?
405417
this.#customizations.loadSync(url, context) :
406418
defaultLoadSync(url, context);
419+
420+
// TODO(joyeecheung): we need a better way to detect the format and cache the result.
407421
let format = result?.format;
408-
if (format === 'module') {
422+
if (format === 'module' && !isRequireModuleAllowed) {
409423
throw new ERR_REQUIRE_ESM(url, true);
410424
}
411425
if (format === 'commonjs') {

0 commit comments

Comments
 (0)