Skip to content

Commit a03faf2

Browse files
joyeecheungmarco-ippolito
authored andcommitted
module: support ESM detection in the CJS loader
This patch: 1. Adds ESM syntax detection to compileFunctionForCJSLoader() for --experimental-detect-module and allow it to emit the warning for how to load ESM when it's used to parse ESM as CJS but detection is not enabled. 2. Moves the ESM detection of --experimental-detect-module for the entrypoint from executeUserEntryPoint() into Module.prototype._compile() and handle it directly in the CJS loader so that the errors thrown during compilation *and execution* during the loading of the entrypoint does not need to be bubbled all the way up. If the entrypoint doesn't parse as CJS, and detection is enabled, the CJS loader will re-load the entrypoint as ESM on the spot asynchronously using runEntryPointWithESMLoader() and cascadedLoader.import(). This is fine for the entrypoint because unlike require(ESM) we don't the namespace of the entrypoint synchronously, and can just ignore the returned value. In this case process.mainModule is reset to undefined as they are not available for ESM entrypoints. 3. Supports --experimental-detect-module for require(esm). PR-URL: #52047 Backport-PR-URL: #56927 Reviewed-By: Geoffrey Booth <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]> Refs: #52697
1 parent f13589f commit a03faf2

12 files changed

+206
-129
lines changed

.eslintrc.js

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ module.exports = {
5858
'test/es-module/test-esm-example-loader.js',
5959
'test/es-module/test-esm-type-flag.js',
6060
'test/es-module/test-esm-type-flag-alias.js',
61+
'test/es-module/test-require-module-detect-entry-point.js',
62+
'test/es-module/test-require-module-detect-entry-point-aou.js',
6163
],
6264
parserOptions: { sourceType: 'module' },
6365
},

doc/api/modules.md

+19-7
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,12 @@ regarding which files are parsed as ECMAScript modules.
187187
If `--experimental-require-module` is enabled, and the ECMAScript module being
188188
loaded by `require()` meets the following requirements:
189189

190-
* Explicitly marked as an ES module with a `"type": "module"` field in
191-
the closest package.json or a `.mjs` extension.
192-
* Fully synchronous (contains no top-level `await`).
190+
* The module is fully synchronous (contains no top-level `await`); and
191+
* One of these conditions are met:
192+
1. The file has a `.mjs` extension.
193+
2. The file has a `.js` extension, and the closest `package.json` contains `"type": "module"`
194+
3. The file has a `.js` extension, the closest `package.json` does not contain
195+
`"type": "commonjs"`, and `--experimental-detect-module` is enabled.
193196

194197
`require()` will load the requested module as an ES Module, and return
195198
the module name space object. In this case it is similar to dynamic
@@ -256,18 +259,27 @@ require(X) from module at path Y
256259
6. LOAD_NODE_MODULES(X, dirname(Y))
257260
7. THROW "not found"
258261
262+
MAYBE_DETECT_AND_LOAD(X)
263+
1. If X parses as a CommonJS module, load X as a CommonJS module. STOP.
264+
2. Else, if `--experimental-require-module` and `--experimental-detect-module` are
265+
enabled, and the source code of X can be parsed as ECMAScript module using
266+
<a href="esm.md#resolver-algorithm-specification">DETECT_MODULE_SYNTAX defined in
267+
the ESM resolver</a>,
268+
a. Load X as an ECMAScript module. STOP.
269+
3. THROW the SyntaxError from attempting to parse X as CommonJS in 1. STOP.
270+
259271
LOAD_AS_FILE(X)
260272
1. If X is a file, load X as its file extension format. STOP
261273
2. If X.js is a file,
262274
a. Find the closest package scope SCOPE to X.
263-
b. If no scope was found, load X.js as a CommonJS module. STOP.
275+
b. If no scope was found
276+
1. MAYBE_DETECT_AND_LOAD(X.js)
264277
c. If the SCOPE/package.json contains "type" field,
265278
1. If the "type" field is "module", load X.js as an ECMAScript module. STOP.
266-
2. Else, load X.js as an CommonJS module. STOP.
279+
2. If the "type" field is "commonjs", load X.js as an CommonJS module. STOP.
280+
d. MAYBE_DETECT_AND_LOAD(X.js)
267281
3. If X.json is a file, load X.json to a JavaScript Object. STOP
268282
4. If X.node is a file, load X.node as binary addon. STOP
269-
5. If X.mjs is a file, and `--experimental-require-module` is enabled,
270-
load X.mjs as an ECMAScript module. STOP
271283
272284
LOAD_INDEX(X)
273285
1. If X/index.js is a file

