From e375063e013987302bf3c0e4c054a829e948b13a Mon Sep 17 00:00:00 2001 From: Geoffrey Booth Date: Fri, 20 Oct 2023 08:44:56 -0700 Subject: [PATCH] esm: detect ESM syntax in ambiguous JavaScript PR-URL: https://github.com/nodejs/node/pull/50096 Reviewed-By: Yagiz Nizipli Reviewed-By: Benjamin Gruenbaum Reviewed-By: Guy Bedford --- benchmark/esm/detect-esm-syntax.js | 37 +++ doc/api/cli.md | 27 +++ doc/api/esm.md | 59 +++-- doc/api/modules.md | 6 +- doc/api/packages.md | 8 + lib/internal/modules/esm/get_format.js | 64 ++++-- lib/internal/modules/esm/load.js | 36 +-- lib/internal/modules/run_main.js | 15 +- lib/internal/process/execution.js | 8 + src/node_contextify.cc | 39 +++- src/node_options.cc | 5 + src/node_options.h | 1 + test/es-module/test-esm-detect-ambiguous.mjs | 214 ++++++++++++++++++ .../package-type-commonjs/imports-esm.js | 1 + .../package-type-commonjs/imports-esm.mjs | 1 + .../package-type-commonjs/module.js | 2 + .../package-type-module/imports-commonjs.cjs | 1 + .../package-type-module/imports-commonjs.mjs | 1 + .../package-without-type/commonjs.js | 2 + .../package-without-type/imports-commonjs.cjs | 1 + .../package-without-type/imports-commonjs.mjs | 1 + .../package-without-type/imports-esm.js | 1 + .../package-without-type/imports-esm.mjs | 1 + .../package-without-type/imports-noext-cjs.js | 1 + .../imports-noext-cjs.mjs | 1 + .../package-without-type/imports-noext-esm.js | 1 + .../imports-noext-esm.mjs | 1 + .../es-modules/package-without-type/module.js | 1 - .../es-modules/package-without-type/noext-cjs | 2 + test/sequential/test-watch-mode.mjs | 4 +- 30 files changed, 475 insertions(+), 67 deletions(-) create mode 100644 benchmark/esm/detect-esm-syntax.js create mode 100644 test/es-module/test-esm-detect-ambiguous.mjs create mode 100644 test/fixtures/es-modules/package-type-commonjs/imports-esm.js create mode 100644 test/fixtures/es-modules/package-type-commonjs/imports-esm.mjs create mode 100644 test/fixtures/es-modules/package-type-commonjs/module.js create mode 100644 test/fixtures/es-modules/package-type-module/imports-commonjs.cjs create mode 100644 test/fixtures/es-modules/package-type-module/imports-commonjs.mjs create mode 100644 test/fixtures/es-modules/package-without-type/commonjs.js create mode 100644 test/fixtures/es-modules/package-without-type/imports-commonjs.cjs create mode 100644 test/fixtures/es-modules/package-without-type/imports-commonjs.mjs create mode 100644 test/fixtures/es-modules/package-without-type/imports-esm.js create mode 100644 test/fixtures/es-modules/package-without-type/imports-esm.mjs create mode 100644 test/fixtures/es-modules/package-without-type/imports-noext-cjs.js create mode 100644 test/fixtures/es-modules/package-without-type/imports-noext-cjs.mjs create mode 100644 test/fixtures/es-modules/package-without-type/imports-noext-esm.js create mode 100644 test/fixtures/es-modules/package-without-type/imports-noext-esm.mjs create mode 100644 test/fixtures/es-modules/package-without-type/noext-cjs diff --git a/benchmark/esm/detect-esm-syntax.js b/benchmark/esm/detect-esm-syntax.js new file mode 100644 index 00000000000000..dfc347225f32d6 --- /dev/null +++ b/benchmark/esm/detect-esm-syntax.js @@ -0,0 +1,37 @@ +'use strict'; + +// This benchmarks the cost of running `containsModuleSyntax` on a CommonJS module being imported. +// We use the TypeScript fixture because it's a very large CommonJS file with no ESM syntax: the worst case. +const common = require('../common.js'); +const tmpdir = require('../../test/common/tmpdir.js'); +const fixtures = require('../../test/common/fixtures.js'); +const scriptPath = fixtures.path('snapshot', 'typescript.js'); +const fs = require('node:fs'); + +const bench = common.createBenchmark(main, { + type: ['with-module-syntax-detection', 'without-module-syntax-detection'], + n: [1e4], +}, { + flags: ['--experimental-detect-module'], +}); + +const benchmarkDirectory = tmpdir.fileURL('benchmark-detect-esm-syntax'); +const ambiguousURL = new URL('./typescript.js', benchmarkDirectory); +const explicitURL = new URL('./typescript.cjs', benchmarkDirectory); + +async function main({ n, type }) { + tmpdir.refresh(); + + fs.mkdirSync(benchmarkDirectory, { recursive: true }); + fs.cpSync(scriptPath, ambiguousURL); + fs.cpSync(scriptPath, explicitURL); + + bench.start(); + + for (let i = 0; i < n; i++) { + const url = type === 'with-module-syntax-detection' ? ambiguousURL : explicitURL; + await import(url); + } + + bench.end(n); +} diff --git a/doc/api/cli.md b/doc/api/cli.md index bde6e2f46ffdcb..61f0ba9f053bc9 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -620,6 +620,32 @@ files with no extension will be treated as WebAssembly if they begin with the WebAssembly magic number (`\0asm`); otherwise they will be treated as ES module JavaScript. +### `--experimental-detect-module` + + + +> Stability: 1.0 - Early development + +Node.js will inspect the source code of ambiguous input to determine whether it +contains ES module syntax; if such syntax is detected, the input will be treated +as an ES module. + +Ambiguous input is defined as: + +* Files with a `.js` extension or no extension; and either no controlling + `package.json` file or one that lacks a `type` field; and + `--experimental-default-type` is not specified. +* String input (`--eval` or STDIN) when neither `--input-type` nor + `--experimental-default-type` are specified. + +ES module syntax is defined as syntax that would throw when evaluated as +CommonJS. This includes `import` and `export` statements and `import.meta` +references. It does _not_ include `import()` expressions, which are valid in +CommonJS. + ### `--experimental-import-meta-resolve` @@ -1019,18 +1029,33 @@ _isImports_, _conditions_) > 1. Return _"commonjs"_. > 4. If _url_ ends in _".json"_, then > 1. Return _"json"_. -> 5. Let _packageURL_ be the result of **LOOKUP\_PACKAGE\_SCOPE**(_url_). -> 6. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_). -> 7. If _pjson?.type_ exists and is _"module"_, then -> 1. If _url_ ends in _".js"_ or has no file extension, then -> 1. If `--experimental-wasm-modules` is enabled and the file at _url_ -> contains the header for a WebAssembly module, then -> 1. Return _"wasm"_. -> 2. Otherwise, -> 1. Return _"module"_. -> 2. Return **undefined**. -> 8. Otherwise, -> 1. Return **undefined**. +> 5. If `--experimental-wasm-modules` is enabled and _url_ ends in +> _".wasm"_, then +> 1. Return _"wasm"_. +> 6. Let _packageURL_ be the result of **LOOKUP\_PACKAGE\_SCOPE**(_url_). +> 7. Let _pjson_ be the result of **READ\_PACKAGE\_JSON**(_packageURL_). +> 8. Let _packageType_ be **null**. +> 9. If _pjson?.type_ is _"module"_ or _"commonjs"_, then +> 1. Set _packageType_ to _pjson.type_. +> 10. If _url_ ends in _".js"_, then +> 1. If _packageType_ is not **null**, then +> 1. Return _packageType_. +> 2. If `--experimental-detect-module` is enabled and the source of +> module contains static import or export syntax, then +> 1. Return _"module"_. +> 3. Return _"commonjs"_. +> 11. If _url_ does not have any extension, then +> 1. If _packageType_ is _"module"_ and `--experimental-wasm-modules` is +> enabled and the file at _url_ contains the header for a WebAssembly +> module, then +> 1. Return _"wasm"_. +> 2. If _packageType_ is not **null**, then +> 1. Return _packageType_. +> 3. If `--experimental-detect-module` is enabled and the source of +> module contains static import or export syntax, then +> 1. Return _"module"_. +> 4. Return _"commonjs"_. +> 12. Return **undefined** (will throw during load phase). **LOOKUP\_PACKAGE\_SCOPE**(_url_) diff --git a/doc/api/modules.md b/doc/api/modules.md index 02fae47d88ea69..e38bca4f3cee54 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -80,8 +80,10 @@ By default, Node.js will treat the following as CommonJS modules: * Files with a `.js` extension when the nearest parent `package.json` file contains a top-level field [`"type"`][] with a value of `"commonjs"`. -* Files with a `.js` extension when the nearest parent `package.json` file - doesn't contain a top-level field [`"type"`][]. Package authors should include +* Files with a `.js` extension or without an extension, when the nearest parent + `package.json` file doesn't contain a top-level field [`"type"`][] or there is + no `package.json` in any parent folder; unless the file contains syntax that + errors unless it is evaluated as an ES module. Package authors should include the [`"type"`][] field, even in packages where all sources are CommonJS. Being explicit about the `type` of the package will make things easier for build tools and loaders to determine how the files in the package should be diff --git a/doc/api/packages.md b/doc/api/packages.md index 9f55cbbb15939f..9287ff71c404c9 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -69,6 +69,14 @@ expressions: * Strings passed in as an argument to `--eval`, or piped to `node` via `STDIN`, with the flag `--input-type=module`. +* Code that contains syntax that only parses successfully as [ES modules][], + such as `import` or `export` statements or `import.meta`, when the code has no + explicit marker of how it should be interpreted. Explicit markers are `.mjs` + or `.cjs` extensions, `package.json` `"type"` fields with either `"module"` or + `"commonjs"` values, or `--input-type` or `--experimental-default-type` flags. + Dynamic `import()` expressions are supported in either CommonJS or ES modules + and would not cause a file to be treated as an ES module. + Node.js will treat the following as [CommonJS][] when passed to `node` as the initial input, or when referenced by `import` statements or `import()` expressions: diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 56d002ca0883ad..ee50bea472758d 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -18,9 +18,7 @@ const { const experimentalNetworkImports = getOptionValue('--experimental-network-imports'); -const defaultTypeFlag = getOptionValue('--experimental-default-type'); -// The next line is where we flip the default to ES modules someday. -const defaultType = defaultTypeFlag === 'module' ? 'module' : 'commonjs'; +const { containsModuleSyntax } = internalBinding('contextify'); const { getPackageType } = require('internal/modules/esm/resolve'); const { fileURLToPath } = require('internal/url'); const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes; @@ -85,11 +83,12 @@ function underNodeModules(url) { /** * @param {URL} url - * @param {{parentURL: string}} context + * @param {{parentURL: string; source?: Buffer}} context * @param {boolean} ignoreErrors * @returns {string} */ -function getFileProtocolModuleFormat(url, context, ignoreErrors) { +function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreErrors) { + const { source } = context; const ext = extname(url); if (ext === '.js') { @@ -97,30 +96,53 @@ function getFileProtocolModuleFormat(url, context, ignoreErrors) { if (packageType !== 'none') { return packageType; } + // The controlling `package.json` file has no `type` field. - if (defaultType === 'module') { - // An exception to the type flag making ESM the default everywhere is that package scopes under `node_modules` - // should retain the assumption that a lack of a `type` field means CommonJS. - return underNodeModules(url) ? 'commonjs' : 'module'; + switch (getOptionValue('--experimental-default-type')) { + case 'module': { // The user explicitly passed `--experimental-default-type=module`. + // An exception to the type flag making ESM the default everywhere is that package scopes under `node_modules` + // should retain the assumption that a lack of a `type` field means CommonJS. + return underNodeModules(url) ? 'commonjs' : 'module'; + } + case 'commonjs': { // The user explicitly passed `--experimental-default-type=commonjs`. + return 'commonjs'; + } + default: { // The user did not pass `--experimental-default-type`. + // `source` is undefined when this is called from `defaultResolve`; + // but this gets called again from `defaultLoad`/`defaultLoadSync`. + if (source && getOptionValue('--experimental-detect-module')) { + return containsModuleSyntax(`${source}`, fileURLToPath(url)) ? 'module' : 'commonjs'; + } + return 'commonjs'; + } } - return 'commonjs'; } if (ext === '') { const packageType = getPackageType(url); - if (defaultType === 'commonjs') { // Legacy behavior - if (packageType === 'none' || packageType === 'commonjs') { - return 'commonjs'; - } // Else packageType === 'module' + if (packageType === 'module') { return getFormatOfExtensionlessFile(url); - } // Else defaultType === 'module' - if (underNodeModules(url)) { // Exception for package scopes under `node_modules` - return packageType === 'module' ? getFormatOfExtensionlessFile(url) : 'commonjs'; } - if (packageType === 'none' || packageType === 'module') { - return getFormatOfExtensionlessFile(url); - } // Else packageType === 'commonjs' - return 'commonjs'; + if (packageType !== 'none') { + return packageType; // 'commonjs' or future package types + } + + // The controlling `package.json` file has no `type` field. + switch (getOptionValue('--experimental-default-type')) { + case 'module': { // The user explicitly passed `--experimental-default-type=module`. + return underNodeModules(url) ? 'commonjs' : getFormatOfExtensionlessFile(url); + } + case 'commonjs': { // The user explicitly passed `--experimental-default-type=commonjs`. + return 'commonjs'; + } + default: { // The user did not pass `--experimental-default-type`. + if (source && getOptionValue('--experimental-detect-module') && + getFormatOfExtensionlessFile(url) === 'module') { + return containsModuleSyntax(`${source}`, fileURLToPath(url)) ? 'module' : 'commonjs'; + } + return 'commonjs'; + } + } } const format = extensionFormatMap[ext]; diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index 1881745a6d3134..f706b5b808da27 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -33,7 +33,7 @@ const DATA_URL_PATTERN = /^[^/]+\/[^,;]+(?:[^,]*?)(;base64)?,([\s\S]*)$/; /** * @param {URL} url URL to the module * @param {ESModuleContext} context used to decorate error messages - * @returns {{ responseURL: string, source: string | BufferView }} + * @returns {Promise<{ responseURL: string, source: string | BufferView }>} */ async function getSource(url, context) { const { protocol, href } = url; @@ -105,7 +105,7 @@ function getSourceSync(url, context) { * @param {LoadContext} context * @returns {LoadReturn} */ -async function defaultLoad(url, context = kEmptyObject) { +async function defaultLoad(url, context = { __proto__: null }) { let responseURL = url; let { importAttributes, @@ -127,19 +127,24 @@ async function defaultLoad(url, context = kEmptyObject) { throwIfUnsupportedURLScheme(urlInstance, experimentalNetworkImports); - format ??= await defaultGetFormat(urlInstance, context); - - validateAttributes(url, format, importAttributes); - - if ( - format === 'builtin' || - format === 'commonjs' - ) { + if (urlInstance.protocol === 'node:') { source = null; } else if (source == null) { ({ responseURL, source } = await getSource(urlInstance, context)); + context.source = source; + } + + if (format == null || format === 'commonjs') { + // Now that we have the source for the module, run `defaultGetFormat` again in case we detect ESM syntax. + format = await defaultGetFormat(urlInstance, context); + } + + if (format === 'commonjs') { + source = null; // Let the CommonJS loader handle it (for now) } + validateAttributes(url, format, importAttributes); + return { __proto__: null, format, @@ -178,16 +183,17 @@ function defaultLoadSync(url, context = kEmptyObject) { throwIfUnsupportedURLScheme(urlInstance, false); - format ??= defaultGetFormat(urlInstance, context); - - validateAttributes(url, format, importAttributes); - - if (format === 'builtin') { + if (urlInstance.protocol === 'node:') { source = null; } else if (source == null) { ({ responseURL, source } = getSourceSync(urlInstance, context)); + context.source = source; } + format ??= defaultGetFormat(urlInstance, context); + + validateAttributes(url, format, importAttributes); + return { __proto__: null, format, diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js index a9828286a9c0e0..1f03c313121db0 100644 --- a/lib/internal/modules/run_main.js +++ b/lib/internal/modules/run_main.js @@ -4,6 +4,7 @@ const { StringPrototypeEndsWith, } = primordials; +const { containsModuleSyntax } = internalBinding('contextify'); const { getOptionValue } = require('internal/options'); const path = require('path'); @@ -70,7 +71,19 @@ function shouldUseESMLoader(mainPath) { const { readPackageScope } = require('internal/modules/package_json_reader'); const pkg = readPackageScope(mainPath); // No need to guard `pkg` as it can only be an object or `false`. - return pkg.data?.type === 'module' || getOptionValue('--experimental-default-type') === 'module'; + switch (pkg.data?.type) { + case 'module': + return true; + case 'commonjs': + return false; + default: { // No package.json or no `type` field. + if (getOptionValue('--experimental-detect-module')) { + // If the first argument of `containsModuleSyntax` is undefined, it will read `mainPath` from the file system. + return containsModuleSyntax(undefined, mainPath); + } + return false; + } + } } /** diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index b8c507c798182e..5de5edfb2d5524 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -26,6 +26,8 @@ const { emitAfter, popAsyncContext, } = require('internal/async_hooks'); +const { containsModuleSyntax } = internalBinding('contextify'); +const { getOptionValue } = require('internal/options'); const { makeContextifyScript, runScriptInThisContext, } = require('internal/vm'); @@ -70,6 +72,12 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) { const baseUrl = pathToFileURL(module.filename).href; const { loadESM } = asyncESM; + if (getOptionValue('--experimental-detect-module') && + getOptionValue('--input-type') === '' && getOptionValue('--experimental-default-type') === '' && + containsModuleSyntax(body, name)) { + return evalModule(body, print); + } + const runScript = () => { // Create wrapper for cache entry const script = ` diff --git a/src/node_contextify.cc b/src/node_contextify.cc index 35c628f4210d3a..a849efcc9c334d 100644 --- a/src/node_contextify.cc +++ b/src/node_contextify.cc @@ -1384,20 +1384,43 @@ constexpr std::array esm_syntax_error_messages = { void ContextifyContext::ContainsModuleSyntax( const FunctionCallbackInfo& args) { - // Argument 1: source code - CHECK(args[0]->IsString()); - Local code = args[0].As(); + Environment* env = Environment::GetCurrent(args); + Isolate* isolate = env->isolate(); + Local context = env->context(); - // Argument 2: filename - Local filename = String::Empty(args.GetIsolate()); + if (args.Length() == 0) { + return THROW_ERR_MISSING_ARGS( + env, "containsModuleSyntax needs at least 1 argument"); + } + + // Argument 2: filename; if undefined, use empty string + Local filename = String::Empty(isolate); if (!args[1]->IsUndefined()) { CHECK(args[1]->IsString()); filename = args[1].As(); } - Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); - Local context = env->context(); + // Argument 1: source code; if undefined, read from filename in argument 2 + Local code; + if (args[0]->IsUndefined()) { + CHECK(!filename.IsEmpty()); + const char* filename_str = Utf8Value(isolate, filename).out(); + std::string contents; + int result = ReadFileSync(&contents, filename_str); + if (result != 0) { + isolate->ThrowException( + ERR_MODULE_NOT_FOUND(isolate, "Cannot read file %s", filename_str)); + return; + } + code = String::NewFromUtf8(isolate, + contents.c_str(), + v8::NewStringType::kNormal, + contents.length()) + .ToLocalChecked(); + } else { + CHECK(args[0]->IsString()); + code = args[0].As(); + } // TODO(geoffreybooth): Centralize this rather than matching the logic in // cjs/loader.js and translators.js diff --git a/src/node_options.cc b/src/node_options.cc index fc9940b64c5c33..29cb7fc6b29b89 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -347,6 +347,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { &EnvironmentOptions::conditions, kAllowedInEnvvar); AddAlias("-C", "--conditions"); + AddOption("--experimental-detect-module", + "when ambiguous modules fail to evaluate because they contain " + "ES module syntax, try again to evaluate them as ES modules", + &EnvironmentOptions::detect_module, + kAllowedInEnvvar); AddOption("--diagnostic-dir", "set dir for all output files" " (default: current working directory)", diff --git a/src/node_options.h b/src/node_options.h index ba16f54e129aec..30955c779714ce 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -104,6 +104,7 @@ class EnvironmentOptions : public Options { public: bool abort_on_uncaught_exception = false; std::vector conditions; + bool detect_module = false; std::string dns_result_order; bool enable_source_maps = false; bool experimental_fetch = true; diff --git a/test/es-module/test-esm-detect-ambiguous.mjs b/test/es-module/test-esm-detect-ambiguous.mjs new file mode 100644 index 00000000000000..61629965518a82 --- /dev/null +++ b/test/es-module/test-esm-detect-ambiguous.mjs @@ -0,0 +1,214 @@ +import { spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { spawn } from 'node:child_process'; +import { describe, it } from 'node:test'; +import { strictEqual, match } from 'node:assert'; + +describe('--experimental-detect-module', { concurrency: true }, () => { + describe('string input', { concurrency: true }, () => { + it('permits ESM syntax in --eval input without requiring --input-type=module', async () => { + const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + '--eval', + 'import { version } from "node:process"; console.log(version);', + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, `${process.version}\n`); + strictEqual(code, 0); + strictEqual(signal, null); + }); + + // ESM is unsupported for --print via --input-type=module + + it('permits ESM syntax in STDIN input without requiring --input-type=module', async () => { + const child = spawn(process.execPath, [ + '--experimental-detect-module', + ]); + child.stdin.end('console.log(typeof import.meta.resolve)'); + + match((await child.stdout.toArray()).toString(), /^function\r?\n$/); + }); + + it('should be overridden by --input-type', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + '--input-type=commonjs', + '--eval', + 'import.meta.url', + ]); + + match(stderr, /SyntaxError: Cannot use 'import\.meta' outside a module/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + + it('should be overridden by --experimental-default-type', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + '--experimental-default-type=commonjs', + '--eval', + 'import.meta.url', + ]); + + match(stderr, /SyntaxError: Cannot use 'import\.meta' outside a module/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + + it('does not trigger detection via source code `eval()`', async () => { + const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + '--eval', + 'eval("import \'nonexistent\';");', + ]); + + match(stderr, /SyntaxError: Cannot use import statement outside a module/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + }); + + describe('.js file input in a typeless package', { concurrency: true }, () => { + for (const { testName, entryPath } of [ + { + testName: 'permits CommonJS syntax in a .js entry point', + entryPath: fixtures.path('es-modules/package-without-type/commonjs.js'), + }, + { + testName: 'permits ESM syntax in a .js entry point', + entryPath: fixtures.path('es-modules/package-without-type/module.js'), + }, + { + testName: 'permits CommonJS syntax in a .js file imported by a CommonJS entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-commonjs.cjs'), + }, + { + testName: 'permits ESM syntax in a .js file imported by a CommonJS entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-esm.js'), + }, + { + testName: 'permits CommonJS syntax in a .js file imported by an ESM entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-commonjs.mjs'), + }, + { + testName: 'permits ESM syntax in a .js file imported by an ESM entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-esm.mjs'), + }, + ]) { + it(testName, async () => { + const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + entryPath, + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + } + }); + + describe('extensionless file input in a typeless package', { concurrency: true }, () => { + for (const { testName, entryPath } of [ + { + testName: 'permits CommonJS syntax in an extensionless entry point', + entryPath: fixtures.path('es-modules/package-without-type/noext-cjs'), + }, + { + testName: 'permits ESM syntax in an extensionless entry point', + entryPath: fixtures.path('es-modules/package-without-type/noext-esm'), + }, + { + testName: 'permits CommonJS syntax in an extensionless file imported by a CommonJS entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-noext-cjs.js'), + }, + { + testName: 'permits ESM syntax in an extensionless file imported by a CommonJS entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-noext-esm.js'), + }, + { + testName: 'permits CommonJS syntax in an extensionless file imported by an ESM entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-noext-cjs.mjs'), + }, + { + testName: 'permits ESM syntax in an extensionless file imported by an ESM entry point', + entryPath: fixtures.path('es-modules/package-without-type/imports-noext-esm.mjs'), + }, + ]) { + it(testName, async () => { + const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + entryPath, + ]); + + strictEqual(stderr, ''); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + } + }); + + describe('file input in a "type": "commonjs" package', { concurrency: true }, () => { + for (const { testName, entryPath } of [ + { + testName: 'disallows ESM syntax in a .js entry point', + entryPath: fixtures.path('es-modules/package-type-commonjs/module.js'), + }, + { + testName: 'disallows ESM syntax in a .js file imported by a CommonJS entry point', + entryPath: fixtures.path('es-modules/package-type-commonjs/imports-esm.js'), + }, + { + testName: 'disallows ESM syntax in a .js file imported by an ESM entry point', + entryPath: fixtures.path('es-modules/package-type-commonjs/imports-esm.mjs'), + }, + ]) { + it(testName, async () => { + const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + entryPath, + ]); + + match(stderr, /SyntaxError: Unexpected token 'export'/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + } + }); + + describe('file input in a "type": "module" package', { concurrency: true }, () => { + for (const { testName, entryPath } of [ + { + testName: 'disallows CommonJS syntax in a .js entry point', + entryPath: fixtures.path('es-modules/package-type-module/cjs.js'), + }, + { + testName: 'disallows CommonJS syntax in a .js file imported by a CommonJS entry point', + entryPath: fixtures.path('es-modules/package-type-module/imports-commonjs.cjs'), + }, + { + testName: 'disallows CommonJS syntax in a .js file imported by an ESM entry point', + entryPath: fixtures.path('es-modules/package-type-module/imports-commonjs.mjs'), + }, + ]) { + it(testName, async () => { + const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + entryPath, + ]); + + match(stderr, /ReferenceError: module is not defined in ES module scope/); + strictEqual(stdout, ''); + strictEqual(code, 1); + strictEqual(signal, null); + }); + } + }); +}); diff --git a/test/fixtures/es-modules/package-type-commonjs/imports-esm.js b/test/fixtures/es-modules/package-type-commonjs/imports-esm.js new file mode 100644 index 00000000000000..d2f5d5fee76ef7 --- /dev/null +++ b/test/fixtures/es-modules/package-type-commonjs/imports-esm.js @@ -0,0 +1 @@ +import('./module.js'); diff --git a/test/fixtures/es-modules/package-type-commonjs/imports-esm.mjs b/test/fixtures/es-modules/package-type-commonjs/imports-esm.mjs new file mode 100644 index 00000000000000..d3eb2fba6a8ee7 --- /dev/null +++ b/test/fixtures/es-modules/package-type-commonjs/imports-esm.mjs @@ -0,0 +1 @@ +import './module.js'; diff --git a/test/fixtures/es-modules/package-type-commonjs/module.js b/test/fixtures/es-modules/package-type-commonjs/module.js new file mode 100644 index 00000000000000..251d6e538a1fcf --- /dev/null +++ b/test/fixtures/es-modules/package-type-commonjs/module.js @@ -0,0 +1,2 @@ +export default 'module'; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-type-module/imports-commonjs.cjs b/test/fixtures/es-modules/package-type-module/imports-commonjs.cjs new file mode 100644 index 00000000000000..7dbbf0d97a46ba --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/imports-commonjs.cjs @@ -0,0 +1 @@ +import('./cjs.js'); diff --git a/test/fixtures/es-modules/package-type-module/imports-commonjs.mjs b/test/fixtures/es-modules/package-type-module/imports-commonjs.mjs new file mode 100644 index 00000000000000..df53dcd2bd4885 --- /dev/null +++ b/test/fixtures/es-modules/package-type-module/imports-commonjs.mjs @@ -0,0 +1 @@ +import './cjs.js'; diff --git a/test/fixtures/es-modules/package-without-type/commonjs.js b/test/fixtures/es-modules/package-without-type/commonjs.js new file mode 100644 index 00000000000000..9b4d39fa21ada2 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/commonjs.js @@ -0,0 +1,2 @@ +module.exports = 'cjs'; +console.log('executed'); diff --git a/test/fixtures/es-modules/package-without-type/imports-commonjs.cjs b/test/fixtures/es-modules/package-without-type/imports-commonjs.cjs new file mode 100644 index 00000000000000..b247f42a15ceb8 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-commonjs.cjs @@ -0,0 +1 @@ +import('./commonjs.js'); diff --git a/test/fixtures/es-modules/package-without-type/imports-commonjs.mjs b/test/fixtures/es-modules/package-without-type/imports-commonjs.mjs new file mode 100644 index 00000000000000..c2f8171fd1deda --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-commonjs.mjs @@ -0,0 +1 @@ +import './commonjs.js'; diff --git a/test/fixtures/es-modules/package-without-type/imports-esm.js b/test/fixtures/es-modules/package-without-type/imports-esm.js new file mode 100644 index 00000000000000..d2f5d5fee76ef7 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-esm.js @@ -0,0 +1 @@ +import('./module.js'); diff --git a/test/fixtures/es-modules/package-without-type/imports-esm.mjs b/test/fixtures/es-modules/package-without-type/imports-esm.mjs new file mode 100644 index 00000000000000..d3eb2fba6a8ee7 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-esm.mjs @@ -0,0 +1 @@ +import './module.js'; diff --git a/test/fixtures/es-modules/package-without-type/imports-noext-cjs.js b/test/fixtures/es-modules/package-without-type/imports-noext-cjs.js new file mode 100644 index 00000000000000..9f78ce4dd0ed7e --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-noext-cjs.js @@ -0,0 +1 @@ +import('./noext-cjs'); diff --git a/test/fixtures/es-modules/package-without-type/imports-noext-cjs.mjs b/test/fixtures/es-modules/package-without-type/imports-noext-cjs.mjs new file mode 100644 index 00000000000000..2419cba28f9318 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-noext-cjs.mjs @@ -0,0 +1 @@ +import './noext-cjs'; diff --git a/test/fixtures/es-modules/package-without-type/imports-noext-esm.js b/test/fixtures/es-modules/package-without-type/imports-noext-esm.js new file mode 100644 index 00000000000000..96eca54521b9d3 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-noext-esm.js @@ -0,0 +1 @@ +import './noext-esm'; diff --git a/test/fixtures/es-modules/package-without-type/imports-noext-esm.mjs b/test/fixtures/es-modules/package-without-type/imports-noext-esm.mjs new file mode 100644 index 00000000000000..96eca54521b9d3 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/imports-noext-esm.mjs @@ -0,0 +1 @@ +import './noext-esm'; diff --git a/test/fixtures/es-modules/package-without-type/module.js b/test/fixtures/es-modules/package-without-type/module.js index 69147a3b8ca027..251d6e538a1fcf 100644 --- a/test/fixtures/es-modules/package-without-type/module.js +++ b/test/fixtures/es-modules/package-without-type/module.js @@ -1,3 +1,2 @@ -// This file can be run or imported only if `--experimental-default-type=module` is set. export default 'module'; console.log('executed'); diff --git a/test/fixtures/es-modules/package-without-type/noext-cjs b/test/fixtures/es-modules/package-without-type/noext-cjs new file mode 100644 index 00000000000000..37f7b87ad10561 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/noext-cjs @@ -0,0 +1,2 @@ +module.exports = 'commonjs'; +console.log('executed'); diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs index dbe486f5bb2991..c6dcb0fcbffb1b 100644 --- a/test/sequential/test-watch-mode.mjs +++ b/test/sequential/test-watch-mode.mjs @@ -277,11 +277,11 @@ console.log(values.random); it('should not load --import modules in main process', async () => { const file = createTmpFile(); - const imported = pathToFileURL(createTmpFile('setImmediate(() => process.exit(0));')); + const imported = pathToFileURL(createTmpFile('process._rawDebug("imported");')); const args = ['--import', imported, file]; const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args }); - assert.strictEqual(stderr, ''); + assert.strictEqual(stderr, 'imported\nimported\n'); assert.deepStrictEqual(stdout, [ 'running', `Completed running ${inspect(file)}`,