Skip to content

Commit 4d59a9d

Browse files
authored
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 Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent d2ebaaa commit 4d59a9d

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
@@ -180,9 +180,12 @@ regarding which files are parsed as ECMAScript modules.
180180
If `--experimental-require-module` is enabled, and the ECMAScript module being
181181
loaded by `require()` meets the following requirements:
182182

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

187190
`require()` will load the requested module as an ES Module, and return
188191
the module name space object. In this case it is similar to dynamic
@@ -249,18 +252,27 @@ require(X) from module at path Y
249252
6. LOAD_NODE_MODULES(X, dirname(Y))
250253
7. THROW "not found"
251254

255+
MAYBE_DETECT_AND_LOAD(X)
256+
1. If X parses as a CommonJS module, load X as a CommonJS module. STOP.
257+
2. Else, if `--experimental-require-module` and `--experimental-detect-module` are
258+
enabled, and the source code of X can be parsed as ECMAScript module using
259+
<a href="esm.md#resolver-algorithm-specification">DETECT_MODULE_SYNTAX defined in
260+
the ESM resolver</a>,
261+
a. Load X as an ECMAScript module. STOP.
262+
3. THROW the SyntaxError from attempting to parse X as CommonJS in 1. STOP.
263+
252264
LOAD_AS_FILE(X)
253265
1. If X is a file, load X as its file extension format. STOP
254266
2. If X.js is a file,
255267
a. Find the closest package scope SCOPE to X.
256-
b. If no scope was found, load X.js as a CommonJS module. STOP.
268+
b. If no scope was found
269+
1. MAYBE_DETECT_AND_LOAD(X.js)
257270
c. If the SCOPE/package.json contains "type" field,
258271
1. If the "type" field is "module", load X.js as an ECMAScript module. STOP.
259-
2. Else, load X.js as an CommonJS module. STOP.
272+
2. If the "type" field is "commonjs", load X.js as an CommonJS module. STOP.
273+
d. MAYBE_DETECT_AND_LOAD(X.js)
260274
3. If X.json is a file, load X.json to a JavaScript Object. STOP
261275
4. If X.node is a file, load X.node as binary addon. STOP
262-
5. If X.mjs is a file, and `--experimental-require-module` is enabled,
263-
load X.mjs as an ECMAScript module. STOP
264276

265277
LOAD_INDEX(X)
266278
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,
@@ -1332,9 +1331,18 @@ function loadESMFromCJS(mod, filename) {
13321331
const source = getMaybeCachedSource(mod, filename);
13331332
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
13341333
const isMain = mod[kIsMainSymbol];
1335-
// TODO(joyeecheung): we may want to invent optional special handling for default exports here.
1336-
// For now, it's good enough to be identical to what `import()` returns.
1337-
mod.exports = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
1334+
if (isMain) {
1335+
require('internal/modules/run_main').runEntryPointWithESMLoader((cascadedLoader) => {
1336+
const mainURL = pathToFileURL(filename).href;
1337+
cascadedLoader.import(mainURL, undefined, { __proto__: null }, true);
1338+
});
1339+
// ESM won't be accessible via process.mainModule.
1340+
setOwnProperty(process, 'mainModule', undefined);
1341+
} else {
1342+
// TODO(joyeecheung): we may want to invent optional special handling for default exports here.
1343+
// For now, it's good enough to be identical to what `import()` returns.
1344+
mod.exports = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
1345+
}
13381346
}
13391347

13401348
/**
@@ -1343,8 +1351,10 @@ function loadESMFromCJS(mod, filename) {
13431351
* @param {string} content The content of the file being loaded
13441352
* @param {Module} cjsModuleInstance The CommonJS loader instance
13451353
* @param {object} codeCache The SEA code cache
1354+
* @param {'commonjs'|undefined} format Intended format of the module.
13461355
*/
1347-
function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
1356+
function wrapSafe(filename, content, cjsModuleInstance, codeCache, format) {
1357+
assert(format !== 'module'); // ESM should be handled in loadESMFromCJS().
13481358
const hostDefinedOptionId = vm_dynamic_import_default_internal;
13491359
const importModuleDynamically = vm_dynamic_import_default_internal;
13501360
if (patched) {
@@ -1374,46 +1384,33 @@ function wrapSafe(filename, content, cjsModuleInstance, codeCache) {
13741384
};
13751385
}
13761386

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

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

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

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

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

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

1517+
let format;
15131518
if (StringPrototypeEndsWith(filename, '.js')) {
15141519
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
15151520
// Function require shouldn't be used in ES modules.
15161521
if (pkg?.data.type === 'module') {
15171522
if (getOptionValue('--experimental-require-module')) {
1518-
module._compile(content, filename, true);
1523+
module._compile(content, filename, 'module');
15191524
return;
15201525
}
15211526

@@ -1549,10 +1554,14 @@ Module._extensions['.js'] = function(module, filename) {
15491554
}
15501555
}
15511556
throw err;
1557+
} else if (pkg?.data.type === 'commonjs') {
1558+
format = 'commonjs';
15521559
}
1560+
} else if (StringPrototypeEndsWith(filename, '.cjs')) {
1561+
format = 'commonjs';
15531562
}
15541563

1555-
module._compile(content, filename, false);
1564+
module._compile(content, filename, format);
15561565
};
15571566

15581567
/**

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
ObjectKeys,
98
ObjectPrototypeHasOwnProperty,
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
globalThis,
86
} = primordials;
97

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

0 commit comments

Comments
 (0)