From 165cdef666a1ac3bc55725b84bb00fbfc759b0f5 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sun, 13 Nov 2022 19:31:33 -0500 Subject: [PATCH 1/9] feat(resolve): support "fallback array" in package exports field Closes #4439 More context: https://github.com/vitejs/vite/pull/10504 --- packages/vite/package.json | 2 +- packages/vite/src/node/plugins/resolve.ts | 150 ++++++++-------------- pnpm-lock.yaml | 14 +- 3 files changed, 60 insertions(+), 106 deletions(-) diff --git a/packages/vite/package.json b/packages/vite/package.json index 4cc39e7f156009..dd8dd089cd1267 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -110,7 +110,7 @@ "postcss-import": "^15.0.0", "postcss-load-config": "^4.0.1", "postcss-modules": "^5.0.0", - "resolve.exports": "^1.1.0", + "resolve.exports": "npm:@alloc/resolve.exports@^1.1.0", "sirv": "^2.0.2", "source-map-js": "^1.0.2", "source-map-support": "^0.5.21", diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 1c1c80f573e84d..b4f187d1c27daf 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import colors from 'picocolors' import type { PartialResolvedId } from 'rollup' -import { resolve as _resolveExports } from 'resolve.exports' +import { resolveExports } from 'resolve.exports' import { hasESMSyntax } from 'mlly' import type { Plugin } from '../plugin' import { @@ -923,29 +923,28 @@ export function resolvePackageEntry( return cached } try { - let entryPoint: string | undefined | void + let entryPoints: string[] = [] - // resolve exports field with highest priority - // using https://github.com/lukeed/resolve.exports + // the exports field takes highest priority as described in + // https://nodejs.org/api/packages.html#package-entry-points if (data.exports) { - entryPoint = resolveExports(data, '.', options, targetWeb) - } - - // if exports resolved to .mjs, still resolve other fields. - // This is because .mjs files can technically import .cjs files which would - // make them invalid for pure ESM environments - so if other module/browser - // fields are present, prioritize those instead. - if ( - targetWeb && - options.browserField && - (!entryPoint || entryPoint.endsWith('.mjs')) - ) { + entryPoints = resolveExports( + data, + '.', + options, + getInlineConditions(options.conditions, targetWeb) + ) + if (!entryPoints.length) { + packageEntryFailure(id) + } + } else if (targetWeb && options.browserField) { // check browser field // https://github.com/defunctzombie/package-browser-field-spec const browserEntry = typeof data.browser === 'string' ? data.browser : isObject(data.browser) && data.browser['.'] + if (browserEntry) { // check if the package also has a "module" field. if ( @@ -968,34 +967,34 @@ export function resolvePackageEntry( const content = fs.readFileSync(resolvedBrowserEntry, 'utf-8') if (hasESMSyntax(content)) { // likely ESM, prefer browser - entryPoint = browserEntry + entryPoints[0] = browserEntry } else { // non-ESM, UMD or IIFE or CJS(!!! e.g. firebase 7.x), prefer module - entryPoint = data.module + entryPoints[0] = data.module } } } else { - entryPoint = browserEntry + entryPoints[0] = browserEntry } } } - if (!entryPoint || entryPoint.endsWith('.mjs')) { + if (!entryPoints[0]) { for (const field of options.mainFields) { if (field === 'browser') continue // already checked above if (typeof data[field] === 'string') { - entryPoint = data[field] + entryPoints[0] = data[field] break } } + entryPoints[0] ||= data.main } - entryPoint ||= data.main // try default entry when entry is not define // https://nodejs.org/api/modules.html#all-together - const entryPoints = entryPoint - ? [entryPoint] - : ['index.js', 'index.json', 'index.node'] + if (!entryPoints[0]) { + entryPoints = ['index.js', 'index.json', 'index.node'] + } for (let entry of entryPoints) { // make sure we don't get scripts when looking for sass @@ -1040,52 +1039,8 @@ function packageEntryFailure(id: string, details?: string) { ) } -const conditionalConditions = new Set(['production', 'development', 'module']) - -function resolveExports( - pkg: PackageData['data'], - key: string, - options: InternalResolveOptionsWithOverrideConditions, - targetWeb: boolean -) { - const overrideConditions = options.overrideConditions - ? new Set(options.overrideConditions) - : undefined - - const conditions = [] - if ( - (!overrideConditions || overrideConditions.has('production')) && - options.isProduction - ) { - conditions.push('production') - } - if ( - (!overrideConditions || overrideConditions.has('development')) && - !options.isProduction - ) { - conditions.push('development') - } - if ( - (!overrideConditions || overrideConditions.has('module')) && - !options.isRequire - ) { - conditions.push('module') - } - if (options.overrideConditions) { - conditions.push( - ...options.overrideConditions.filter((condition) => - conditionalConditions.has(condition) - ) - ) - } else if (options.conditions.length > 0) { - conditions.push(...options.conditions) - } - - return _resolveExports(pkg, key, { - browser: targetWeb && !conditions.includes('node'), - require: options.isRequire && !conditions.includes('import'), - conditions - }) +function getInlineConditions(conditions: string[], targetWeb: boolean) { + return targetWeb && !conditions.includes('node') ? ['browser'] : ['node'] } function resolveDeepImport( @@ -1098,56 +1053,55 @@ function resolveDeepImport( data }: PackageData, targetWeb: boolean, - options: InternalResolveOptions + options: InternalResolveOptionsWithOverrideConditions ): string | undefined { const cache = getResolvedCache(id, targetWeb) if (cache) { return cache } - let relativeId: string | undefined | void = id const { exports: exportsField, browser: browserField } = data + const { file, postfix } = splitFileAndPostfix(id) - // map relative based on exports data + let possibleFiles: string[] | undefined if (exportsField) { - if (isObject(exportsField) && !Array.isArray(exportsField)) { - // resolve without postfix (see #7098) - const { file, postfix } = splitFileAndPostfix(relativeId) - const exportsId = resolveExports(data, file, options, targetWeb) - if (exportsId !== undefined) { - relativeId = exportsId + postfix - } else { - relativeId = undefined - } - } else { - // not exposed - relativeId = undefined - } - if (!relativeId) { + // map relative based on exports data + possibleFiles = resolveExports( + data, + file, + options, + getInlineConditions(options.conditions, targetWeb), + options.overrideConditions + ) + if (!possibleFiles.length) { throw new Error( - `Package subpath '${relativeId}' is not defined by "exports" in ` + + `Package subpath '${file}' is not defined by "exports" in ` + `${path.join(dir, 'package.json')}.` ) } } else if (targetWeb && options.browserField && isObject(browserField)) { - // resolve without postfix (see #7098) - const { file, postfix } = splitFileAndPostfix(relativeId) const mapped = mapWithBrowserField(file, browserField) if (mapped) { - relativeId = mapped + postfix + possibleFiles = [mapped] } else if (mapped === false) { return (webResolvedImports[id] = browserExternalId) } } - if (relativeId) { - const resolved = tryFsResolve( - path.join(dir, relativeId), - options, - !exportsField, // try index only if no exports field - targetWeb + possibleFiles ||= [id] + if (possibleFiles[0]) { + let resolved: string | undefined + possibleFiles.some( + (file) => + (resolved = tryFsResolve( + path.join(dir, file), + options, + !exportsField, // try index only if no exports field + targetWeb + )) ) if (resolved) { + resolved += postfix isDebug && debug( `[node/deep-import] ${colors.cyan(id)} -> ${colors.dim(resolved)}` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7f8770e4973ee..3f1369b739fd2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,7 +260,7 @@ importers: postcss-load-config: ^4.0.1 postcss-modules: ^5.0.0 resolve: ^1.22.1 - resolve.exports: ^1.1.0 + resolve.exports: npm:@alloc/resolve.exports@^1.1.0 rollup: ~3.3.0 sirv: ^2.0.2 source-map-js: ^1.0.2 @@ -323,7 +323,7 @@ importers: postcss-import: 15.0.0_postcss@8.4.19 postcss-load-config: 4.0.1_postcss@8.4.19 postcss-modules: 5.0.0_postcss@8.4.19 - resolve.exports: 1.1.0 + resolve.exports: /@alloc/resolve.exports/1.1.0 sirv: 2.0.2 source-map-js: 1.0.2 source-map-support: 0.5.21 @@ -1420,6 +1420,11 @@ packages: '@algolia/requester-common': 4.13.1 dev: true + /@alloc/resolve.exports/1.1.0: + resolution: {integrity: sha512-daZJ4gBXxPUgjWjtxRp+5mU9tV6k7cSG2iKFyiPZOTxRzMRFPEe8dcTuqP+zIVSTfFpN1/SCIOMMYeYA7GwQvQ==} + engines: {node: '>=10'} + dev: true + /@ampproject/remapping/2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -7772,11 +7777,6 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - /resolve.exports/1.1.0: - resolution: {integrity: sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==} - engines: {node: '>=10'} - dev: true - /resolve/1.17.0: resolution: {integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==} dependencies: From 606f126f496386d501acf2ec7444152a895d8a53 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Mon, 14 Nov 2022 13:12:55 -0500 Subject: [PATCH 2/9] refactor: overrideConditions --- packages/vite/src/node/plugins/resolve.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index b4f187d1c27daf..6ff88dff49d541 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -579,21 +579,19 @@ function tryResolveFile( } } -export type InternalResolveOptionsWithOverrideConditions = - InternalResolveOptions & { - /** - * @deprecated In future, `conditions` will work like this. - * @internal - */ - overrideConditions?: string[] - } +export interface InternalNodeResolveOptions extends InternalResolveOptions { + /** + * When defined, only conditions defined in this array will be used. + */ + overrideConditions?: string[] +} export const idToPkgMap = new Map() export function tryNodeResolve( id: string, importer: string | null | undefined, - options: InternalResolveOptionsWithOverrideConditions, + options: InternalNodeResolveOptions, targetWeb: boolean, depsOptimizer?: DepsOptimizer, ssr?: boolean, @@ -1053,7 +1051,7 @@ function resolveDeepImport( data }: PackageData, targetWeb: boolean, - options: InternalResolveOptionsWithOverrideConditions + options: InternalNodeResolveOptions ): string | undefined { const cache = getResolvedCache(id, targetWeb) if (cache) { From a94200f853e94c8d0498c5bfbb6eea5dfd63e67d Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Mon, 14 Nov 2022 13:13:13 -0500 Subject: [PATCH 3/9] fix: add `module` condition by default --- packages/vite/src/node/plugins/resolve.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 6ff88dff49d541..796880b980c66a 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -1038,7 +1038,14 @@ function packageEntryFailure(id: string, details?: string) { } function getInlineConditions(conditions: string[], targetWeb: boolean) { - return targetWeb && !conditions.includes('node') ? ['browser'] : ['node'] + const inlineConditions = + targetWeb && !conditions.includes('node') ? ['browser'] : ['node'] + + // The "module" condition is no longer recommended, but some older + // packages may still use it. + inlineConditions.push('module') + + return inlineConditions } function resolveDeepImport( From 296fe46928341e8071313fad81a0dbe25060fd92 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Mon, 14 Nov 2022 13:14:08 -0500 Subject: [PATCH 4/9] fix: config bundling regression Bug introduced in #10683 Some packages use "require" instead of "default" for CJS entry --- packages/vite/src/node/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index e90bdd7150bced..77680d34f3c224 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -968,7 +968,7 @@ async function bundleConfigFile( mainFields: [], browserField: false, conditions: [], - overrideConditions: ['node'], + overrideConditions: ['node', 'require'], dedupe: [], extensions: DEFAULT_EXTENSIONS, preserveSymlinks: false From 4d3c6ac03cafd5014a0592b7480be4ddc34a5184 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Mon, 14 Nov 2022 13:32:13 -0500 Subject: [PATCH 5/9] fix: respect `overrideConditions` and `isRequire` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …in the `getInlineConditions` function. --- packages/vite/src/node/config.ts | 8 +--- packages/vite/src/node/plugins/resolve.ts | 51 ++++++++++++++++------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 77680d34f3c224..23f4b8c40371e4 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -48,11 +48,7 @@ import { DEFAULT_MAIN_FIELDS, ENV_ENTRY } from './constants' -import type { - InternalResolveOptions, - InternalResolveOptionsWithOverrideConditions, - ResolveOptions -} from './plugins/resolve' +import type { InternalResolveOptions, ResolveOptions } from './plugins/resolve' import { resolvePlugin, tryNodeResolve } from './plugins/resolve' import type { LogLevel, Logger } from './logger' import { createLogger } from './logger' @@ -958,7 +954,7 @@ async function bundleConfigFile( { name: 'externalize-deps', setup(build) { - const options: InternalResolveOptionsWithOverrideConditions = { + const options: InternalResolveOptions = { root: path.dirname(fileName), isBuild: true, isProduction: true, diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 796880b980c66a..4934192697c898 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -107,6 +107,7 @@ export interface InternalResolveOptions extends Required { shouldExternalize?: (id: string) => boolean | undefined // Check this resolve is called from `hookNodeResolve` in SSR isHookNodeResolve?: boolean + overrideConditions?: string[] } export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { @@ -579,19 +580,12 @@ function tryResolveFile( } } -export interface InternalNodeResolveOptions extends InternalResolveOptions { - /** - * When defined, only conditions defined in this array will be used. - */ - overrideConditions?: string[] -} - export const idToPkgMap = new Map() export function tryNodeResolve( id: string, importer: string | null | undefined, - options: InternalNodeResolveOptions, + options: InternalResolveOptions, targetWeb: boolean, depsOptimizer?: DepsOptimizer, ssr?: boolean, @@ -930,7 +924,8 @@ export function resolvePackageEntry( data, '.', options, - getInlineConditions(options.conditions, targetWeb) + getInlineConditions(options, targetWeb), + options.overrideConditions ) if (!entryPoints.length) { packageEntryFailure(id) @@ -1037,13 +1032,39 @@ function packageEntryFailure(id: string, details?: string) { ) } -function getInlineConditions(conditions: string[], targetWeb: boolean) { - const inlineConditions = - targetWeb && !conditions.includes('node') ? ['browser'] : ['node'] +/** + * This generates conditions that aren't inferred by `resolveExports` + * from the `options` object. + */ +function getInlineConditions( + options: InternalResolveOptions, + targetWeb: boolean +) { + const inlineConditions: string[] = [] + + const conditions: readonly string[] = + options.overrideConditions || options.conditions + + if (targetWeb) { + if (!conditions.includes('node')) { + inlineConditions.push('browser') + } + } else if (!conditions.includes('browser')) { + inlineConditions.push('node') + } // The "module" condition is no longer recommended, but some older // packages may still use it. - inlineConditions.push('module') + if (!options.isRequire && !conditions.includes('require')) { + inlineConditions.push('module') + } + + // The "overrideConditions" array can add arbitrary conditions. + options.overrideConditions?.forEach((condition) => { + if (!inlineConditions.includes(condition)) { + inlineConditions.push(condition) + } + }) return inlineConditions } @@ -1058,7 +1079,7 @@ function resolveDeepImport( data }: PackageData, targetWeb: boolean, - options: InternalNodeResolveOptions + options: InternalResolveOptions ): string | undefined { const cache = getResolvedCache(id, targetWeb) if (cache) { @@ -1075,7 +1096,7 @@ function resolveDeepImport( data, file, options, - getInlineConditions(options.conditions, targetWeb), + getInlineConditions(options, targetWeb), options.overrideConditions ) if (!possibleFiles.length) { From bb4fbd0dbd387e0d0e4769f44c15ccbf5927e220 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Mon, 14 Nov 2022 14:17:57 -0500 Subject: [PATCH 6/9] fix: try `resolveExports` call with postfix included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and avoid breaking certain `tryFsResolve` calls too (like with `es5-ext`) --- packages/vite/src/node/plugins/resolve.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 4934192697c898..5cabc36c8d69d4 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -1099,6 +1099,19 @@ function resolveDeepImport( getInlineConditions(options, targetWeb), options.overrideConditions ) + if (postfix) { + if (possibleFiles.length) { + possibleFiles = possibleFiles.map((f) => f + postfix) + } else { + possibleFiles = resolveExports( + data, + file + postfix, + options, + getInlineConditions(options, targetWeb), + options.overrideConditions + ) + } + } if (!possibleFiles.length) { throw new Error( `Package subpath '${file}' is not defined by "exports" in ` + @@ -1108,7 +1121,7 @@ function resolveDeepImport( } else if (targetWeb && options.browserField && isObject(browserField)) { const mapped = mapWithBrowserField(file, browserField) if (mapped) { - possibleFiles = [mapped] + possibleFiles = [mapped + postfix] } else if (mapped === false) { return (webResolvedImports[id] = browserExternalId) } @@ -1127,7 +1140,6 @@ function resolveDeepImport( )) ) if (resolved) { - resolved += postfix isDebug && debug( `[node/deep-import] ${colors.cyan(id)} -> ${colors.dim(resolved)}` From 5ed1379ac7442ce93d871600e3a5be79878c8810 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sun, 17 Jul 2022 09:13:42 -0400 Subject: [PATCH 7/9] fix(resolve): make tryNodeResolve more robust The "{id}/package.json" lookup done by `resolvePackageData` is insufficient for certain edge cases, like when "node_modules/{dep}" is linked to a directory without a package.json in it. With this PR, you can now import any file from node_modules even if it has no package.json file associated with it. This mirrors the same capability in Node's resolution algorithm. In addition to supporting more edge cases, this new implementation might also be faster in some cases, since we are doing less lookups than compared to the previous behavior of calling `resolvePackageData` for every path in the `possiblePkgIds` array. --- packages/vite/src/node/packages.ts | 37 ++ packages/vite/src/node/plugins/resolve.ts | 380 ++++++++++++------ playground/resolve/__tests__/resolve.spec.ts | 9 + playground/resolve/index.html | 18 + playground/resolve/package.json | 1 + .../pr-9170/unpackaged-deep-import.js | 1 + .../node_modules/pr-9170/unpackaged-file.js | 1 + .../pr-9170/unpackaged-index-file/index.js | 1 + .../pr-9170/node_modules/unpackaged-file.js | 1 + pnpm-lock.yaml | 2 + 10 files changed, 324 insertions(+), 127 deletions(-) create mode 100644 playground/resolve/pr-9170/node_modules/pr-9170/unpackaged-deep-import.js create mode 100644 playground/resolve/pr-9170/node_modules/pr-9170/unpackaged-file.js create mode 100644 playground/resolve/pr-9170/node_modules/pr-9170/unpackaged-index-file/index.js create mode 100644 playground/resolve/pr-9170/node_modules/unpackaged-file.js diff --git a/packages/vite/src/node/packages.ts b/packages/vite/src/node/packages.ts index e1a85bff441212..996809db638d5e 100644 --- a/packages/vite/src/node/packages.ts +++ b/packages/vite/src/node/packages.ts @@ -163,3 +163,40 @@ export function watchPackageDataPlugin(config: ResolvedConfig): Plugin { } } } + +export function findPackageJson(dir: string): string | null { + // Stop looking at node_modules directory. + if (path.basename(dir) === 'node_modules') { + return null + } + const pkgPath = path.join(dir, 'package.json') + if (fs.existsSync(pkgPath)) { + return pkgPath + } + const parentDir = path.dirname(dir) + return parentDir !== dir ? findPackageJson(parentDir) : null +} + +const workspaceRootFiles = ['lerna.json', 'pnpm-workspace.yaml', '.git'] + +export function isWorkspaceRoot( + dir: string, + preserveSymlinks?: boolean, + packageCache?: PackageCache +): boolean { + const files = fs.readdirSync(dir) + if (files.some((file) => workspaceRootFiles.includes(file))) { + return true // Found a lerna/pnpm workspace or git repository. + } + if (files.includes('package.json')) { + const workspacePkg = loadPackageData( + path.join(dir, 'package.json'), + preserveSymlinks, + packageCache + ) + if (workspacePkg?.data.workspaces) { + return true // Found a npm/yarn workspace. + } + } + return false +} diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 5cabc36c8d69d4..c2a7cc21cec0e4 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import { Module } from 'node:module' import colors from 'picocolors' import type { PartialResolvedId } from 'rollup' import { resolveExports } from 'resolve.exports' @@ -42,7 +43,12 @@ import { import { optimizedDepInfoFromFile, optimizedDepInfoFromId } from '../optimizer' import type { DepsOptimizer } from '../optimizer' import type { SSROptions } from '..' -import type { PackageCache, PackageData } from '../packages' +import { + findPackageJson, + isWorkspaceRoot, + PackageCache, + PackageData +} from '../packages' import { loadPackageData, resolvePackageData } from '../packages' import { isWorkerRequest } from './worker' @@ -495,13 +501,17 @@ function tryFsResolve( } } + if (!tryIndex) { + return + } + if ( postfix && (res = tryResolveFile( fsPath, '', options, - tryIndex, + true, targetWeb, options.tryPrefix, options.skipPackageJson @@ -515,7 +525,7 @@ function tryFsResolve( file, postfix, options, - tryIndex, + true, targetWeb, options.tryPrefix, options.skipPackageJson @@ -551,8 +561,10 @@ function tryResolveFile( } } } - const index = tryFsResolve(file + '/index', options) - if (index) return index + postfix + const indexFile = tryIndexFile(file, targetWeb, options) + if (indexFile) { + return indexFile + postfix + } } } @@ -580,8 +592,23 @@ function tryResolveFile( } } +function tryIndexFile( + dir: string, + targetWeb: boolean, + options: InternalResolveOptions +) { + if (!options.skipPackageJson) { + options = { ...options, skipPackageJson: true } + } + return tryFsResolve(dir + '/index', options, false, targetWeb) +} + export const idToPkgMap = new Map() +const lookupNodeModules = (Module as any)._nodeModulePaths as { + (cwd: string): string[] +} + export function tryNodeResolve( id: string, importer: string | null | undefined, @@ -601,37 +628,15 @@ export function tryNodeResolve( // 'foo' => '' & 'foo' const lastArrowIndex = id.lastIndexOf('>') const nestedRoot = id.substring(0, lastArrowIndex).trim() - const nestedPath = id.substring(lastArrowIndex + 1).trim() - - const possiblePkgIds: string[] = [] - for (let prevSlashIndex = -1; ; ) { - let slashIndex = nestedPath.indexOf('/', prevSlashIndex + 1) - if (slashIndex < 0) { - slashIndex = nestedPath.length - } - - const part = nestedPath.slice( - prevSlashIndex + 1, - (prevSlashIndex = slashIndex) - ) - if (!part) { - break - } - - // Assume path parts with an extension are not package roots, except for the - // first path part (since periods are sadly allowed in package names). - // At the same time, skip the first path part if it begins with "@" - // (since "@foo/bar" should be treated as the top-level path). - if (possiblePkgIds.length ? path.extname(part) : part[0] === '@') { - continue - } - const possiblePkgId = nestedPath.slice(0, slashIndex) - possiblePkgIds.push(possiblePkgId) + if (lastArrowIndex !== -1) { + id = id.substring(lastArrowIndex + 1).trim() } + const basePkgId = id.split('/', id[0] === '@' ? 2 : 1).join('/') + let basedir: string - if (dedupe?.some((id) => possiblePkgIds.includes(id))) { + if (dedupe?.includes(basePkgId)) { basedir = root } else if ( importer && @@ -643,95 +648,210 @@ export function tryNodeResolve( basedir = root } - // nested node module, step-by-step resolve to the basedir of the nestedPath + // resolve a chain of packages notated by '>' in the id if (nestedRoot) { basedir = nestedResolveFrom(nestedRoot, basedir, preserveSymlinks) } - // nearest package.json - let nearestPkg: PackageData | undefined - // nearest package.json that may have the `exports` field - let pkg: PackageData | undefined + let resolvedPkg: PackageData | undefined + let resolvedPkgId: string | undefined + let resolvedPkgType: string | undefined + let resolvedId: string | undefined + let resolver: typeof resolvePackageEntry - let pkgId = possiblePkgIds.reverse().find((pkgId) => { - nearestPkg = resolvePackageData( - pkgId, - basedir, - preserveSymlinks, - packageCache - )! - return nearestPkg - })! + const nodeModules = lookupNodeModules(basedir) + for (const nodeModulesDir of nodeModules) { + if (!fs.existsSync(nodeModulesDir)) { + continue + } - const rootPkgId = possiblePkgIds[0] - const rootPkg = resolvePackageData( - rootPkgId, - basedir, - preserveSymlinks, - packageCache - )! - if (rootPkg?.data?.exports) { - pkg = rootPkg - pkgId = rootPkgId - } else { - pkg = nearestPkg - } + const entryPath = path.join(nodeModulesDir, id) + const nearestPkgPath = findPackageJson(entryPath) + if (nearestPkgPath) { + resolvedPkg = loadPackageData( + nearestPkgPath, + preserveSymlinks, + packageCache + ) + resolvedPkgId = path.dirname( + path.relative(nodeModulesDir, nearestPkgPath) + ) - if (!pkg || !nearestPkg) { - // if import can't be found, check if it's an optional peer dep. - // if so, we can resolve to a special id that errors only when imported. - if ( - !options.isHookNodeResolve && - basedir !== root && // root has no peer dep - !isBuiltin(nestedPath) && - !nestedPath.includes('\0') && - bareImportRE.test(nestedPath) - ) { - // find package.json with `name` as main - const mainPackageJson = lookupFile(basedir, ['package.json'], { - predicate: (content) => !!JSON.parse(content).name - }) - if (mainPackageJson) { - const mainPkg = JSON.parse(mainPackageJson) - if ( - mainPkg.peerDependencies?.[nestedPath] && - mainPkg.peerDependenciesMeta?.[nestedPath]?.optional - ) { - return { - id: `${optionalPeerDepId}:${nestedPath}:${mainPkg.name}` + // Always use the nearest package.json to determine whether a + // ".js" module is ESM or CJS. + resolvedPkgType = resolvedPkg.data.type + + // If the nearest package.json has no "exports" field, then we + // need to check the dependency's root directory for an exports + // field, since that should take precedence (see #10371). + if (resolvedPkgId !== basePkgId) { + try { + const basePkgPath = path.join( + nodeModulesDir, + basePkgId, + 'package.json' + ) + const basePkg = loadPackageData( + basePkgPath, + preserveSymlinks, + packageCache + ) + if (basePkg.data.exports) { + resolvedPkg = basePkg + resolvedPkgId = path.dirname( + path.relative(nodeModulesDir, basePkgPath) + ) } + } catch (e) { + if (e.code !== 'ENOENT') { + throw e + } + } + } + + let usedId: string + if (resolvedPkgId === id) { + // Use the main entry point + resolver = resolvePackageEntry + usedId = id + } else { + // Use a deep entry point + resolver = resolveDeepImport + usedId = '.' + id.slice(resolvedPkgId.length) + } + + try { + resolvedId = resolver(usedId, resolvedPkg, targetWeb, options) + if (resolvedId) { + break + } + } catch (err) { + if (!options.tryEsmOnly) { + throw err } } + if (options.tryEsmOnly) { + resolvedId = resolver(usedId, resolvedPkg, targetWeb, { + ...options, + isRequire: false, + mainFields: DEFAULT_MAIN_FIELDS, + extensions: DEFAULT_EXTENSIONS + }) + if (resolvedId) { + break + } + } + + // Reset the resolvedPkg variables to avoid false positives as we + // continue our search. + resolvedPkg = undefined + resolvedPkgId = undefined + resolvedPkgType = undefined + continue } - return - } - let resolveId = resolvePackageEntry - let unresolvedId = pkgId - const isDeepImport = unresolvedId !== nestedPath - if (isDeepImport) { - resolveId = resolveDeepImport - unresolvedId = '.' + nestedPath.slice(pkgId.length) - } + // No package.json was found, but there could still be a module + // here. To match Node's behavior, we must be able to resolve a + // module without a package.json file helping us out. + try { + const stat = fs.statSync(entryPath) + if (stat.isFile()) { + resolvedId = entryPath + break + } + resolvedId = tryIndexFile(entryPath, targetWeb, options) + if (resolvedId) { + break + } + } catch {} + + // In case a file extension is missing, we need to try calling the + // `tryFsResolve` function. + let entryDir = path.dirname(entryPath) + let entryDirExists = false + if (entryDir === nodeModulesDir) { + entryDirExists = true + } else { + try { + const stat = fs.statSync(entryDir) + entryDirExists = stat.isDirectory() + } catch {} + } - let resolved: string | undefined - try { - resolved = resolveId(unresolvedId, pkg, targetWeb, options) - } catch (err) { - if (!options.tryEsmOnly) { - throw err + if (entryDirExists) { + resolvedId = tryFsResolve( + entryPath, + { ...options, skipPackageJson: true }, + false, + targetWeb + ) + if (resolvedId) { + break + } } + + // Stop looking if we're at the workspace root directory. + if ( + isWorkspaceRoot( + path.dirname(nodeModulesDir), + preserveSymlinks, + packageCache + ) + ) + break } - if (!resolved && options.tryEsmOnly) { - resolved = resolveId(unresolvedId, pkg, targetWeb, { - ...options, - isRequire: false, - mainFields: DEFAULT_MAIN_FIELDS, - extensions: DEFAULT_EXTENSIONS + + if (!resolvedId) { + const mayBeOptionalPeerDep = + !options.isHookNodeResolve && + basedir !== root && + !isBuiltin(basePkgId) && + !basePkgId.includes('\0') && + bareImportRE.test(basePkgId) + + if (!mayBeOptionalPeerDep) { + return // Module not found. + } + + // Find the importer's nearest package.json with a "name" field. + // Some projects (like Svelte) have nameless package.json files to + // appease older Node.js versions and they don't have the list of + // optional peer dependencies like the root package.json does. + let basePkg: PackageData | undefined + lookupFile(basedir, ['package.json'], { + pathOnly: true, + predicate(pkgPath) { + basePkg = loadPackageData(pkgPath, preserveSymlinks, packageCache) + return !!basePkg.data.name + } }) + + if (!basePkg) { + return // Module not found. + } + + const { peerDependencies, peerDependenciesMeta } = basePkg.data + const optionalPeerDep = + peerDependenciesMeta?.[basePkgId]?.optional && + peerDependencies?.[basePkgId] + + if (!optionalPeerDep) { + return // Module not found. + } + + return { + id: `${optionalPeerDepId}:${basePkgId}:${basePkg.data.name}` + } } - if (!resolved) { - return + + if (!resolvedPkg) { + const pkgPath = lookupFile(path.dirname(resolvedId), ['package.json'], { + pathOnly: true + }) + if (!pkgPath) { + return { id: resolvedId } + } + resolvedPkg = loadPackageData(pkgPath, preserveSymlinks, packageCache) } const processResult = (resolved: PartialResolvedId) => { @@ -753,8 +873,8 @@ export function tryNodeResolve( return resolved } let resolvedId = id - if (isDeepImport) { - if (!pkg?.data.exports && path.extname(id) !== resolvedExt) { + if (resolver === resolveDeepImport) { + if (!resolvedPkg?.data.exports && path.extname(id) !== resolvedExt) { resolvedId = resolved.id.slice(resolved.id.indexOf(id)) isDebug && debug( @@ -766,33 +886,33 @@ export function tryNodeResolve( } // link id to pkg for browser field mapping check - idToPkgMap.set(resolved, pkg) + idToPkgMap.set(resolvedId, resolvedPkg) if ((isBuild && !depsOptimizer) || externalize) { // Resolve package side effects for build so that rollup can better // perform tree-shaking return processResult({ - id: resolved, - moduleSideEffects: pkg.hasSideEffects(resolved) + id: resolvedId, + moduleSideEffects: resolvedPkg.hasSideEffects(resolvedId) }) } - const ext = path.extname(resolved) + const ext = path.extname(resolvedId) const isCJS = - ext === '.cjs' || (ext === '.js' && nearestPkg.data.type !== 'module') + ext === '.cjs' || (ext === '.js' && resolvedPkgType !== 'module') if ( !options.ssrOptimizeCheck && - (!resolved.includes('node_modules') || // linked + (!resolvedId.includes('node_modules') || // linked !depsOptimizer || // resolving before listening to the server options.scan) // initial esbuild scan phase ) { - return { id: resolved } + return { id: resolvedId } } // if we reach here, it's a valid dep import that hasn't been optimized. const isJsType = depsOptimizer - ? isOptimizable(resolved, depsOptimizer.options) - : OPTIMIZABLE_ENTRY_RE.test(resolved) + ? isOptimizable(resolvedId, depsOptimizer.options) + : OPTIMIZABLE_ENTRY_RE.test(resolvedId) let exclude = depsOptimizer?.options.exclude let include = depsOptimizer?.options.exclude @@ -803,22 +923,28 @@ export function tryNodeResolve( } const skipOptimization = + !resolvedPkgId || !isJsType || importer?.includes('node_modules') || - exclude?.includes(pkgId) || - exclude?.includes(nestedPath) || - SPECIAL_QUERY_RE.test(resolved) || + exclude?.includes(resolvedPkgId) || + exclude?.includes(basePkgId) || + exclude?.includes(id) || + SPECIAL_QUERY_RE.test(resolvedId) || (!isBuild && ssr) || // Only optimize non-external CJS deps during SSR by default (ssr && !isCJS && - !(include?.includes(pkgId) || include?.includes(nestedPath))) + !( + include?.includes(resolvedPkgId) || + include?.includes(basePkgId) || + include?.includes(id) + )) if (options.ssrOptimizeCheck) { return { id: skipOptimization - ? injectQuery(resolved, `__vite_skip_optimization`) - : resolved + ? injectQuery(resolvedId, `__vite_skip_optimization`) + : resolvedId } } @@ -831,25 +957,25 @@ export function tryNodeResolve( if (!isBuild) { const versionHash = depsOptimizer!.metadata.browserHash if (versionHash && isJsType) { - resolved = injectQuery(resolved, `v=${versionHash}`) + resolvedId = injectQuery(resolvedId, `v=${versionHash}`) } } } else { // this is a missing import, queue optimize-deps re-run and // get a resolved its optimized info - const optimizedInfo = depsOptimizer!.registerMissingImport(id, resolved) - resolved = depsOptimizer!.getOptimizedDepId(optimizedInfo) + const optimizedInfo = depsOptimizer!.registerMissingImport(id, resolvedId) + resolvedId = depsOptimizer!.getOptimizedDepId(optimizedInfo) } if (isBuild) { // Resolve package side effects for build so that rollup can better // perform tree-shaking return { - id: resolved, - moduleSideEffects: pkg.hasSideEffects(resolved) + id: resolvedId, + moduleSideEffects: resolvedPkg.hasSideEffects(resolvedId) } } else { - return { id: resolved! } + return { id: resolvedId } } } diff --git a/playground/resolve/__tests__/resolve.spec.ts b/playground/resolve/__tests__/resolve.spec.ts index 8f910f4989bfaa..0ddcc465adf7bf 100644 --- a/playground/resolve/__tests__/resolve.spec.ts +++ b/playground/resolve/__tests__/resolve.spec.ts @@ -144,3 +144,12 @@ test('resolve package that contains # in path', async () => { '[success]' ) }) + +// Support this so we can add symlinks to local directories without +// creating a package.json file (and because Node.js also supports +// this). +test('unpackaged modules in node_modules', async () => { + expect(await page.textContent('.unpackaged-file')).toMatch('[success]') + expect(await page.textContent('.unpackaged-index-file')).toMatch('[success]') + expect(await page.textContent('.unpackaged-deep-import')).toMatch('[success]') +}) diff --git a/playground/resolve/index.html b/playground/resolve/index.html index 6150dc86dbb1ef..89f507de13873e 100644 --- a/playground/resolve/index.html +++ b/playground/resolve/index.html @@ -121,6 +121,15 @@

resolve.conditions

resolve package that contains # in path

+

unpackaged file in node_modules

+

+ +

index file from unpackaged directory in node_modules

+

+ +

deep import of unpackaged directory in node_modules

+

+