Skip to content

Commit

Permalink
fix(resolve): make tryNodeResolve more robust
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aleclarson committed Jul 17, 2022
1 parent 7065005 commit 488e9ee
Show file tree
Hide file tree
Showing 2 changed files with 164 additions and 65 deletions.
13 changes: 13 additions & 0 deletions packages/vite/src/node/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,16 @@ 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
}
216 changes: 151 additions & 65 deletions packages/vite/src/node/plugins/resolve.ts
Original file line number Diff line number Diff line change
@@ -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 { resolve as _resolveExports } from 'resolve.exports'
Expand Down Expand Up @@ -28,6 +29,7 @@ import {
isObject,
isPossibleTsOutput,
isTsRequest,
lookupFile,
nestedResolveFrom,
normalizePath,
resolveFrom,
Expand All @@ -37,7 +39,7 @@ import { optimizedDepInfoFromFile, optimizedDepInfoFromId } from '../optimizer'
import type { DepsOptimizer } from '../optimizer'
import type { SSROptions } from '..'
import type { PackageCache, PackageData } from '../packages'
import { loadPackageData, resolvePackageData } from '../packages'
import { findPackageJson, loadPackageData } from '../packages'

// special id for paths marked with browser: false
// https://github.com/defunctzombie/package-browser-field-spec#ignore-a-module
Expand Down Expand Up @@ -447,13 +449,17 @@ function tryFsResolve(
}
}

if (!tryIndex) {
return
}

