From d65e95e6108f9d04c1ef278242ed03f127fe9cb4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 3 Aug 2023 01:14:08 +0200 Subject: [PATCH] fix(node-resolve): Implement package exports / imports resolution algorithm according to Node documentation This fixes the package exports and imports resolution algorithm by strictly following the Node API documentation. For backwards compatibility a new option `allowExportsFolderMapping` is introduced which will enable deprecated folder mappings. Test case included Signed-off-by: Ferdinand Thiessen --- packages/node-resolve/README.md | 25 +++ packages/node-resolve/rollup.config.mjs | 3 +- packages/node-resolve/src/{fs.js => fs.ts} | 4 +- packages/node-resolve/src/index.js | 7 +- .../src/package/resolvePackageExports.js | 48 ----- .../src/package/resolvePackageExports.ts | 71 +++++++ ...ageImports.js => resolvePackageImports.ts} | 31 +++- .../package/resolvePackageImportsExports.js | 44 ----- .../package/resolvePackageImportsExports.ts | 98 ++++++++++ .../src/package/resolvePackageTarget.js | 114 ------------ .../src/package/resolvePackageTarget.ts | 173 ++++++++++++++++++ .../src/package/{utils.js => utils.ts} | 35 ++-- .../src/resolveImportSpecifiers.js | 10 +- .../fixtures/exports-pattern-extension.js | 3 + .../exports-pattern-extension/package.json | 10 + .../node-resolve/test/package-entry-points.js | 13 ++ packages/node-resolve/types/index.d.ts | 8 + 17 files changed, 464 insertions(+), 233 deletions(-) rename packages/node-resolve/src/{fs.js => fs.ts} (79%) delete mode 100644 packages/node-resolve/src/package/resolvePackageExports.js create mode 100644 packages/node-resolve/src/package/resolvePackageExports.ts rename packages/node-resolve/src/package/{resolvePackageImports.js => resolvePackageImports.ts} (59%) delete mode 100644 packages/node-resolve/src/package/resolvePackageImportsExports.js create mode 100644 packages/node-resolve/src/package/resolvePackageImportsExports.ts delete mode 100644 packages/node-resolve/src/package/resolvePackageTarget.js create mode 100644 packages/node-resolve/src/package/resolvePackageTarget.ts rename packages/node-resolve/src/package/{utils.js => utils.ts} (61%) create mode 100644 packages/node-resolve/test/fixtures/exports-pattern-extension.js create mode 100644 packages/node-resolve/test/fixtures/node_modules/exports-pattern-extension/package.json diff --git a/packages/node-resolve/README.md b/packages/node-resolve/README.md index 566d4fe71..1ef0915de 100755 --- a/packages/node-resolve/README.md +++ b/packages/node-resolve/README.md @@ -175,6 +175,31 @@ rootDir: path.join(process.cwd(), '..') If you use the `sideEffects` property in the package.json, by default this is respected for files in the root package. Set to `true` to ignore the `sideEffects` configuration for the root package. +### `allowExportsFolderMapping` + +Older Node versions supported exports mappings of folders like + +```json +{ + "exports": { + "./foo/": "./dist/foo/" + } +} +``` + +This was deprecated with Node 14 and removed in Node 17, instead it is recommended to use exports patterns like + +```json +{ + "exports": { + "./foo/*": "./dist/foo/*" + } +} +``` + +But for backwards compatibility this behavior is still supported by enabling the `allowExportsFolderMapping` (defaults to `true`). +The default value might change in a futur major release. + ## Preserving symlinks This plugin honours the rollup [`preserveSymlinks`](https://rollupjs.org/guide/en/#preservesymlinks) option. diff --git a/packages/node-resolve/rollup.config.mjs b/packages/node-resolve/rollup.config.mjs index 15c7bcf3a..169d67294 100755 --- a/packages/node-resolve/rollup.config.mjs +++ b/packages/node-resolve/rollup.config.mjs @@ -1,6 +1,7 @@ import { readFileSync } from 'fs'; import json from '@rollup/plugin-json'; +import typescript from '@rollup/plugin-typescript'; import { createConfig } from '../../shared/rollup.config.mjs'; @@ -9,5 +10,5 @@ export default { pkg: JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) }), input: 'src/index.js', - plugins: [json()] + plugins: [json(), typescript()] }; diff --git a/packages/node-resolve/src/fs.js b/packages/node-resolve/src/fs.ts similarity index 79% rename from packages/node-resolve/src/fs.js rename to packages/node-resolve/src/fs.ts index f312e1c61..6b0499b61 100644 --- a/packages/node-resolve/src/fs.js +++ b/packages/node-resolve/src/fs.ts @@ -8,7 +8,7 @@ export const realpath = promisify(fs.realpath); export { realpathSync } from 'fs'; export const stat = promisify(fs.stat); -export async function fileExists(filePath) { +export async function fileExists(filePath: fs.PathLike) { try { const res = await stat(filePath); return res.isFile(); @@ -17,6 +17,6 @@ export async function fileExists(filePath) { } } -export async function resolveSymlink(path) { +export async function resolveSymlink(path: fs.PathLike) { return (await fileExists(path)) ? realpath(path) : path; } diff --git a/packages/node-resolve/src/index.js b/packages/node-resolve/src/index.js index 559ae1516..0a06f8a3f 100644 --- a/packages/node-resolve/src/index.js +++ b/packages/node-resolve/src/index.js @@ -37,7 +37,9 @@ const defaults = { extensions: ['.mjs', '.js', '.json', '.node'], resolveOnly: [], moduleDirectories: ['node_modules'], - ignoreSideEffectsForRoot: false + ignoreSideEffectsForRoot: false, + // TODO: set to false in next major release or remove + allowExportsFolderMapping: true }; export const DEFAULTS = deepFreeze(deepMerge({}, defaults)); @@ -183,7 +185,8 @@ export function nodeResolve(opts = {}) { moduleDirectories, modulePaths, rootDir, - ignoreSideEffectsForRoot + ignoreSideEffectsForRoot, + allowExportsFolderMapping: options.allowExportsFolderMapping }); const importeeIsBuiltin = isBuiltinModule(importee); diff --git a/packages/node-resolve/src/package/resolvePackageExports.js b/packages/node-resolve/src/package/resolvePackageExports.js deleted file mode 100644 index 0c233f00b..000000000 --- a/packages/node-resolve/src/package/resolvePackageExports.js +++ /dev/null @@ -1,48 +0,0 @@ -import { - InvalidModuleSpecifierError, - InvalidConfigurationError, - isMappings, - isConditions, - isMixedExports -} from './utils'; -import resolvePackageTarget from './resolvePackageTarget'; -import resolvePackageImportsExports from './resolvePackageImportsExports'; - -async function resolvePackageExports(context, subpath, exports) { - if (isMixedExports(exports)) { - throw new InvalidConfigurationError( - context, - 'All keys must either start with ./, or without one.' - ); - } - - if (subpath === '.') { - let mainExport; - // If exports is a String or Array, or an Object containing no keys starting with ".", then - if (typeof exports === 'string' || Array.isArray(exports) || isConditions(exports)) { - mainExport = exports; - } else if (isMappings(exports)) { - mainExport = exports['.']; - } - - if (mainExport) { - const resolved = await resolvePackageTarget(context, { target: mainExport, subpath: '' }); - if (resolved) { - return resolved; - } - } - } else if (isMappings(exports)) { - const resolvedMatch = await resolvePackageImportsExports(context, { - matchKey: subpath, - matchObj: exports - }); - - if (resolvedMatch) { - return resolvedMatch; - } - } - - throw new InvalidModuleSpecifierError(context); -} - -export default resolvePackageExports; diff --git a/packages/node-resolve/src/package/resolvePackageExports.ts b/packages/node-resolve/src/package/resolvePackageExports.ts new file mode 100644 index 000000000..493b0b4ec --- /dev/null +++ b/packages/node-resolve/src/package/resolvePackageExports.ts @@ -0,0 +1,71 @@ +import { + InvalidModuleSpecifierError, + InvalidConfigurationError, + isMappings, + isConditions, + isMixedExports +} from './utils'; +import resolvePackageTarget from './resolvePackageTarget'; +import resolvePackageImportsExports from './resolvePackageImportsExports'; + +/** + * Implementation of PACKAGE_EXPORTS_RESOLVE + */ +async function resolvePackageExports(context: any, subpath: string, exports: any) { + // If exports is an Object with both a key starting with "." and a key not starting with "." + if (isMixedExports(exports)) { + // throw an Invalid Package Configuration error. + throw new InvalidConfigurationError( + context, + 'All keys must either start with ./, or without one.' + ); + } + + // If subpath is equal to ".", then + if (subpath === '.') { + // Let mainExport be undefined. + let mainExport: string | string[] | Record | undefined; + // If exports is a String or Array, or an Object containing no keys starting with ".", then + if (typeof exports === 'string' || Array.isArray(exports) || isConditions(exports)) { + // Set mainExport to exports + mainExport = exports; + // Otherwise if exports is an Object containing a "." property, then + } else if (isMappings(exports)) { + // Set mainExport to exports["."] + mainExport = exports['.']; + } + + // If mainExport is not undefined, then + if (mainExport) { + // Let resolved be the result of PACKAGE_TARGET_RESOLVE with target = mainExport + const resolved = await resolvePackageTarget(context, { + target: mainExport, + patternMatch: '', + isImports: false + }); + // If resolved is not null or undefined, return resolved. + if (resolved) { + return resolved; + } + } + + // Otherwise, if exports is an Object and all keys of exports start with ".", then + } else if (isMappings(exports)) { + // Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE + const resolvedMatch = await resolvePackageImportsExports(context, { + matchKey: subpath, + matchObj: exports, + isImports: false + }); + + // If resolved is not null or undefined, return resolved. + if (resolvedMatch) { + return resolvedMatch; + } + } + + // Throw a Package Path Not Exported error. + throw new InvalidModuleSpecifierError(context); +} + +export default resolvePackageExports; diff --git a/packages/node-resolve/src/package/resolvePackageImports.js b/packages/node-resolve/src/package/resolvePackageImports.ts similarity index 59% rename from packages/node-resolve/src/package/resolvePackageImports.js rename to packages/node-resolve/src/package/resolvePackageImports.ts index e4d405d0e..00b85058e 100644 --- a/packages/node-resolve/src/package/resolvePackageImports.js +++ b/packages/node-resolve/src/package/resolvePackageImports.ts @@ -3,16 +3,26 @@ import { pathToFileURL } from 'url'; import { createBaseErrorMsg, findPackageJson, InvalidModuleSpecifierError } from './utils'; import resolvePackageImportsExports from './resolvePackageImportsExports'; +interface ParamObject { + importSpecifier: string; + importer: string; + moduleDirs: readonly string[]; + conditions: readonly string[]; + resolveId: (id: string) => any; +} + async function resolvePackageImports({ importSpecifier, importer, moduleDirs, conditions, resolveId -}) { +}: ParamObject) { const result = await findPackageJson(importer, moduleDirs); if (!result) { - throw new Error(createBaseErrorMsg('. Could not find a parent package.json.')); + throw new Error( + `${createBaseErrorMsg(importSpecifier, importer)}. Could not find a parent package.json.` + ); } const { pkgPath, pkgJsonPath, pkgJson } = result; @@ -27,19 +37,28 @@ async function resolvePackageImports({ resolveId }; - const { imports } = pkgJson; - if (!imports) { - throw new InvalidModuleSpecifierError(context, true); + // Assert: specifier begins with "#". + if (!importSpecifier.startsWith('#')) { + throw new InvalidModuleSpecifierError(context, true, 'Invalid import specifier.'); } + // If specifier is exactly equal to "#" or starts with "#/", then if (importSpecifier === '#' || importSpecifier.startsWith('#/')) { + // Throw an Invalid Module Specifier error. throw new InvalidModuleSpecifierError(context, true, 'Invalid import specifier.'); } + const { imports } = pkgJson; + if (!imports) { + throw new InvalidModuleSpecifierError(context, true); + } + + // Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL). + // If packageURL is not null, then return resolvePackageImportsExports(context, { matchKey: importSpecifier, matchObj: imports, - internal: true + isImports: true }); } diff --git a/packages/node-resolve/src/package/resolvePackageImportsExports.js b/packages/node-resolve/src/package/resolvePackageImportsExports.js deleted file mode 100644 index 029937298..000000000 --- a/packages/node-resolve/src/package/resolvePackageImportsExports.js +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable no-await-in-loop */ -import resolvePackageTarget from './resolvePackageTarget'; - -import { InvalidModuleSpecifierError } from './utils'; - -async function resolvePackageImportsExports(context, { matchKey, matchObj, internal }) { - if (!matchKey.endsWith('*') && matchKey in matchObj) { - const target = matchObj[matchKey]; - const resolved = await resolvePackageTarget(context, { target, subpath: '', internal }); - return resolved; - } - - const expansionKeys = Object.keys(matchObj) - .filter((k) => k.endsWith('/') || k.endsWith('*')) - .sort((a, b) => b.length - a.length); - - for (const expansionKey of expansionKeys) { - const prefix = expansionKey.substring(0, expansionKey.length - 1); - - if (expansionKey.endsWith('*') && matchKey.startsWith(prefix)) { - const target = matchObj[expansionKey]; - const subpath = matchKey.substring(expansionKey.length - 1); - const resolved = await resolvePackageTarget(context, { - target, - subpath, - pattern: true, - internal - }); - return resolved; - } - - if (matchKey.startsWith(expansionKey)) { - const target = matchObj[expansionKey]; - const subpath = matchKey.substring(expansionKey.length); - - const resolved = await resolvePackageTarget(context, { target, subpath, internal }); - return resolved; - } - } - - throw new InvalidModuleSpecifierError(context, internal); -} - -export default resolvePackageImportsExports; diff --git a/packages/node-resolve/src/package/resolvePackageImportsExports.ts b/packages/node-resolve/src/package/resolvePackageImportsExports.ts new file mode 100644 index 000000000..57fa60543 --- /dev/null +++ b/packages/node-resolve/src/package/resolvePackageImportsExports.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-await-in-loop */ +import resolvePackageTarget from './resolvePackageTarget'; + +import { InvalidModuleSpecifierError } from './utils'; + +/** + * Implementation of Node's `PATTERN_KEY_COMPARE` function + */ +function nodePatternKeyCompare(keyA: string, keyB: string) { + // Let baseLengthA be the index of "*" in keyA plus one, if keyA contains "*", or the length of keyA otherwise. + const baseLengthA = keyA.includes('*') ? keyA.indexOf('*') + 1 : keyA.length; + // Let baseLengthB be the index of "*" in keyB plus one, if keyB contains "*", or the length of keyB otherwise. + const baseLengthB = keyB.includes('*') ? keyB.indexOf('*') + 1 : keyB.length; + + // if baseLengthA is greater, return -1, if lower 1 + const rval = baseLengthB - baseLengthA; + if (rval !== 0) return rval; + + // If keyA does not contain "*", return 1. + if (!keyA.includes('*')) return 1; + // If keyB does not contain "*", return -1. + if (!keyB.includes('*')) return -1; + + // If the length of keyA is greater than the length of keyB, return -1. + // If the length of keyB is greater than the length of keyA, return 1. + // --> both keys do not contain * so baseLength of both is the key length, so this is already handled by `rval` above + + // Else Return 0. + return 0; +} + +interface ParamObject { + matchKey: string; + matchObj: Record; + isImports?: boolean; +} +async function resolvePackageImportsExports( + context: any, + { matchKey, matchObj, isImports }: ParamObject +) { + // If matchKey is a key of matchObj and does not contain "*", then + if (!matchKey.includes('*') && matchKey in matchObj) { + // Let target be the value of matchObj[matchKey]. + const target = matchObj[matchKey]; + // Return the result of PACKAGE_TARGET_RESOLVE(packageURL, target, null, isImports, conditions). + const resolved = await resolvePackageTarget(context, { target, patternMatch: '', isImports }); + return resolved; + } + + // Let expansionKeys be the list of keys of matchObj containing only a single "*" + const expansionKeys = Object.keys(matchObj) + // Assert: ends with "/" or contains only a single "*". + .filter((k) => k.endsWith('/') || k.includes('*')) + // sorted by the sorting function PATTERN_KEY_COMPARE which orders in descending order of specificity. + .sort(nodePatternKeyCompare); + + // For each key expansionKey in expansionKeys, do + for (const expansionKey of expansionKeys) { + const indexOfAsterisk = expansionKey.indexOf('*'); + // Let patternBase be the substring of expansionKey up to but excluding the first "*" character. + const patternBase = + indexOfAsterisk === -1 ? expansionKey : expansionKey.substring(0, indexOfAsterisk); + + // If matchKey starts with but is not equal to patternBase, then + if (matchKey.startsWith(patternBase) && matchKey !== patternBase) { + // Let patternTrailer be the substring of expansionKey from the index after the first "*" character. + const patternTrailer = + indexOfAsterisk !== -1 ? expansionKey.substring(indexOfAsterisk + 1) : ''; + + // If patternTrailer has zero length, + if ( + patternTrailer.length === 0 || + // or if matchKey ends with patternTrailer and the length of matchKey is greater than or equal to the length of expansionKey, then + (matchKey.endsWith(patternTrailer) && matchKey.length >= expansionKey.length) + ) { + // Let target be the value of matchObj[expansionKey]. + const target = matchObj[expansionKey]; + // Let patternMatch be the substring of matchKey starting at the index of the length of patternBase up to the length + // of matchKey minus the length of patternTrailer. + const patternMatch = matchKey.substring( + patternBase.length, + matchKey.length - patternTrailer.length + ); + // Return the result of PACKAGE_TARGET_RESOLVE + const resolved = await resolvePackageTarget(context, { + target, + patternMatch, + isImports + }); + return resolved; + } + } + } + + throw new InvalidModuleSpecifierError(context, isImports); +} + +export default resolvePackageImportsExports; diff --git a/packages/node-resolve/src/package/resolvePackageTarget.js b/packages/node-resolve/src/package/resolvePackageTarget.js deleted file mode 100644 index 4deee1a8f..000000000 --- a/packages/node-resolve/src/package/resolvePackageTarget.js +++ /dev/null @@ -1,114 +0,0 @@ -/* eslint-disable no-await-in-loop, no-undefined */ -import { pathToFileURL } from 'url'; - -import { isUrl, InvalidModuleSpecifierError, InvalidPackageTargetError } from './utils'; - -function includesInvalidSegments(pathSegments, moduleDirs) { - return pathSegments - .split('/') - .slice(1) - .some((t) => ['.', '..', ...moduleDirs].includes(t)); -} - -async function resolvePackageTarget(context, { target, subpath, pattern, internal }) { - if (typeof target === 'string') { - if (!pattern && subpath.length > 0 && !target.endsWith('/')) { - throw new InvalidModuleSpecifierError(context); - } - - if (!target.startsWith('./')) { - if (internal && !['/', '../'].some((p) => target.startsWith(p)) && !isUrl(target)) { - // this is a bare package import, remap it and resolve it using regular node resolve - if (pattern) { - const result = await context.resolveId( - target.replace(/\*/g, subpath), - context.pkgURL.href - ); - return result ? pathToFileURL(result.location).href : null; - } - - const result = await context.resolveId(`${target}${subpath}`, context.pkgURL.href); - return result ? pathToFileURL(result.location).href : null; - } - throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`); - } - - if (includesInvalidSegments(target, context.moduleDirs)) { - throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`); - } - - const resolvedTarget = new URL(target, context.pkgURL); - if (!resolvedTarget.href.startsWith(context.pkgURL.href)) { - throw new InvalidPackageTargetError( - context, - `Resolved to ${resolvedTarget.href} which is outside package ${context.pkgURL.href}` - ); - } - - if (includesInvalidSegments(subpath, context.moduleDirs)) { - throw new InvalidModuleSpecifierError(context); - } - - if (pattern) { - return resolvedTarget.href.replace(/\*/g, subpath); - } - return new URL(subpath, resolvedTarget).href; - } - - if (Array.isArray(target)) { - let lastError; - for (const item of target) { - try { - const resolved = await resolvePackageTarget(context, { - target: item, - subpath, - pattern, - internal - }); - - // return if defined or null, but not undefined - if (resolved !== undefined) { - return resolved; - } - } catch (error) { - if (!(error instanceof InvalidPackageTargetError)) { - throw error; - } else { - lastError = error; - } - } - } - - if (lastError) { - throw lastError; - } - return null; - } - - if (target && typeof target === 'object') { - for (const [key, value] of Object.entries(target)) { - if (key === 'default' || context.conditions.includes(key)) { - const resolved = await resolvePackageTarget(context, { - target: value, - subpath, - pattern, - internal - }); - - // return if defined or null, but not undefined - if (resolved !== undefined) { - return resolved; - } - } - } - return undefined; - } - - if (target === null) { - return null; - } - - throw new InvalidPackageTargetError(context, `Invalid exports field.`); -} - -export default resolvePackageTarget; diff --git a/packages/node-resolve/src/package/resolvePackageTarget.ts b/packages/node-resolve/src/package/resolvePackageTarget.ts new file mode 100644 index 000000000..a065c729e --- /dev/null +++ b/packages/node-resolve/src/package/resolvePackageTarget.ts @@ -0,0 +1,173 @@ +/* eslint-disable no-await-in-loop, no-undefined */ +import { pathToFileURL } from 'url'; + +import { isUrl, InvalidModuleSpecifierError, InvalidPackageTargetError } from './utils'; + +/** + * Check for invalid path segments + */ +function includesInvalidSegments(pathSegments: readonly string[], moduleDirs: readonly string[]) { + const invalidSegments = ['', '.', '..', ...moduleDirs]; + + // contains any "", ".", "..", or "node_modules" segments, including percent encoded variants + return pathSegments.some( + (v) => invalidSegments.includes(v) || invalidSegments.includes(decodeURI(v)) + ); +} + +interface ParamObject { + target: any; + patternMatch?: string; + isImports?: boolean; +} + +async function resolvePackageTarget( + context: any, + { target, patternMatch, isImports }: ParamObject +): Promise { + // If target is a String, then + if (typeof target === 'string') { + // If target does not start with "./", then + if (!target.startsWith('./')) { + // If isImports is false, or if target starts with "../" or "/", or if target is a valid URL, then + if (!isImports || ['/', '../'].some((p) => target.startsWith(p)) || isUrl(target)) { + // Throw an Invalid Package Target error. + throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`); + } + + // If patternMatch is a String, then + if (typeof patternMatch === 'string') { + // Return PACKAGE_RESOLVE(target with every instance of "*" replaced by patternMatch, packageURL + "/") + const result = await context.resolveId( + target.replace(/\*/g, patternMatch), + context.pkgURL.href + ); + return result ? pathToFileURL(result.location).href : null; + } + + // Return PACKAGE_RESOLVE(target, packageURL + "/"). + const result = await context.resolveId(target, context.pkgURL.href); + return result ? pathToFileURL(result.location).href : null; + } + + // TODO: Drop if we do not support Node <= 16 anymore + // This behavior was removed in Node 17 (deprecated in Node 14), see DEP0148 + if (context.allowExportsFolderMapping) { + target = target.replace(/\/$/, '/*'); + } + + // If target split on "/" or "\" + { + const pathSegments = target.split(/\/|\\/); + // after the first "." segment + const firstDot = pathSegments.indexOf('.'); + firstDot !== -1 && pathSegments.slice(firstDot); + if ( + firstDot !== -1 && + firstDot < pathSegments.length - 1 && + includesInvalidSegments(pathSegments.slice(firstDot + 1), context.moduleDirs) + ) { + throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`); + } + } + + // Let resolvedTarget be the URL resolution of the concatenation of packageURL and target. + const resolvedTarget = new URL(target, context.pkgURL); + // Assert: resolvedTarget is contained in packageURL. + if (!resolvedTarget.href.startsWith(context.pkgURL.href)) { + throw new InvalidPackageTargetError( + context, + `Resolved to ${resolvedTarget.href} which is outside package ${context.pkgURL.href}` + ); + } + + // If patternMatch is null, then + if (!patternMatch) { + // Return resolvedTarget. + return resolvedTarget; + } + + // If patternMatch split on "/" or "\" contains invalid segments + if (includesInvalidSegments(patternMatch.split(/\/|\\/), context.moduleDirs)) { + // throw an Invalid Module Specifier error. + throw new InvalidModuleSpecifierError(context); + } + + // Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch. + return resolvedTarget.href.replace(/\*/g, patternMatch); + } + + // Otherwise, if target is an Array, then + if (Array.isArray(target)) { + // If _target.length is zero, return null. + if (target.length === 0) { + return null; + } + + let lastError = null; + // For each item in target, do + for (const item of target) { + // Let resolved be the result of PACKAGE_TARGET_RESOLVE of the item + // continuing the loop on any Invalid Package Target error. + try { + const resolved = await resolvePackageTarget(context, { + target: item, + patternMatch, + isImports + }); + // If resolved is undefined, continue the loop. + // Else Return resolved. + if (resolved !== undefined) { + return resolved; + } + } catch (error) { + if (!(error instanceof InvalidPackageTargetError)) { + throw error; + } else { + lastError = error; + } + } + } + // Return or throw the last fallback resolution null return or error + if (lastError) { + throw lastError; + } + return null; + } + + // Otherwise, if target is a non-null Object, then + if (target && typeof target === 'object') { + // For each property of target + for (const [key, value] of Object.entries(target)) { + // If exports contains any index property keys, as defined in ECMA-262 6.1.7 Array Index, throw an Invalid Package Configuration error. + // TODO: We do not check if the key is a number here... + // If key equals "default" or conditions contains an entry for the key, then + if (key === 'default' || context.conditions.includes(key)) { + // Let targetValue be the value of the property in target. + // Let resolved be the result of PACKAGE_TARGET_RESOLVE of the targetValue + const resolved = await resolvePackageTarget(context, { + target: value, + patternMatch, + isImports + }); + // If resolved is equal to undefined, continue the loop. + // Return resolved. + if (resolved !== undefined) { + return resolved; + } + } + } + // Return undefined. + return undefined; + } + + // Otherwise, if target is null, return null. + if (target === null) { + return null; + } + + // Otherwise throw an Invalid Package Target error. + throw new InvalidPackageTargetError(context, `Invalid exports field.`); +} + +export default resolvePackageTarget; diff --git a/packages/node-resolve/src/package/utils.js b/packages/node-resolve/src/package/utils.ts similarity index 61% rename from packages/node-resolve/src/package/utils.js rename to packages/node-resolve/src/package/utils.ts index 31efc8d1e..00a71a791 100644 --- a/packages/node-resolve/src/package/utils.js +++ b/packages/node-resolve/src/package/utils.ts @@ -4,11 +4,11 @@ import fs from 'fs'; import { fileExists } from '../fs'; -function isModuleDir(current, moduleDirs) { +function isModuleDir(current: string, moduleDirs: readonly string[]) { return moduleDirs.some((dir) => current.endsWith(dir)); } -export async function findPackageJson(base, moduleDirs) { +export async function findPackageJson(base: string, moduleDirs: readonly string[]) { const { root } = path.parse(base); let current = base; @@ -23,7 +23,7 @@ export async function findPackageJson(base, moduleDirs) { return null; } -export function isUrl(str) { +export function isUrl(str: string) { try { return !!new URL(str); } catch (_) { @@ -31,46 +31,55 @@ export function isUrl(str) { } } -export function isConditions(exports) { +/** + * Conditions is an export object where all keys are conditions like 'node' (aka do not with '.') + */ +export function isConditions(exports: any) { return typeof exports === 'object' && Object.keys(exports).every((k) => !k.startsWith('.')); } -export function isMappings(exports) { +/** + * Mappings is an export object where all keys start with '. + */ +export function isMappings(exports: any) { return typeof exports === 'object' && !isConditions(exports); } -export function isMixedExports(exports) { +/** + * Check for mixed exports, which are exports where some keys start with '.' and some do not + */ +export function isMixedExports(exports: Record) { const keys = Object.keys(exports); return keys.some((k) => k.startsWith('.')) && keys.some((k) => !k.startsWith('.')); } -export function createBaseErrorMsg(importSpecifier, importer) { +export function createBaseErrorMsg(importSpecifier: string, importer: string) { return `Could not resolve import "${importSpecifier}" in ${importer}`; } -export function createErrorMsg(context, reason, internal) { +export function createErrorMsg(context: any, reason?: string, isImports?: boolean) { const { importSpecifier, importer, pkgJsonPath } = context; const base = createBaseErrorMsg(importSpecifier, importer); - const field = internal ? 'imports' : 'exports'; + const field = isImports ? 'imports' : 'exports'; return `${base} using ${field} defined in ${pkgJsonPath}.${reason ? ` ${reason}` : ''}`; } export class ResolveError extends Error {} export class InvalidConfigurationError extends ResolveError { - constructor(context, reason) { + constructor(context: any, reason?: string) { super(createErrorMsg(context, `Invalid "exports" field. ${reason}`)); } } export class InvalidModuleSpecifierError extends ResolveError { - constructor(context, internal, reason) { - super(createErrorMsg(context, reason, internal)); + constructor(context: any, isImports?: boolean, reason?: string) { + super(createErrorMsg(context, reason, isImports)); } } export class InvalidPackageTargetError extends ResolveError { - constructor(context, reason) { + constructor(context: any, reason?: string) { super(createErrorMsg(context, reason)); } } diff --git a/packages/node-resolve/src/resolveImportSpecifiers.js b/packages/node-resolve/src/resolveImportSpecifiers.js index 748762384..d29af56c1 100644 --- a/packages/node-resolve/src/resolveImportSpecifiers.js +++ b/packages/node-resolve/src/resolveImportSpecifiers.js @@ -115,7 +115,8 @@ async function resolveWithExportMap({ moduleDirectories, modulePaths, rootDir, - ignoreSideEffectsForRoot + ignoreSideEffectsForRoot, + allowExportsFolderMapping }) { if (importSpecifier.startsWith('#')) { // this is a package internal import, resolve using package imports field @@ -204,6 +205,7 @@ async function resolveWithExportMap({ moduleDirs: moduleDirectories, pkgURL, pkgJsonPath, + allowExportsFolderMapping, conditions: exportConditions }; const resolvedPackageExport = await resolvePackageExports(context, subpath, pkgJson.exports); @@ -284,7 +286,8 @@ export default async function resolveImportSpecifiers({ moduleDirectories, modulePaths, rootDir, - ignoreSideEffectsForRoot + ignoreSideEffectsForRoot, + allowExportsFolderMapping }) { try { const exportMapRes = await resolveWithExportMap({ @@ -300,7 +303,8 @@ export default async function resolveImportSpecifiers({ moduleDirectories, modulePaths, rootDir, - ignoreSideEffectsForRoot + ignoreSideEffectsForRoot, + allowExportsFolderMapping }); if (exportMapRes) return exportMapRes; } catch (error) { diff --git a/packages/node-resolve/test/fixtures/exports-pattern-extension.js b/packages/node-resolve/test/fixtures/exports-pattern-extension.js new file mode 100644 index 000000000..e79ba97c5 --- /dev/null +++ b/packages/node-resolve/test/fixtures/exports-pattern-extension.js @@ -0,0 +1,3 @@ +import { foo } from 'exports-pattern-extension/component/foo.js'; + +export { foo } diff --git a/packages/node-resolve/test/fixtures/node_modules/exports-pattern-extension/package.json b/packages/node-resolve/test/fixtures/node_modules/exports-pattern-extension/package.json new file mode 100644 index 000000000..93fa65be9 --- /dev/null +++ b/packages/node-resolve/test/fixtures/node_modules/exports-pattern-extension/package.json @@ -0,0 +1,10 @@ +{ + "name": "exports-pattern-extension", + "type": "module", + "exports": { + "./component/*.js": { + "import": "./dist/*.mjs", + "require": "./dist/*.cjs" + } + } +} \ No newline at end of file diff --git a/packages/node-resolve/test/package-entry-points.js b/packages/node-resolve/test/package-entry-points.js index 13a44ef14..371a815a3 100644 --- a/packages/node-resolve/test/package-entry-points.js +++ b/packages/node-resolve/test/package-entry-points.js @@ -36,6 +36,19 @@ test('handles export map with fallback', async (t) => { t.is(module.exports, 'MAIN MAPPED'); }); +test('handles export map with pattern and extensions', async (t) => { + const bundle = await rollup({ + input: 'exports-pattern-extension.js', + onwarn: () => { + t.fail('No warnings were expected'); + }, + plugins: [nodeResolve()] + }); + const { module } = await testBundle(t, bundle); + + t.is(module.exports.foo, 'foo'); +}); + test('handles export map with top level mappings', async (t) => { const bundle = await rollup({ input: 'exports-top-level-mappings.js', diff --git a/packages/node-resolve/types/index.d.ts b/packages/node-resolve/types/index.d.ts index 20b35169e..6bd5db3c2 100755 --- a/packages/node-resolve/types/index.d.ts +++ b/packages/node-resolve/types/index.d.ts @@ -96,6 +96,14 @@ export interface RollupNodeResolveOptions { * @default process.cwd() */ rootDir?: string; + + /** + * Allow folder mappings in package exports (trailing /) + * This was deprecated in Node 14 and removed with Node 17, see DEP0148. + * So this option might be changed to default to `false` in a future release. + * @default true + */ + allowExportsFolderMapping?: boolean; } /**