diff --git a/doc/api/deprecations.md b/doc/api/deprecations.md index 326f4705856596..724d5abae685f9 100644 --- a/doc/api/deprecations.md +++ b/doc/api/deprecations.md @@ -2677,6 +2677,28 @@ In future versions of Node.js, `fs.rmdir(path, { recursive: true })` will throw if `path` does not exist or is a file. Use `fs.rm(path, { recursive: true, force: true })` instead. +### DEP0148: Folder mappings in `"exports"` (trailing `"/"`) + + +Type: Runtime (supports [`--pending-deprecation`][]) + +Prior to [subpath patterns][] support, it was possible to define +[subpath folder mappings][] in the [subpath exports][] or +[subpath imports][] fields using a trailing `"/"`. + +Without `--pending-deprecation`, runtime warnings occur only for exports +resolutions not in `node_modules`. This means there will not be deprecation +warnings for `"exports"` in dependencies. With `--pending-deprecation`, a +runtime warning results no matter where the `"exports"` usage occurs. + [Legacy URL API]: url.md#url_legacy_url_api [NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf [RFC 6066]: https://tools.ietf.org/html/rfc6066#section-3 @@ -2801,3 +2823,7 @@ Use `fs.rm(path, { recursive: true, force: true })` instead. [from_string_encoding]: buffer.md#buffer_static_method_buffer_from_string_encoding [legacy `urlObject`]: url.md#url_legacy_urlobject [static methods of `crypto.Certificate()`]: crypto.md#crypto_class_certificate +[subpath exports]: #packages_subpath_exports +[subpath folder mappings]: #packages_subpath_folder_mappings +[subpath imports]: #packages_subpath_imports +[subpath patterns]: #packages_subpath_patterns diff --git a/doc/api/packages.md b/doc/api/packages.md index 5fef7f4766d1ce..9b7ba7fb1198b2 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -365,6 +365,45 @@ treating the right hand side target pattern as a `**` glob against the list of files within the package. Because `node_modules` paths are forbidden in exports targets, this expansion is dependent on only the files of the package itself. +### Subpath folder mappings + + +> Stability: 0 - Deprecated: Use subpath patterns instead. + +Before subpath patterns were supported, a trailing `"/"` suffix was used to +support folder mappings: + +```json +{ + "exports": { + "./features/": "./features/" + } +} +``` + +_This feature will be removed in a future release._ + +Instead, use direct [subpath patterns][]: + +```json +{ + "exports": { + "./features/*": "./features/*.js" + } +} +``` + +The benefit of patterns over folder exports is that packages can always be +imported by consumers without subpath file extensions being necessary. + ### Exports sugar If the `"."` export is the only export, the [`"exports"`][] field provides sugar @@ -1028,5 +1067,6 @@ This field defines [subpath imports][] for the current package. [self-reference]: #packages_self_referencing_a_package_using_its_name [subpath exports]: #packages_subpath_exports [subpath imports]: #packages_subpath_imports +[subpath patterns]: #packages_subpath_patterns [the full specifier path]: esm.md#esm_mandatory_file_extensions [the dual CommonJS/ES module packages section]: #packages_dual_commonjs_es_module_packages diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index 21459cf3d1e753..1c1598d6251047 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -16,6 +16,7 @@ const { String, StringPrototypeEndsWith, StringPrototypeIndexOf, + StringPrototypeLastIndexOf, StringPrototypeReplace, StringPrototypeSlice, StringPrototypeSplit, @@ -59,6 +60,36 @@ const userConditions = getOptionValue('--conditions'); const DEFAULT_CONDITIONS = ObjectFreeze(['node', 'import', ...userConditions]); const DEFAULT_CONDITIONS_SET = new SafeSet(DEFAULT_CONDITIONS); +const pendingDeprecation = getOptionValue('--pending-deprecation'); +const emittedPackageWarnings = new SafeSet(); +function emitFolderMapDeprecation(match, pjsonUrl, isExports, base) { + const pjsonPath = fileURLToPath(pjsonUrl); + if (!pendingDeprecation) { + const nodeModulesIndex = StringPrototypeLastIndexOf(pjsonPath, + '/node_modules/'); + if (nodeModulesIndex !== -1) { + const afterNodeModulesPath = StringPrototypeSlice(pjsonPath, + nodeModulesIndex + 14, + -13); + try { + const { packageSubpath } = parsePackageName(afterNodeModulesPath); + if (packageSubpath === '.') + return; + } catch {} + } + } + if (emittedPackageWarnings.has(pjsonPath + '|' + match)) + return; + emittedPackageWarnings.add(pjsonPath + '|' + match); + process.emitWarning( + `Use of deprecated folder mapping "${match}" in the ${isExports ? + '"exports"' : '"imports"'} field module resolution of the package at ${ + pjsonPath}${base ? ` imported from ${fileURLToPath(base)}` : ''}.\n` + + `Update this package.json to use a subpath pattern like "${match}*".`, + 'DeprecationWarning', + 'DEP0148' + ); +} function getConditionsSet(conditions) { if (conditions !== undefined && conditions !== DEFAULT_CONDITIONS) { @@ -507,6 +538,8 @@ function packageExportsResolve( conditions); if (resolved === null || resolved === undefined) throwExportsNotFound(packageSubpath, packageJSONUrl, base); + if (!pattern) + emitFolderMapDeprecation(bestMatch, packageJSONUrl, true, base); return { resolved, exact: pattern }; } @@ -556,8 +589,11 @@ function packageImportsResolve(name, base, conditions) { const resolved = resolvePackageTarget( packageJSONUrl, target, subpath, bestMatch, base, pattern, true, conditions); - if (resolved !== null) + if (resolved !== null) { + if (!pattern) + emitFolderMapDeprecation(bestMatch, packageJSONUrl, false, base); return { resolved, exact: pattern }; + } } } } @@ -570,13 +606,7 @@ function getPackageType(url) { return packageConfig.type; } -/** - * @param {string} specifier - * @param {URL} base - * @param {Set} conditions - * @returns {URL} - */ -function packageResolve(specifier, base, conditions) { +function parsePackageName(specifier, base) { let separatorIndex = StringPrototypeIndexOf(specifier, '/'); let validPackageName = true; let isScoped = false; @@ -610,6 +640,19 @@ function packageResolve(specifier, base, conditions) { const packageSubpath = '.' + (separatorIndex === -1 ? '' : StringPrototypeSlice(specifier, separatorIndex)); + return { packageName, packageSubpath, isScoped }; +} + +/** + * @param {string} specifier + * @param {URL} base + * @param {Set} conditions + * @returns {URL} + */ +function packageResolve(specifier, base, conditions) { + const { packageName, packageSubpath, isScoped } = + parsePackageName(specifier, base); + // ResolveSelf const packageConfig = getPackageScopeConfig(base); if (packageConfig.exists) { diff --git a/test/es-module/test-esm-exports-pending-deprecations.mjs b/test/es-module/test-esm-exports-pending-deprecations.mjs new file mode 100644 index 00000000000000..edc9ae814a59a8 --- /dev/null +++ b/test/es-module/test-esm-exports-pending-deprecations.mjs @@ -0,0 +1,19 @@ +// Flags: --pending-deprecation +import { mustCall } from '../common/index.mjs'; +import assert from 'assert'; + +let curWarning = 0; +const expectedWarnings = [ + '"./sub/"', + '"./fallbackdir/"', + '"./subpath/"' +]; + +process.addListener('warning', mustCall((warning) => { + assert(warning.stack.includes(expectedWarnings[curWarning++]), warning.stack); +}, expectedWarnings.length)); + +(async () => { + await import('./test-esm-exports.mjs'); +})() +.catch((err) => console.error(err)); diff --git a/test/es-module/test-esm-local-deprecations.mjs b/test/es-module/test-esm-local-deprecations.mjs new file mode 100644 index 00000000000000..96d8cd1c8af735 --- /dev/null +++ b/test/es-module/test-esm-local-deprecations.mjs @@ -0,0 +1,22 @@ +import { mustCall } from '../common/index.mjs'; +import assert from 'assert'; +import fixtures from '../common/fixtures.js'; +import { pathToFileURL } from 'url'; + +const selfDeprecatedFolders = + fixtures.path('/es-modules/self-deprecated-folders/main.js'); + +let curWarning = 0; +const expectedWarnings = [ + '"./" in the "exports" field', + '"#self/" in the "imports" field' +]; + +process.addListener('warning', mustCall((warning) => { + assert(warning.stack.includes(expectedWarnings[curWarning++]), warning.stack); +}, expectedWarnings.length)); + +(async () => { + await import(pathToFileURL(selfDeprecatedFolders)); +})() +.catch((err) => console.error(err)); diff --git a/test/fixtures/es-modules/self-deprecated-folders/main.js b/test/fixtures/es-modules/self-deprecated-folders/main.js new file mode 100644 index 00000000000000..842623aa8438e4 --- /dev/null +++ b/test/fixtures/es-modules/self-deprecated-folders/main.js @@ -0,0 +1,2 @@ +import 'self/main.js'; +import '#self/main.js'; diff --git a/test/fixtures/es-modules/self-deprecated-folders/package.json b/test/fixtures/es-modules/self-deprecated-folders/package.json new file mode 100644 index 00000000000000..4e27e6ee79cf5e --- /dev/null +++ b/test/fixtures/es-modules/self-deprecated-folders/package.json @@ -0,0 +1,11 @@ +{ + "name": "self", + "type": "module", + "exports": { + ".": "./main.js", + "./": "./" + }, + "imports": { + "#self/": "./" + } +}