if (
postfix &&
(res = tryResolveFile(
fsPath,
'',
options,
tryIndex,
true,
targetWeb,
options.tryPrefix,
options.skipPackageJson
Expand All @@ -467,7 +473,7 @@ function tryFsResolve(
file,
postfix,
options,
tryIndex,
true,
targetWeb,
options.tryPrefix,
options.skipPackageJson
Expand Down Expand Up @@ -506,8 +512,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
}
}
}

Expand Down Expand Up @@ -535,8 +543,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<string, PackageData>()

const lookupNodeModules = (Module as any)._nodeModulePaths as {
(cwd: string): string[]
}

export function tryNodeResolve(
id: string,
importer: string | null | undefined,
Expand All @@ -556,36 +579,11 @@ export function tryNodeResolve(
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)
}
const possibleIds = getPossibleModuleIds(nestedPath)
possibleIds.reverse()

let basedir: string
if (dedupe?.some((id) => possiblePkgIds.includes(id))) {
if (dedupe?.some((id) => possibleIds.includes(id))) {
basedir = root
} else if (
importer &&
Expand All @@ -603,41 +601,110 @@ export function tryNodeResolve(
}

let pkg: PackageData | undefined
const pkgId = possiblePkgIds.reverse().find((pkgId) => {
pkg = resolvePackageData(pkgId, basedir, preserveSymlinks, packageCache)!
return pkg
})!
let pkgId: string | undefined
let exportId: string | undefined
let resolved: string | undefined
let resolver: typeof resolvePackageEntry

if (!pkg) {
return
}
const nodeModules = lookupNodeModules(basedir)
lookup: for (const nodeModulesDir of nodeModules) {
if (!fs.existsSync(nodeModulesDir)) {
continue
}

let resolveId = resolvePackageEntry
let unresolvedId = pkgId
const isDeepImport = unresolvedId !== nestedPath
if (isDeepImport) {
resolveId = resolveDeepImport
unresolvedId = '.' + nestedPath.slice(pkgId.length)
}
let i = 0
let triedId = possibleIds[i]
let triedPath = path.join(nodeModulesDir, triedId)

let resolved: string | undefined
try {
resolved = resolveId(unresolvedId, pkg, targetWeb, options)
} catch (err) {
if (!options.tryEsmOnly) {
throw err
const pkgPath = findPackageJson(triedPath)
if (pkgPath) {
pkg = loadPackageData(pkgPath, options.preserveSymlinks, packageCache)
pkgId = path.dirname(path.relative(nodeModulesDir, pkgPath))

exportId = nestedPath
resolver = resolvePackageEntry
if (pkgId !== exportId) {
exportId = '.' + nestedPath.slice(pkgId.length)
resolver = resolveDeepImport
}
try {
resolved = resolver(exportId, pkg, targetWeb, options)
} catch (err) {
if (!options.tryEsmOnly) {
throw err
}
}
if (!resolved && options.tryEsmOnly) {
resolved = resolver(exportId, pkg, targetWeb, {
...options,
isRequire: false,
mainFields: DEFAULT_MAIN_FIELDS,
extensions: DEFAULT_EXTENSIONS
})
}

// Resolved or not, we're done with this nodeModulesDir
// since we found a package.
if (resolved) {
break lookup
}
pkgId = undefined
continue lookup
}

// No package.json was found, so it's time to check the last
// two possible modules to determine if "/index" should be appended
// or if the `nestedPath` is pointing to a specific file.
while (i < 2) {
try {
const stat = fs.statSync(triedPath)
if (stat.isDirectory()) {
resolved =
i === 0
? tryIndexFile(nestedPath, targetWeb, options)
: tryFsResolve(
nestedPath,
{ ...options, skipPackageJson: true },
false,
targetWeb
)

// Resolved or not, we're done with this lookupPath.
if (resolved) break lookup
continue lookup
}
// The path exists but isn't a directory, so it must be a file.
// But we only care if it's the first possible module.
if (i === 0) {
resolved = triedPath
break lookup
}
} catch {
// The first path we try could be a file missing an extension,
// so we should try at least two paths.
if (i > 0) {
break // Try the next nodeModulesDir.
}
}
if (++i == possibleIds.length) {
break
}
triedId = possibleIds[i]
triedPath = path.join(nodeModulesDir, triedId)
}
}
if (!resolved && options.tryEsmOnly) {
resolved = resolveId(unresolvedId, pkg, targetWeb, {
...options,
isRequire: false,
mainFields: DEFAULT_MAIN_FIELDS,
extensions: DEFAULT_EXTENSIONS
})
}

if (!resolved) {
return
return // Module not found.
}
if (!pkg) {
const pkgPath = lookupFile(path.dirname(resolved), ['package.json'], {
pathOnly: true
})
if (!pkgPath) {
return // Resolved module must be within a package.
}
pkg = loadPackageData(pkgPath, options.preserveSymlinks, packageCache)
}

const processResult = (resolved: PartialResolvedId) => {
Expand All @@ -646,7 +713,7 @@ export function tryNodeResolve(
}
const resolvedExt = path.extname(resolved.id)
let resolvedId = id
if (isDeepImport) {
if (resolver === resolveDeepImport) {
// check ext before externalizing - only externalize
// extension-less imports and explicit .js imports
if (resolvedExt && !resolved.id.match(/(.js|.mjs|.cjs)$/)) {
Expand Down Expand Up @@ -696,14 +763,14 @@ export function tryNodeResolve(
const skipOptimization =
!isJsType ||
importer?.includes('node_modules') ||
exclude?.includes(pkgId) ||
(pkgId && exclude?.includes(pkgId)) ||
exclude?.includes(nestedPath) ||
SPECIAL_QUERY_RE.test(resolved) ||
(!isBuild && ssr) ||
// Only optimize non-external CJS deps during SSR by default
(ssr &&
!isCJS &&
!(include?.includes(pkgId) || include?.includes(nestedPath)))
!((pkgId && include?.includes(pkgId)) || include?.includes(nestedPath)))

if (options.ssrOptimizeCheck) {
return {
Expand Down Expand Up @@ -744,6 +811,25 @@ export function tryNodeResolve(
}
}

function getPossibleModuleIds(id: string) {
const moduleIds: string[] = []
let moduleId = ''
for (const part of id.split('/')) {
if (!moduleId) {
moduleId = part
if (part[0] === '@') {
// For efficiency, let's assume that package scope folders
// are guaranteed to never be a module ID.
continue
}
} else {
moduleId += '/' + part
}
moduleIds.push(moduleId)
}
return moduleIds
}

export async function tryOptimizedResolve(
depsOptimizer: DepsOptimizer,
id: string,
Expand Down

0 comments on commit 488e9ee

Please sign in to comment.