Skip to content

Commit 63d04d4

Browse files
module: fix detect-module not retrying as esm for cjs-only errors
PR-URL: #52024 Reviewed-By: Guy Bedford <guybedford@gmail.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
1 parent de0602d commit 63d04d4

File tree

4 files changed

+187
-7
lines changed

4 files changed

+187
-7
lines changed

doc/api/cli.md

+10-4
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,7 @@ added:
777777
- v20.10.0
778778
-->
779779

780-
> Stability: 1.0 - Early development
780+
> Stability: 1.1 - Active development
781781
782782
Node.js will inspect the source code of ambiguous input to determine whether it
783783
contains ES module syntax; if such syntax is detected, the input will be treated
@@ -792,9 +792,15 @@ Ambiguous input is defined as:
792792
`--experimental-default-type` are specified.
793793

794794
ES module syntax is defined as syntax that would throw when evaluated as
795-
CommonJS. This includes `import` and `export` statements and `import.meta`
796-
references. It does _not_ include `import()` expressions, which are valid in
797-
CommonJS.
795+
CommonJS. This includes the following:
796+
797+
* `import` statements (but _not_ `import()` expressions, which are valid in
798+
CommonJS).
799+
* `export` statements.
800+
* `import.meta` references.
801+
* `await` at the top level of a module.
802+
* Lexical redeclarations of the CommonJS wrapper variables (`require`, `module`,
803+
`exports`, `__dirname`, `__filename`).
798804

799805
### `--experimental-import-meta-resolve`
800806

src/node_contextify.cc

+78-3
Original file line numberDiff line numberDiff line change
@@ -1409,6 +1409,25 @@ constexpr std::array<std::string_view, 3> esm_syntax_error_messages = {
14091409
"Unexpected token 'export'", // `export` statements
14101410
"Cannot use 'import.meta' outside a module"}; // `import.meta` references
14111411

1412+
// Another class of error messages that we need to check for are syntax errors
1413+
// where the syntax throws when parsed as CommonJS but succeeds when parsed as
1414+
// ESM. So far, the cases we've found are:
1415+
// - CommonJS module variables (`module`, `exports`, `require`, `__filename`,
1416+
// `__dirname`): if the user writes code such as `const module =` in the top
1417+
// level of a CommonJS module, it will throw a syntax error; but the same
1418+
// code is valid in ESM.
1419+
// - Top-level `await`: if the user writes `await` at the top level of a
1420+
// CommonJS module, it will throw a syntax error; but the same code is valid
1421+
// in ESM.
1422+
constexpr std::array<std::string_view, 6> throws_only_in_cjs_error_messages = {
1423+
"Identifier 'module' has already been declared",
1424+
"Identifier 'exports' has already been declared",
1425+
"Identifier 'require' has already been declared",
1426+
"Identifier '__filename' has already been declared",
1427+
"Identifier '__dirname' has already been declared",
1428+
"await is only valid in async functions and "
1429+
"the top level bodies of modules"};
1430+
14121431
void ContextifyContext::ContainsModuleSyntax(
14131432
const FunctionCallbackInfo<Value>& args) {
14141433
Environment* env = Environment::GetCurrent(args);
@@ -1476,19 +1495,75 @@ void ContextifyContext::ContainsModuleSyntax(
14761495
id_symbol,
14771496
try_catch);
14781497

1479-
bool found_error_message_caused_by_module_syntax = false;
1498+
bool should_retry_as_esm = false;
14801499
if (try_catch.HasCaught() && !try_catch.HasTerminated()) {
14811500
Utf8Value message_value(env->isolate(), try_catch.Message()->Get());
14821501
auto message = message_value.ToStringView();
14831502

14841503
for (const auto& error_message : esm_syntax_error_messages) {
14851504
if (message.find(error_message) != std::string_view::npos) {
1486-
found_error_message_caused_by_module_syntax = true;
1505+
should_retry_as_esm = true;
14871506
break;
14881507
}
14891508
}
1509+
1510+
if (!should_retry_as_esm) {
1511+
for (const auto& error_message : throws_only_in_cjs_error_messages) {
1512+
if (message.find(error_message) != std::string_view::npos) {
1513+
// Try parsing again where the CommonJS wrapper is replaced by an
1514+
// async function wrapper. If the new parse succeeds, then the error
1515+
// was caused by either a top-level declaration of one of the CommonJS
1516+
// module variables, or a top-level `await`.
1517+
TryCatchScope second_parse_try_catch(env);
1518+
code =
1519+
String::Concat(isolate,
1520+
String::NewFromUtf8(isolate, "(async function() {")
1521+
.ToLocalChecked(),
1522+
code);
1523+
code = String::Concat(
1524+
isolate,
1525+
code,
1526+
String::NewFromUtf8(isolate, "})();").ToLocalChecked());
1527+
ScriptCompiler::Source wrapped_source = GetCommonJSSourceInstance(
1528+
isolate, code, filename, 0, 0, host_defined_options, nullptr);
1529+
std::ignore = ScriptCompiler::CompileFunction(
1530+
context,
1531+
&wrapped_source,
1532+
params.size(),
1533+
params.data(),
1534+
0,
1535+
nullptr,
1536+
options,
1537+
v8::ScriptCompiler::NoCacheReason::kNoCacheNoReason);
1538+
if (!second_parse_try_catch.HasTerminated()) {
1539+
if (second_parse_try_catch.HasCaught()) {
1540+
// If on the second parse an error is thrown by ESM syntax, then
1541+
// what happened was that the user had top-level `await` or a
1542+
// top-level declaration of one of the CommonJS module variables
1543+
// above the first `import` or `export`.
1544+
Utf8Value second_message_value(
1545+
env->isolate(), second_parse_try_catch.Message()->Get());
1546+
auto second_message = second_message_value.ToStringView();
1547+
for (const auto& error_message : esm_syntax_error_messages) {
1548+
if (second_message.find(error_message) !=
1549+
std::string_view::npos) {
1550+
should_retry_as_esm = true;
1551+
break;
1552+
}
1553+
}
1554+
} else {
1555+
// No errors thrown in the second parse, so most likely the error
1556+
// was caused by a top-level `await` or a top-level declaration of
1557+
// one of the CommonJS module variables.
1558+
should_retry_as_esm = true;
1559+
}
1560+
}
1561+
break;
1562+
}
1563+
}
1564+
}
14901565
}
1491-
args.GetReturnValue().Set(found_error_message_caused_by_module_syntax);
1566+
args.GetReturnValue().Set(should_retry_as_esm);
14921567
}
14931568

