From 343b8b23fdea5d2bae09d2f484664106de8418f4 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 14 Aug 2026 13:49:32 -0400 Subject: [PATCH] esm: end-of-life DEP0151 main index lookup ES module main entry resolution now requires an explicit "exports" or "main" field with the exact file extension. The legacy index.js and extension-searching lookups that previously succeeded with a DEP0151 warning now throw ERR_INVALID_PACKAGE_CONFIG. This is a semver-major change. It is triggered by `import 'pkg'` when the resolved package entry is an ES module and either has no "main"/"exports" field or has a "main" value that omits the file extension. CommonJS packages are unaffected. Refs: https://github.com/nodejs/node/pull/37206 Refs: https://github.com/nodejs/node/pull/36918 Signed-off-by: Yagiz Nizipli --- doc/api/deprecations.md | 13 +++-- doc/api/esm.md | 4 +- doc/api/packages.md | 11 +++++ lib/internal/modules/esm/resolve.js | 47 +++++++++---------- .../test-esm-exports-deprecations.mjs | 2 - test/es-module/test-esm-exports.mjs | 16 +++++-- .../test-esm-extension-lookup-deprecation.mjs | 47 +++++++++++++------ test/es-module/test-esm-type-main.mjs | 13 ++--- .../implicit-main-type-module/package.json | 2 +- 9 files changed, 97 insertions(+), 58 deletions(-) diff --git a/doc/api/deprecations.md b/doc/api/deprecations.md index ee2c3ef3e1b6..3ac811af445b 100644 --- a/doc/api/deprecations.md +++ b/doc/api/deprecations.md @@ -3404,6 +3404,9 @@ to change the value will be removed in a future version of Node.js. -Type: Runtime +Type: End-of-Life -Previously, `index.js` and extension searching lookups would apply to -`import 'pkg'` main entry point resolution, even when resolving ES modules. +`index.js` and extension searching lookups no longer apply to +`import 'pkg'` main entry point resolution when resolving ES modules. -With this deprecation, all ES module main entry point resolutions require -an explicit [`"exports"` or `"main"` entry][] with the exact file extension. +All ES module main entry point resolutions require an explicit +[`"exports"` or `"main"` entry][] with the exact file extension. ### DEP0152: Extension PerformanceEntry properties diff --git a/doc/api/esm.md b/doc/api/esm.md index 7bee67774583..a0a2ff1465b8 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -188,7 +188,8 @@ the Node.js module resolution, see the [packages documentation](packages.md). A file extension must be provided when using the `import` keyword to resolve relative or absolute specifiers. Directory indexes (e.g. `'./startup/index.js'`) -must also be fully specified. +must also be fully specified. A package's [`"main"`][] field must also include +the exact file extension when the package is an ES module. This behavior matches how `import` behaves in browser environments, assuming a typically configured server. @@ -1336,6 +1337,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][]. [URL]: https://url.spec.whatwg.org/ [WebAssembly JS String Builtins Proposal]: https://github.com/WebAssembly/js-string-builtins [`"exports"`]: packages.md#exports +[`"main"`]: packages.md#main [`"type"`]: packages.md#type [`--experimental-package-map`]: cli.md#--experimental-package-mappath [`--input-type`]: cli.md#--input-typetype diff --git a/doc/api/packages.md b/doc/api/packages.md index c4781eca1062..31514f886db5 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -1185,6 +1185,12 @@ The `"name"` field can be used in addition to the [`"exports"`][] field to * Type: {string} @@ -1198,6 +1204,11 @@ added: v0.4.0 The `"main"` field defines the entry point of a package when imported by name via a `node_modules` lookup. Its value is a path. +When the package is an [ES module][] (for example, `"type": "module"`), +the `"main"` field must include the exact file extension. Default `index.js` +lookups and automatic extension resolution are not supported for ES modules. +Use the [`"exports"`][] field or a `"main"` value such as `"./index.js"`. + The [`"exports"`][] field, if it exists, takes precedence over the `"main"` field when importing the package by name. diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index 3916e79328ed..6a639be7db21 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -113,40 +113,37 @@ function emitInvalidSegmentDeprecation(target, request, match, pjsonUrl, interna } /** - * Emits a deprecation warning if the given URL is a module and - * the package.json file does not define a "main" or "exports" field. + * Throws if the given URL is an ES module resolved via legacy index lookup + * or "main" extension searching rather than an explicit "exports" or "main" + * path with the exact file extension. * @param {URL} url - The URL of the module being resolved. * @param {string} path - The path of the module being resolved. * @param {string} pkgPath - The path of the parent dir of the package.json file for the module. * @param {string | URL} [base] - The base URL for the module being resolved. * @param {string} [main] - The "main" field from the package.json file. */ -function emitLegacyIndexDeprecation(url, path, pkgPath, base, main) { - if (process.noDeprecation) { +function throwIfLegacyIndexESM(url, path, pkgPath, base, main) { + const format = defaultGetFormatWithoutErrors(url); + if (format !== 'module') { return; } - const format = defaultGetFormatWithoutErrors(url); - if (format !== 'module') { return; } const basePath = fileURLToPath(base); + const packageJSONPath = join(pkgPath, 'package.json'); if (!main) { - process.emitWarning( - `No "main" or "exports" field defined in the package.json for ${pkgPath - } resolving the main entry point "${ - StringPrototypeSlice(path, pkgPath.length)}", imported from ${basePath - }.\nDefault "index" lookups for the main are deprecated for ES modules.`, - 'DeprecationWarning', - 'DEP0151', - ); - } else if (resolve(pkgPath, main) !== path) { - process.emitWarning( - `Package ${pkgPath} has a "main" field set to "${main}", ` + - `excluding the full filename and extension to the resolved file at "${ - StringPrototypeSlice(path, pkgPath.length)}", imported from ${ - basePath}.\n Automatic extension resolution of the "main" field is ` + - 'deprecated for ES modules.', - 'DeprecationWarning', - 'DEP0151', - ); + throw new ERR_INVALID_PACKAGE_CONFIG( + packageJSONPath, + basePath, + 'Default "index" lookups for the main are not supported for ES ' + + 'modules. Add an explicit "exports" or "main" entry with the exact ' + + 'file extension.'); + } + if (resolve(pkgPath, main) !== path) { + throw new ERR_INVALID_PACKAGE_CONFIG( + packageJSONPath, + basePath, + 'Automatic extension resolution of the "main" field is not supported ' + + 'for ES modules. The "main" field must include the exact file ' + + 'extension.'); } } @@ -206,7 +203,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { const resolvedPath = resolve(pkgPath, maybeMain + legacyMainResolveExtensions[resolvedOption]); const resolvedUrl = pathToFileURL(resolvedPath); - emitLegacyIndexDeprecation(resolvedUrl, resolvedPath, pkgPath, base, packageConfig.main); + throwIfLegacyIndexESM(resolvedUrl, resolvedPath, pkgPath, base, packageConfig.main); return resolvedUrl; } diff --git a/test/es-module/test-esm-exports-deprecations.mjs b/test/es-module/test-esm-exports-deprecations.mjs index d4d8b8e3e379..8055a2ee126b 100644 --- a/test/es-module/test-esm-exports-deprecations.mjs +++ b/test/es-module/test-esm-exports-deprecations.mjs @@ -17,8 +17,6 @@ const expectedWarnings = [ '".//internal/test.js"', '".//internal//test.js"', '"./////internal/////test.js"', - 'no_exports', - 'default_index', ]; process.addListener('warning', mustCall((warning) => { diff --git a/test/es-module/test-esm-exports.mjs b/test/es-module/test-esm-exports.mjs index bc065898c075..68d4d3c13bdd 100644 --- a/test/es-module/test-esm-exports.mjs +++ b/test/es-module/test-esm-exports.mjs @@ -58,10 +58,18 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js'; ]); if (!isRequire) { - // No exports or main field - validSpecifiers.set('no_exports', { default: 'index' }); - // Main field without extension - validSpecifiers.set('default_index', { default: 'main' }); + // DEP0151 End-of-Life: ESM main resolution requires an explicit + // "exports" or "main" entry with the exact file extension. + const legacyMainErrors = new Map([ + ['no_exports', 'Default "index" lookups'], + ['default_index', 'Automatic extension resolution'], + ]); + for (const [specifier, message] of legacyMainErrors) { + loadFixture(specifier).catch(mustCall((err) => { + assert.strictEqual(err.code, 'ERR_INVALID_PACKAGE_CONFIG'); + assertIncludes(err.message, message); + })); + } } for (const [validSpecifier, expected] of validSpecifiers) { diff --git a/test/es-module/test-esm-extension-lookup-deprecation.mjs b/test/es-module/test-esm-extension-lookup-deprecation.mjs index e9e83affeb7d..0e5a555844db 100644 --- a/test/es-module/test-esm-extension-lookup-deprecation.mjs +++ b/test/es-module/test-esm-extension-lookup-deprecation.mjs @@ -47,7 +47,7 @@ describe('ESM in main field', { concurrency: !process.env.TEST_PARALLEL }, () => assert.strictEqual(code, 0); }); - it('should emit warning when "main" and "exports" are missing', async () => { + it('should throw when "main" and "exports" are missing', async () => { const cwd = tmpdir.resolve(Math.random().toString()); const pkgPath = path.join(cwd, './node_modules/pkg/'); await mkdir(pkgPath, { recursive: true }); @@ -60,11 +60,12 @@ describe('ESM in main field', { concurrency: !process.env.TEST_PARALLEL }, () => '--eval', 'import "pkg"', ], { cwd }); - assert.match(stderr, /\[DEP0151\]/); - assert.match(stdout, /^Hello World!\r?\n$/); - assert.strictEqual(code, 0); + assert.match(stderr, /ERR_INVALID_PACKAGE_CONFIG/); + assert.match(stderr, /Default "index" lookups for the main are not supported for ES modules/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); }); - it('should emit warning when "main" is falsy', async () => { + it('should throw when "main" is falsy', async () => { const cwd = tmpdir.resolve(Math.random().toString()); const pkgPath = path.join(cwd, './node_modules/pkg/'); await mkdir(pkgPath, { recursive: true }); @@ -78,11 +79,12 @@ describe('ESM in main field', { concurrency: !process.env.TEST_PARALLEL }, () => '--eval', 'import "pkg"', ], { cwd }); - assert.match(stderr, /\[DEP0151\]/); - assert.match(stdout, /^Hello World!\r?\n$/); - assert.strictEqual(code, 0); + assert.match(stderr, /ERR_INVALID_PACKAGE_CONFIG/); + assert.match(stderr, /Default "index" lookups for the main are not supported for ES modules/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); }); - it('should emit warning when "main" is a relative path without extension', async () => { + it('should throw when "main" is a relative path without extension', async () => { const cwd = tmpdir.resolve(Math.random().toString()); const pkgPath = path.join(cwd, './node_modules/pkg/'); await mkdir(pkgPath, { recursive: true }); @@ -96,11 +98,12 @@ describe('ESM in main field', { concurrency: !process.env.TEST_PARALLEL }, () => '--eval', 'import "pkg"', ], { cwd }); - assert.match(stderr, /\[DEP0151\]/); - assert.match(stdout, /^Hello World!\r?\n$/); - assert.strictEqual(code, 0); + assert.match(stderr, /ERR_INVALID_PACKAGE_CONFIG/); + assert.match(stderr, /Automatic extension resolution of the "main" field is not supported/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); }); - it('should emit warning when "main" is an absolute path without extension', async () => { + it('should throw when "main" is an absolute path without extension', async () => { const cwd = tmpdir.resolve(Math.random().toString()); const pkgPath = path.join(cwd, './node_modules/pkg/'); await mkdir(pkgPath, { recursive: true }); @@ -114,7 +117,23 @@ describe('ESM in main field', { concurrency: !process.env.TEST_PARALLEL }, () => '--eval', 'import "pkg"', ], { cwd }); - assert.match(stderr, /\[DEP0151\]/); + assert.match(stderr, /ERR_INVALID_PACKAGE_CONFIG/); + assert.match(stderr, /Automatic extension resolution of the "main" field is not supported/); + assert.strictEqual(stdout, ''); + assert.strictEqual(code, 1); + }); + it('should still resolve CommonJS packages via index lookup', async () => { + const cwd = tmpdir.resolve(Math.random().toString()); + const pkgPath = path.join(cwd, './node_modules/pkg/'); + await mkdir(pkgPath, { recursive: true }); + await writeFile(path.join(pkgPath, './index.js'), 'console.log("Hello World!")'); + await writeFile(path.join(pkgPath, './package.json'), JSON.stringify({})); + const { code, stdout, stderr } = await spawnPromisified(execPath, [ + '--input-type=module', + '--eval', 'import "pkg"', + ], { cwd }); + + assert.strictEqual(stderr, ''); assert.match(stdout, /^Hello World!\r?\n$/); assert.strictEqual(code, 0); }); diff --git a/test/es-module/test-esm-type-main.mjs b/test/es-module/test-esm-type-main.mjs index 012cf4f35fc9..317023bdb1fd 100644 --- a/test/es-module/test-esm-type-main.mjs +++ b/test/es-module/test-esm-type-main.mjs @@ -1,9 +1,10 @@ -import { mustNotCall } from '../common/index.mjs'; +import '../common/index.mjs'; import assert from 'assert'; import { importFixture } from '../fixtures/pkgexports.mjs'; -(async () => { - const m = await importFixture('type-main'); - assert.strictEqual(m.default, 'asdf'); -})() -.catch(mustNotCall); +// DEP0151 End-of-Life: "type": "module" with a "main" field that omits +// the file extension no longer resolves. +await assert.rejects(importFixture('type-main'), { + code: 'ERR_INVALID_PACKAGE_CONFIG', + message: /Automatic extension resolution of the "main" field is not supported/, +}); diff --git a/test/fixtures/es-module-specifiers/node_modules/implicit-main-type-module/package.json b/test/fixtures/es-module-specifiers/node_modules/implicit-main-type-module/package.json index 5710280badd3..6172b89b3dfa 100644 --- a/test/fixtures/es-module-specifiers/node_modules/implicit-main-type-module/package.json +++ b/test/fixtures/es-module-specifiers/node_modules/implicit-main-type-module/package.json @@ -1,4 +1,4 @@ { - "main": "entry", + "main": "entry.js", "type": "module" }