lib/internal/modules/cjs/loader.js

+48-39
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ module.exports = {
106106
kModuleExportNames,
107107
kModuleCircularVisited,
108108
initializeCJS,
109-
entryPointSource: undefined, // Set below.
110109
Module,
111110
wrapSafe,
112111
kIsMainSymbol,
@@ -1333,9 +1332,18 @@ function loadESMFromCJS(mod, filename) {
13331332
const source = getMaybeCachedSource(mod, filename);
13341333
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
13351334
const isMain = mod[kIsMainSymbol];
1336-
// TODO(joyeecheung): we may want to invent optional special handling for default exports here.
1337-
// For now, it's good enough to be identical to what `import()` returns.
1338-
mod.exports = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
1335+
if (isMain) {
1336+
require('internal/modules/run_main').runEntryPointWithESMLoader((cascadedLoader) => {
1337+
const mainURL = pathToFileURL(filename).href;
1338+
cascadedLoader.import(mainURL, undefined, { __proto__: null }, true);
1339+
});
1340+
// ESM won't be accessible via process.mainModule.
1341+
setOwnProperty(process, 'mainModule', undefined);
1342+
} else {
1343+
// TODO(joyeecheung): we may want to invent optional special handling for default exports here.
1344+
// For now, it's good enough to be identical to what `import()` returns.
1345+
mod.exports = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
1346+
}
13391347
}
13401348

13411349
/**
@@ -1344,8 +1352,10 @@ function loadESMFromCJS(mod, filename) {
13441352
* @param {string} content The content of the file being loaded
13451353
* @param {Module} cjsModuleInstance The CommonJS loader instance
13461354
* @param {object} codeCache The SEA code cache
1355+
* @param {'commonjs'|undefined} format Intended format of the module.
13471356
*/
1348-
function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
1357+
function wrapSafe(filename, content, cjsModuleInstance, codeCache, format) {
1358+
assert(format !== 'module'); // ESM should be handled in loadESMFromCJS().
13491359
const hostDefinedOptionId = vm_dynamic_import_default_internal;
13501360
const importModuleDynamically = vm_dynamic_import_default_internal;
13511361
if (patched) {
@@ -1375,46 +1385,33 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
13751385
};
13761386
}
13771387

1378-
try {
1379-
const result = compileFunctionForCJSLoader(content, filename);
1380-
1381-
// cachedDataRejected is only set for cache coming from SEA.
1382-
if (codeCache &&
1383-
result.cachedDataRejected !== false &&
1384-
internalBinding('sea').isSea()) {
1385-
process.emitWarning('Code cache data rejected.');
1386-
}
1388+
const isMain = !!(cjsModuleInstance && cjsModuleInstance[kIsMainSymbol]);
1389+
const shouldDetectModule = (format !== 'commonjs' && getOptionValue('--experimental-detect-module'));
1390+
const result = compileFunctionForCJSLoader(content, filename, isMain, shouldDetectModule);
13871391

1388-
// Cache the source map for the module if present.
1389-
if (result.sourceMapURL) {
1390-
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
1391-
}
1392+
// cachedDataRejected is only set for cache coming from SEA.
1393+
if (codeCache &&
1394+
result.cachedDataRejected !== false &&
1395+
internalBinding('sea').isSea()) {
1396+
process.emitWarning('Code cache data rejected.');
1397+
}
13921398

1393-
return result;
1394-
} catch (err) {
1395-
if (process.mainModule === cjsModuleInstance) {
1396-
if (getOptionValue('--experimental-detect-module')) {
1397-
// For the main entry point, cache the source to potentially retry as ESM.
1398-
module.exports.entryPointSource = content;
1399-
} else {
1400-
// We only enrich the error (print a warning) if we're sure we're going to for-sure throw it; so if we're
1401-
// retrying as ESM, wait until we know whether we're going to retry before calling `enrichCJSError`.
1402-
const { enrichCJSError } = require('internal/modules/esm/translators');
1403-
enrichCJSError(err, content, filename);
1404-
}
1405-
}
1406-
throw err;
1399+
// Cache the source map for the module if present.
1400+
if (result.sourceMapURL) {
1401+
maybeCacheSourceMap(filename, content, this, false, undefined, result.sourceMapURL);
14071402
}
1403+
1404+
return result;
14081405
}
14091406

14101407
/**
14111408
* Run the file contents in the correct scope or sandbox. Expose the correct helper variables (`require`, `module`,
14121409
* `exports`) to the file. Returns exception, if any.
14131410
* @param {string} content The source code of the module
14141411
* @param {string} filename The file path of the module
1415-
* @param {boolean} loadAsESM Whether it's known to be ESM via .mjs or "type" in package.json.
1412+
* @param {'module'|'commonjs'|undefined} format Intended format of the module.
14161413
*/
1417-
Module.prototype._compile = function(content, filename, loadAsESM = false) {
1414+
Module.prototype._compile = function(content, filename, format) {
14181415
let moduleURL;
14191416
let redirects;
14201417
const manifest = policy()?.manifest;
@@ -1424,17 +1421,24 @@ Module.prototype._compile = function(content, filename, loadAsESM = false) {
14241421
manifest.assertIntegrity(moduleURL, content);
14251422
}
14261423

1424+
let compiledWrapper;
1425+
if (format !== 'module') {
1426+
const result = wrapSafe(filename, content, this, undefined, format);
1427+
compiledWrapper = result.function;
1428+
if (result.canParseAsESM) {
1429+
format = 'module';
1430+
}
1431+
}
1432+
14271433
// TODO(joyeecheung): when the module is the entry point, consider allowing TLA.
14281434
// Only modules being require()'d really need to avoid TLA.
1429-
if (loadAsESM) {
1435+
if (format === 'module') {
14301436
// Pass the source into the .mjs extension handler indirectly through the cache.
14311437
this[kModuleSource] = content;
14321438
loadESMFromCJS(this, filename);
14331439
return;
14341440
}
14351441

1436-
const { function: compiledWrapper } = wrapSafe(filename, content, this);
1437-
14381442
// TODO(joyeecheung): the detection below is unnecessarily complex. Using the
14391443
// kIsMainSymbol, or a kBreakOnStartSymbol that gets passed from
14401444
// higher level instead of doing hacky detection here.
@@ -1511,12 +1515,13 @@ Module._extensions['.js'] = function(module, filename) {
15111515
// If already analyzed the source, then it will be cached.
15121516
const content = getMaybeCachedSource(module, filename);
15131517

1518+
let format;
15141519
if (StringPrototypeEndsWith(filename, '.js')) {
15151520
const pkg = packageJsonReader.readPackageScope(filename) || { __proto__: null };
15161521
// Function require shouldn't be used in ES modules.
15171522
if (pkg.data?.type === 'module') {
15181523
if (getOptionValue('--experimental-require-module')) {
1519-
module._compile(content, filename, true);
1524+
module._compile(content, filename, 'module');
15201525
return;
15211526
}
15221527

@@ -1550,10 +1555,14 @@ Module._extensions['.js'] = function(module, filename) {
15501555
}
15511556
}
15521557
throw err;
1558+
} else if (pkg.data?.type === 'commonjs') {
1559+
format = 'commonjs';
15531560
}
1561+
} else if (StringPrototypeEndsWith(filename, '.cjs')) {
1562+
format = 'commonjs';
15541563
}
15551564

1556-
module._compile(content, filename, false);
1565+
module._compile(content, filename, format);
15571566
};
15581567

15591568
/**

lib/internal/modules/esm/translators.js

+11-43
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const {
44
ArrayPrototypeMap,
55
Boolean,
66
JSONParse,
7-
ObjectGetPrototypeOf,
87
ObjectPrototypeHasOwnProperty,
98
ObjectKeys,
109
ReflectApply,
@@ -15,7 +14,6 @@ const {
1514
StringPrototypeReplaceAll,
1615
StringPrototypeSlice,
1716
StringPrototypeStartsWith,
18-
SyntaxErrorPrototype,
1917
globalThis: { WebAssembly },
2018
} = primordials;
2119

@@ -30,7 +28,6 @@ function lazyTypes() {
3028
}
3129

3230
const {
33-
containsModuleSyntax,
3431
compileFunctionForCJSLoader,
3532
} = internalBinding('contextify');
3633

@@ -62,7 +59,6 @@ const {
6259
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
6360
const moduleWrap = internalBinding('module_wrap');
6461
const { ModuleWrap } = moduleWrap;
65-
const { emitWarningSync } = require('internal/process/warning');
6662

6763
// Lazy-loading to avoid circular dependencies.
6864
let getSourceSync;
@@ -107,7 +103,6 @@ function initCJSParseSync() {
107103

108104
const translators = new SafeMap();
109105
exports.translators = translators;
110-
exports.enrichCJSError = enrichCJSError;
111106

112107
let DECODER = null;
113108
/**
@@ -169,25 +164,6 @@ translators.set('module', function moduleStrategy(url, source, isMain) {
169164
return module;
170165
});
171166

172-
/**
173-
* Provide a more informative error for CommonJS imports.
174-
* @param {Error | any} err
175-
* @param {string} [content] Content of the file, if known.
176-
* @param {string} [filename] The filename of the erroring module.
177-
*/
178-
function enrichCJSError(err, content, filename) {
179-
if (err != null && ObjectGetPrototypeOf(err) === SyntaxErrorPrototype &&
180-
containsModuleSyntax(content, filename)) {
181-
// Emit the warning synchronously because we are in the middle of handling
182-
// a SyntaxError that will throw and likely terminate the process before an
183-
// asynchronous warning would be emitted.
184-
emitWarningSync(
185-
'To load an ES module, set "type": "module" in the package.json or use ' +
186-
'the .mjs extension.',
187-
);
188-
}
189-
}
190-
191167
/**
192168
* Loads a CommonJS module via the ESM Loader sync CommonJS translator.
193169
* This translator creates its own version of the `require` function passed into CommonJS modules.
@@ -197,15 +173,11 @@ function enrichCJSError(err, content, filename) {
197173
* @param {string} source - The source code of the module.
198174
* @param {string} url - The URL of the module.
199175
* @param {string} filename - The filename of the module.
176+
* @param {boolean} isMain - Whether the module is the entrypoint
200177
*/
201-
function loadCJSModule(module, source, url, filename) {
202-
let compileResult;
203-
try {
204-
compileResult = compileFunctionForCJSLoader(source, filename);
205-
} catch (err) {
206-
enrichCJSError(err, source, filename);
207-
throw err;
208-
}
178+
function loadCJSModule(module, source, url, filename, isMain) {
179+
const compileResult = compileFunctionForCJSLoader(source, filename, isMain, false);
180+
209181
// Cache the source map for the cjs module if present.
210182
if (compileResult.sourceMapURL) {
211183
maybeCacheSourceMap(url, source, null, false, undefined, compileResult.sourceMapURL);
@@ -283,7 +255,7 @@ function createCJSModuleWrap(url, source, isMain, loadCJS = loadCJSModule) {
283255
debug(`Loading CJSModule ${url}`);
284256

285257
if (!module.loaded) {
286-
loadCJS(module, source, url, filename);
258+
loadCJS(module, source, url, filename, !!isMain);
287259
}
288260

289261
let exports;
@@ -315,9 +287,10 @@ translators.set('commonjs-sync', function requireCommonJS(url, source, isMain) {
315287
initCJSParseSync();
316288
assert(!isMain); // This is only used by imported CJS modules.
317289

318-
return createCJSModuleWrap(url, source, isMain, (module, source, url, filename) => {
290+
return createCJSModuleWrap(url, source, isMain, (module, source, url, filename, isMain) => {
319291
assert(module === CJSModule._cache[filename]);
320-
CJSModule._load(filename);
292+
assert(!isMain);
293+
CJSModule._load(filename, null, isMain);
321294
});
322295
});
323296

@@ -340,14 +313,9 @@ translators.set('commonjs', async function commonjsStrategy(url, source,
340313
// For backward-compatibility, it's possible to return a nullish value for
341314
// CJS source associated with a file: URL. In this case, the source is
342315
// obtained by calling the monkey-patchable CJS loader.
343-
const cjsLoader = source == null ? (module, source, url, filename) => {
344-
try {
345-
assert(module === CJSModule._cache[filename]);
346-
CJSModule._load(filename);
347-
} catch (err) {
348-
enrichCJSError(err, source, filename);
349-
throw err;
350-
}
316+
const cjsLoader = source == null ? (module, source, url, filename, isMain) => {
317+
assert(module === CJSModule._cache[filename]);
318+
CJSModule._load(filename, undefined, isMain);
351319
} : loadCJSModule;
352320

353321
try {

lib/internal/modules/run_main.js

+2-28
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
'use strict';
22

33
const {
4-
ObjectGetPrototypeOf,
54
StringPrototypeEndsWith,
6-
SyntaxErrorPrototype,
75
} = primordials;
86

97
const { getOptionValue } = require('internal/options');
@@ -160,35 +158,11 @@ function executeUserEntryPoint(main = process.argv[1]) {
160158
let mainURL;
161159
// Unless we know we should use the ESM loader to handle the entry point per the checks in `shouldUseESMLoader`, first
162160
// try to run the entry point via the CommonJS loader; and if that fails under certain conditions, retry as ESM.
163-
let retryAsESM = false;
164161
if (!useESMLoader) {
165162
const cjsLoader = require('internal/modules/cjs/loader');
166163
const { Module } = cjsLoader;
167-
if (getOptionValue('--experimental-detect-module')) {
168-
// TODO(joyeecheung): handle this in the CJS loader. Don't try-catch here.
169-
try {
170-
// Module._load is the monkey-patchable CJS module loader.
171-
Module._load(main, null, true);
172-
} catch (error) {
173-
if (error != null && ObjectGetPrototypeOf(error) === SyntaxErrorPrototype) {
174-
const { shouldRetryAsESM } = internalBinding('contextify');
175-
const mainPath = resolvedMain || main;
176-
mainURL = pathToFileURL(mainPath).href;
177-
retryAsESM = shouldRetryAsESM(error.message, cjsLoader.entryPointSource, mainURL);
178-
// In case the entry point is a large file, such as a bundle,
179-
// ensure no further references can prevent it being garbage-collected.
180-
cjsLoader.entryPointSource = undefined;
181-
}
182-
if (!retryAsESM) {
183-
throw error;
184-
}
185-
}
186-
} else { // `--experimental-detect-module` is not passed
187-
Module._load(main, null, true);
188-
}
189-
}
190-
191-
if (useESMLoader || retryAsESM) {
164+
Module._load(main, null, true);
165+
} else {
192166
const mainPath = resolvedMain || main;
193167
if (mainURL === undefined) {
194168
mainURL = pathToFileURL(mainPath).href;

0 commit comments

Comments
 (0)