diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 5959d476e13..5e653c81c6e 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -5,6 +5,7 @@ const { ObjectPrototypeHasOwnProperty, PromisePrototypeThen, PromiseResolve, + SafeSet, StringPrototypeIncludes, StringPrototypeCharCodeAt, StringPrototypeSlice, @@ -19,7 +20,7 @@ const { const experimentalNetworkImports = getOptionValue('--experimental-network-imports'); const { containsModuleSyntax } = internalBinding('contextify'); -const { getPackageType } = require('internal/modules/package_json_reader'); +const { getPackageScopeConfig, getPackageType } = require('internal/modules/package_json_reader'); const { fileURLToPath } = require('internal/url'); const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes; @@ -81,6 +82,7 @@ function underNodeModules(url) { return StringPrototypeIncludes(url.pathname, '/node_modules/'); } +let typelessPackageJsonFilesWarnedAbout; /** * @param {URL} url * @param {{parentURL: string; source?: Buffer}} context @@ -92,7 +94,7 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE const ext = extname(url); if (ext === '.js') { - const packageType = getPackageType(url); + const { type: packageType, pjsonPath } = getPackageScopeConfig(url); if (packageType !== 'none') { return packageType; } @@ -111,9 +113,23 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE // `source` is undefined when this is called from `defaultResolve`; // but this gets called again from `defaultLoad`/`defaultLoadSync`. if (getOptionValue('--experimental-detect-module')) { - return source ? + const format = source ? (containsModuleSyntax(`${source}`, fileURLToPath(url)) ? 'module' : 'commonjs') : null; + if (format === 'module') { + // This module has a .js extension, a package.json with no `type` field, and ESM syntax. + // Warn about the missing `type` field so that the user can avoid the performance penalty of detection. + typelessPackageJsonFilesWarnedAbout ??= new SafeSet(); + if (!typelessPackageJsonFilesWarnedAbout.has(pjsonPath)) { + const warning = `${url} parsed as an ES module because module syntax was detected;` + + ` to avoid the performance penalty of syntax detection, add "type": "module" to ${pjsonPath}`; + process.emitWarning(warning, { + code: 'MODULE_TYPELESS_PACKAGE_JSON', + }); + typelessPackageJsonFilesWarnedAbout.add(pjsonPath); + } + } + return format; } return 'commonjs'; } diff --git a/test/es-module/test-esm-detect-ambiguous.mjs b/test/es-module/test-esm-detect-ambiguous.mjs index 2067040114a..c027f46328a 100644 --- a/test/es-module/test-esm-detect-ambiguous.mjs +++ b/test/es-module/test-esm-detect-ambiguous.mjs @@ -101,6 +101,7 @@ describe('--experimental-detect-module', { concurrency: true }, () => { ]) { it(testName, async () => { const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--no-warnings', '--experimental-detect-module', entryPath, ]); @@ -142,6 +143,7 @@ describe('--experimental-detect-module', { concurrency: true }, () => { ]) { it(testName, async () => { const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--no-warnings', '--experimental-detect-module', entryPath, ]); @@ -291,6 +293,7 @@ describe('--experimental-detect-module', { concurrency: true }, () => { it('permits declaration of CommonJS module variables', async () => { const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--no-warnings', '--experimental-detect-module', fixtures.path('es-modules/package-without-type/commonjs-wrapper-variables.js'), ]); @@ -327,6 +330,48 @@ describe('--experimental-detect-module', { concurrency: true }, () => { strictEqual(signal, null); }); }); + + describe('warn about typeless packages for .js files with ESM syntax', { concurrency: true }, () => { + for (const { testName, entryPath } of [ + { + testName: 'warns for ESM syntax in a .js entry point in a typeless package', + entryPath: fixtures.path('es-modules/package-without-type/module.js'), + }, + { + testName: 'warns for ESM syntax in a .js file imported by a CommonJS entry point in a typeless package', + entryPath: fixtures.path('es-modules/package-without-type/imports-esm.js'), + }, + { + testName: 'warns for ESM syntax in a .js file imported by an ESM entry point in a typeless package', + 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, + ]); + + match(stderr, /MODULE_TYPELESS_PACKAGE_JSON/); + strictEqual(stdout, 'executed\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + } + + it('warns only once for a package.json that affects multiple files', async () => { + const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [ + '--experimental-detect-module', + fixtures.path('es-modules/package-without-type/detected-as-esm.js'), + ]); + + match(stderr, /MODULE_TYPELESS_PACKAGE_JSON/); + strictEqual(stderr.match(/MODULE_TYPELESS_PACKAGE_JSON/g).length, 1); + strictEqual(stdout, 'executed\nexecuted\n'); + strictEqual(code, 0); + strictEqual(signal, null); + }); + }); }); // Validate temporarily disabling `--abort-on-uncaught-exception` diff --git a/test/fixtures/es-modules/package-without-type/detected-as-esm.js b/test/fixtures/es-modules/package-without-type/detected-as-esm.js new file mode 100644 index 00000000000..7d81c68b3d2 --- /dev/null +++ b/test/fixtures/es-modules/package-without-type/detected-as-esm.js @@ -0,0 +1,2 @@ +import './module.js'; +console.log('executed');