14941569
static void CompileFunctionForCJSLoader(

test/es-module/test-esm-detect-ambiguous.mjs

+93
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,99 @@ describe('--experimental-detect-module', { concurrency: true }, () => {
234234
});
235235
}
236236
});
237+
238+
// https://github.com/nodejs/node/issues/50917
239+
describe('syntax that errors in CommonJS but works in ESM', { concurrency: true }, () => {
240+
it('permits top-level `await`', async () => {
241+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
242+
'--experimental-detect-module',
243+
'--eval',
244+
'await Promise.resolve(); console.log("executed");',
245+
]);
246+
247+
strictEqual(stderr, '');
248+
strictEqual(stdout, 'executed\n');
249+
strictEqual(code, 0);
250+
strictEqual(signal, null);
251+
});
252+
253+
it('permits top-level `await` above import/export syntax', async () => {
254+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
255+
'--experimental-detect-module',
256+
'--eval',
257+
'await Promise.resolve(); import "node:os"; console.log("executed");',
258+
]);
259+
260+
strictEqual(stderr, '');
261+
strictEqual(stdout, 'executed\n');
262+
strictEqual(code, 0);
263+
strictEqual(signal, null);
264+
});
265+
266+
it('still throws on `await` in an ordinary sync function', async () => {
267+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
268+
'--experimental-detect-module',
269+
'--eval',
270+
'function fn() { await Promise.resolve(); } fn();',
271+
]);
272+
273+
match(stderr, /SyntaxError: await is only valid in async function/);
274+
strictEqual(stdout, '');
275+
strictEqual(code, 1);
276+
strictEqual(signal, null);
277+
});
278+
279+
it('throws on undefined `require` when top-level `await` triggers ESM parsing', async () => {
280+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
281+
'--experimental-detect-module',
282+
'--eval',
283+
'const fs = require("node:fs"); await Promise.resolve();',
284+
]);
285+
286+
match(stderr, /ReferenceError: require is not defined in ES module scope/);
287+
strictEqual(stdout, '');
288+
strictEqual(code, 1);
289+
strictEqual(signal, null);
290+
});
291+
292+
it('permits declaration of CommonJS module variables', async () => {
293+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
294+
'--experimental-detect-module',
295+
fixtures.path('es-modules/package-without-type/commonjs-wrapper-variables.js'),
296+
]);
297+
298+
strictEqual(stderr, '');
299+
strictEqual(stdout, 'exports require module __filename __dirname\n');
300+
strictEqual(code, 0);
301+
strictEqual(signal, null);
302+
});
303+
304+
it('permits declaration of CommonJS module variables above import/export', async () => {
305+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
306+
'--experimental-detect-module',
307+
'--eval',
308+
'const module = 3; import "node:os"; console.log("executed");',
309+
]);
310+
311+
strictEqual(stderr, '');
312+
strictEqual(stdout, 'executed\n');
313+
strictEqual(code, 0);
314+
strictEqual(signal, null);
315+
});
316+
317+
it('still throws on double `const` declaration not at the top level', async () => {
318+
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
319+
'--experimental-detect-module',
320+
'--eval',
321+
'function fn() { const require = 1; const require = 2; } fn();',
322+
]);
323+
324+
match(stderr, /SyntaxError: Identifier 'require' has already been declared/);
325+
strictEqual(stdout, '');
326+
strictEqual(code, 1);
327+
strictEqual(signal, null);
328+
});
329+
});
237330
});
238331

239332
// Validate temporarily disabling `--abort-on-uncaught-exception`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const exports = "exports";
2+
const require = "require";
3+
const module = "module";
4+
const __filename = "__filename";
5+
const __dirname = "__dirname";
6+
console.log(exports, require, module, __filename, __dirname);

0 commit comments

Comments
 (0)