Skip to content

Commit 241c53a

Browse files
authored
fix: double check "hoisted" mode for each node module, verify through filesystem check instead of require/import-meta-resolve methods (#9401)
1 parent 70da68a commit 241c53a

File tree

13 files changed

+78121
-25601
lines changed

13 files changed

+78121
-25601
lines changed

.changeset/yellow-loops-wink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
fix: double check yarn hoisted mode for each node module - yarn classic doesn't perma-hoist

packages/app-builder-lib/src/asar/asar.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export class AsarFilesystem {
4848
let node = this.header
4949
for (const dir of p.split(path.sep)) {
5050
if (dir !== ".") {
51+
if (node == null) {
52+
throw new Error(`Cannot find node for path: ${p} (node is null at ${dir})`)
53+
}
5154
let child = node.files![dir]
5255
if (child == null) {
5356
if (!isCreate) {
@@ -70,6 +73,9 @@ export class AsarFilesystem {
7073

7174
const name = path.basename(p)
7275
const dirNode = this.searchNodeFromDirectory(path.dirname(p), true)!
76+
if (dirNode == null) {
77+
throw new Error(`Cannot find node for path: ${p} (node is null at ${path.dirname(p)})`)
78+
}
7379
if (dirNode.files == null) {
7480
dirNode.files = this.newNode()
7581
}
@@ -115,6 +121,9 @@ export class AsarFilesystem {
115121

116122
getNode(p: string): Node | null {
117123
const node = this.searchNodeFromDirectory(path.dirname(p), false)!
124+
if (node == null) {
125+
throw new Error(`Cannot find node for path: ${p} (node is null at ${path.dirname(p)})`)
126+
}
118127
return node.files![path.basename(p)]
119128
}
120129

packages/app-builder-lib/src/node-module-collector/moduleCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type ModuleCache = {
1414
/** Cache for lstat results */
1515
lstat: Map<string, fs.Stats>
1616
/** Cache for require.resolve results (key: "packageName::fromDir") */
17-
requireResolve: Map<string, string | null>
17+
requireResolve: Map<string, { entry: string; packageDir: string } | null>
1818
}
1919

2020
/**

packages/app-builder-lib/src/node-module-collector/nodeModulesCollector.ts

Lines changed: 42 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { exists, log, retry, TmpDir } from "builder-util"
1+
import { exists, isEmptyOrSpaces, log, retry, TmpDir } from "builder-util"
22
import * as childProcess from "child_process"
33
import { CancellationToken } from "builder-util-runtime"
44
import * as fs from "fs-extra"
@@ -9,35 +9,21 @@ import { hoist, type HoisterResult, type HoisterTree } from "./hoist"
99
import { createModuleCache, type ModuleCache } from "./moduleCache"
1010
import { getPackageManagerCommand, PM } from "./packageManager"
1111
import type { Dependency, DependencyGraph, NodeModuleInfo, PackageJson } from "./types"
12-
import { fileURLToPath, pathToFileURL } from "node:url"
1312

1413
export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDepType, OptionalDepType>, OptionalDepType> {
15-
private nodeModules: NodeModuleInfo[] = []
16-
protected allDependencies: Map<string, ProdDepType> = new Map()
17-
protected productionGraph: DependencyGraph = {}
18-
protected pkgJsonCache: Map<string, string> = new Map()
19-
protected memoResolvedModules = new Map<string, Promise<string | null>>()
20-
21-
private importMetaResolve = new Lazy(async () => {
22-
try {
23-
// Node >= 16 builtin ESM-aware resolver
24-
const packageName = "import-meta-resolve"
25-
const imported = await import(packageName)
26-
return imported.resolve
27-
} catch {
28-
return null
29-
}
30-
})
14+
private readonly nodeModules: NodeModuleInfo[] = []
15+
protected readonly allDependencies: Map<string, ProdDepType> = new Map()
16+
protected readonly productionGraph: DependencyGraph = {}
3117

3218
// Unified cache for all file system and module operations
33-
protected cache: ModuleCache = createModuleCache()
19+
private readonly cache: ModuleCache = createModuleCache()
3420

35-
protected isHoisted = new Lazy<boolean>(async () => {
21+
protected readonly isHoisted = new Lazy<boolean>(async () => {
3622
const { manager } = this.installOptions
3723
const command = getPackageManagerCommand(manager)
3824

39-
const config = (await this.asyncExec(command, ["config", "list"])).stdout
40-
if (config == null) {
25+
const config = (await this.asyncExec(command, ["config", "list"])).stdout?.trim()
26+
if (isEmptyOrSpaces(config)) {
4127
log.debug({ manager }, "unable to determine if node_modules are hoisted: no config output. falling back to hoisted mode")
4228
return false
4329
}
@@ -69,7 +55,6 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
6955
}
7056

7157
await this.collectAllDependencies(tree, packageName)
72-
7358
const realTree: ProdDepType = await this.getTreeFromWorkspaces(tree, packageName)
7459
await this.extractProductionDependencyGraph(realTree, packageName)
7560

@@ -80,10 +65,8 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
8065
const hoisterResult: HoisterResult = hoist(this.transformToHoisterTree(this.productionGraph, packageName), {
8166
check: log.isDebugEnabled,
8267
})
83-
8468
await this._getNodeModules(hoisterResult.dependencies, this.nodeModules)
8569
log.debug({ packageName, depCount: this.nodeModules.length }, "node modules collection complete")
86-
8770
return this.nodeModules
8871
}
8972

@@ -210,60 +193,52 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
210193
}
211194

212195
/**
213-
* Resolves a package to its filesystem location using Node.js module resolution.
214-
* Returns the directory containing the package, not the package.json path.
196+
* Resolve a package directory purely from the filesystem.
197+
* Does NOT attempt to load the module or resolve an "exports" entrypoint.
198+
* Good for Yarn 4 because a package may not be resolvable as a module,
199+
* but still exists on disk.
215200
*/
216-
protected async resolvePackageDir(packageName: string, fromDir: string): Promise<string | null> {
201+
protected async resolvePackage(packageName: string, fromDir: string): Promise<{ entry: string; packageDir: string } | null> {
217202
const cacheKey = `${packageName}::${fromDir}`
218203
if (this.cache.requireResolve.has(cacheKey)) {
219204
return this.cache.requireResolve.get(cacheKey)!
220205
}
221206

222-
const resolveEntry = async () => {
223-
// 1) Try Node ESM resolver (handles exports reliably)
224-
if (await this.importMetaResolve.value) {
225-
try {
226-
const url = (await this.importMetaResolve.value)(packageName, pathToFileURL(fromDir).href)
227-
return url.startsWith("file://") ? fileURLToPath(url) : null
228-
} catch (error: any) {
229-
log.debug({ error: error.message }, "import-meta-resolve failed")
230-
// ignore
231-
}
232-
}
233-
234-
// 2) Fallback: require.resolve (CJS, old packages)
235-
try {
236-
return require.resolve(packageName, { paths: [fromDir, this.rootDir] })
237-
} catch (error: any) {
238-
log.debug({ error: error.message }, "require.resolve failed")
239-
// ignore
240-
}
241-
242-
return null
207+
// 1. NESTED under fromDir/node_modules/<name>
208+
let candidate = path.join(fromDir, "node_modules", packageName)
209+
let pkgJson = path.join(candidate, "package.json")
210+
if (await this.existsMemoized(pkgJson)) {
211+
this.cache.requireResolve.set(cacheKey, { entry: pkgJson, packageDir: candidate })
212+
return { entry: pkgJson, packageDir: candidate }
243213
}
244214

245-
const entry = await resolveEntry()
246-
if (!entry) {
247-
this.cache.requireResolve.set(cacheKey, null)
248-
return null
215+
// 2. HOISTED under rootDir/node_modules/<name>
216+
candidate = path.join(this.rootDir, "node_modules", packageName)
217+
pkgJson = path.join(candidate, "package.json")
218+
if (await this.existsMemoized(pkgJson)) {
219+
this.cache.requireResolve.set(cacheKey, { entry: pkgJson, packageDir: candidate })
220+
return { entry: pkgJson, packageDir: candidate }
249221
}
250222

251-
// 3) Walk upward until you find a package.json
252-
let dir = path.dirname(entry)
223+
// 3. FALLBACK: try parent directories BFS (classic Node-style search)
224+
let current = fromDir
253225
while (true) {
254-
const pkgFile = path.join(dir, "package.json")
255-
if (await this.existsMemoized(pkgFile)) {
256-
this.cache.requireResolve.set(cacheKey, dir)
257-
return dir
226+
const nm = path.join(current, "node_modules", packageName)
227+
const pkg = path.join(nm, "package.json")
228+
229+
if (await this.existsMemoized(pkg)) {
230+
this.cache.requireResolve.set(cacheKey, { entry: pkg, packageDir: nm })
231+
return { entry: pkg, packageDir: nm }
258232
}
259233

260-
const parent = path.dirname(dir)
261-
if (parent === dir) {
262-
break // root reached
234+
const parent = path.dirname(current)
235+
if (parent === current) {
236+
break
263237
}
264-
dir = parent
238+
current = parent
265239
}
266240

241+
// 4. LAST RESORT: DO NOT throw — just return null
267242
this.cache.requireResolve.set(cacheKey, null)
268243
return null
269244
}
@@ -298,9 +273,10 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
298273

299274
if (tree.dependencies?.[packageName]) {
300275
const { name, path, dependencies } = tree.dependencies[packageName]
301-
log.debug({ name, path, dependencies: JSON.stringify(dependencies) }, "pruning root app/self reference from workspace tree")
276+
log.debug({ name, path, dependencies: JSON.stringify(dependencies) }, "pruning root app/self reference from workspace tree, merging dependencies uptree")
302277
for (const [name, pkg] of Object.entries(dependencies ?? {})) {
303278
tree.dependencies[name] = pkg
279+
this.allDependencies.set(this.packageVersionString(pkg), pkg)
304280
}
305281
delete tree.dependencies[packageName]
306282
}
@@ -396,6 +372,8 @@ export abstract class NodeModulesCollector<ProdDepType extends Dependency<ProdDe
396372
args = ["/c", tempBatFile, ...args]
397373
}
398374

375+
log.debug({ command, args, cwd, tempOutputFile }, "spawning node module collector process")
376+
399377
await new Promise<void>((resolve, reject) => {
400378
const outStream = createWriteStream(tempOutputFile)
401379

packages/app-builder-lib/src/node-module-collector/npmNodeModulesCollector.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export class NpmNodeModulesCollector extends NodeModulesCollector<NpmDependency,
8484
}
8585

8686
// Read package.json using memoized require for consistency with Node.js module system
87-
const pkg: PackageJson = this.requireMemoized(pkgPath)
87+
const pkg: PackageJson = await this.readJsonMemoized(pkgPath)
8888
const resolvedPackageDir = await this.resolvePath(packageDir)
8989

9090
// Use resolved path as the unique identifier to prevent circular dependencies
@@ -109,25 +109,23 @@ export class NpmNodeModulesCollector extends NodeModulesCollector<NpmDependency,
109109
for (const [depName, depVersion] of Object.entries(allProdDepNames)) {
110110
try {
111111
// Resolve the dependency using Node.js module resolution from this package's directory
112-
const depPath = await this.resolvePackageDir(depName, packageDir)
112+
const resolvedPackage = await this.resolvePackage(depName, packageDir)
113113

114-
if (!depPath) {
115-
log.warn({ package: pkg.name, dependency: depName, version: depVersion }, "dependency not found, skipping")
116-
continue
114+
if (!resolvedPackage) {
115+
log.warn({ package: pkg.name, dependency: depName, version: depVersion }, "dependency not found")
117116
}
118117

119-
const resolvedDepPath = await this.resolvePath(depPath)
120-
118+
const resolvedDepPath = await this.resolvePath(resolvedPackage!.packageDir)
121119
// Skip if this dependency resolves to the base directory or any parent we're already processing
122120
if (resolvedDepPath === resolvedPackageDir || resolvedDepPath === (await this.resolvePath(baseDir))) {
123121
log.debug({ package: pkg.name, dependency: depName, resolvedPath: resolvedDepPath }, "skipping self-referential dependency")
124122
continue
125123
}
126124

127-
log.debug({ package: pkg.name, dependency: depName, resolvedPath: depPath }, "processing production dependency")
125+
log.debug({ package: pkg.name, dependency: depName, ...resolvedPackage, resolvedDepPath }, "processing production dependency")
128126

129127
// Recursively build the dependency tree for this dependency
130-
prodDeps[depName] = await buildFromPackage(depPath)
128+
prodDeps[depName] = await buildFromPackage(resolvedDepPath)
131129
} catch (error: any) {
132130
log.warn({ package: pkg.name, dependency: depName, error: error.message }, "failed to process dependency, skipping")
133131
}

packages/app-builder-lib/src/node-module-collector/packageManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,23 @@ export async function detectPackageManager(searchPaths: string[]): Promise<{ pm:
5858
const [pm, version] = packageManager.split("@")
5959
if (Object.values(PM).includes(pm as PM)) {
6060
const resolvedPackageManager = await resolveIfYarn(pm as PM, version, dir)
61-
log.debug({ resolvedPackageManager, packageManager, cwd: dir }, "packageManager field detected in package.json")
61+
log.info({ resolvedPackageManager, packageManager, cwd: dir }, "packageManager field detected in package.json")
6262
return { pm: resolvedPackageManager, corepackConfig: packageManager, resolvedDirectory: dir }
6363
}
6464
}
6565

6666
pm = await detectPackageManagerByFile(dir)
6767
if (pm) {
6868
const resolvedPackageManager = await resolveIfYarn(pm, "", dir)
69-
log.debug({ resolvedPackageManager, cwd: dir }, "packageManager detected by file")
69+
log.info({ resolvedPackageManager, cwd: dir }, "packageManager detected by file")
7070
return { pm: resolvedPackageManager, resolvedDirectory: dir, corepackConfig: undefined }
7171
}
7272
}
7373

7474
pm = detectPackageManagerByEnv() || PM.NPM
7575
const cwd = process.env.npm_package_json ? path.dirname(process.env.npm_package_json) : (process.env.INIT_CWD ?? process.cwd())
7676
const resolvedPackageManager = await resolveIfYarn(pm, "", cwd)
77-
log.debug({ resolvedPackageManager, detected: cwd }, "packageManager not detected by file, falling back to environment detection")
77+
log.info({ resolvedPackageManager, detected: cwd }, "packageManager not detected by file, falling back to environment detection")
7878
return { pm: resolvedPackageManager, resolvedDirectory: undefined, corepackConfig: undefined }
7979
}
8080

packages/app-builder-lib/src/node-module-collector/pnpmNodeModulesCollector.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,20 @@ export class PnpmNodeModulesCollector extends NodeModulesCollector<PnpmDependenc
3636
throw new Error(`Cannot compute production dependencies for package with empty name: ${packageName}`)
3737
}
3838

39-
const actualPath = await this.resolveActualPath(depTree)
40-
const resolvedLocalPath = await this.resolvePath(actualPath)
41-
const p = path.normalize(resolvedLocalPath)
42-
const pkgJsonPath = path.join(p, "package.json")
39+
const pkgJsonPath = await this.resolvePackage(packageName, depTree.path)
4340

41+
if (pkgJsonPath == null) {
42+
log.warn({ packageName, path: depTree.path, version: depTree.version }, `Cannot find package.json for dependency`)
43+
return { path: depTree.path, prodDeps: {}, optionalDependencies: {} }
44+
}
4445
let packageJson: PackageJson
4546
try {
46-
packageJson = this.requireMemoized(pkgJsonPath)
47+
packageJson = await this.readJsonMemoized(pkgJsonPath.entry)
4748
} catch (error: any) {
48-
log.warn(null, `Failed to read package.json for ${p}: ${error.message}`)
49-
return { path: p, prodDeps: {}, optionalDependencies: {} }
49+
log.warn(pkgJsonPath, `Failed to read package.json: ${error.message}`)
50+
return { path: pkgJsonPath.packageDir, prodDeps: {}, optionalDependencies: {} }
5051
}
51-
return { path: p, prodDeps: { ...packageJson.dependencies, ...packageJson.optionalDependencies }, optionalDependencies: { ...packageJson.optionalDependencies } }
52+
return { path: pkgJsonPath.packageDir, prodDeps: { ...packageJson.dependencies, ...packageJson.optionalDependencies }, optionalDependencies: { ...packageJson.optionalDependencies } }
5253
}
5354

5455
protected async extractProductionDependencyGraph(tree: PnpmDependency, dependencyId: string) {

0 commit comments

Comments
 (0)