Skip to content

Commit 44b0e98

Browse files
authored
fix: resolve vendor dependencies using node module resolution (#691)
1 parent 293c5d8 commit 44b0e98

File tree

4 files changed

+128
-36
lines changed

4 files changed

+128
-36
lines changed

packages/@sanity/cli/src/actions/build/buildVendorDependencies.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import path, {resolve} from 'node:path'
1+
import path from 'node:path'
22

33
import semver from 'semver'
44
import {build} from 'vite'
55

6-
import {getLocalPackageVersion} from '../../util/getLocalPackageVersion.js'
6+
import {getLocalPackageDir, getLocalPackageVersion} from '../../util/getLocalPackageVersion.js'
77
import {createExternalFromImportMap} from './createExternalFromImportMap.js'
88

99
// Directory where vendor packages will be stored
@@ -152,6 +152,10 @@ export async function buildVendorDependencies({
152152

153153
const subpaths = ranges[matchedRange]
154154

155+
// Resolve the actual package directory using Node module resolution,
156+
// so that hoisted packages in monorepos/workspaces are found correctly
157+
const packageDir = getLocalPackageDir(packageName, cwd)
158+
155159
// Iterate over each subpath and its corresponding entry point
156160
for (const [subpath, relativeEntryPoint] of Object.entries(subpaths)) {
157161
const specifier = path.posix.join(packageName, subpath)
@@ -160,7 +164,7 @@ export async function buildVendorDependencies({
160164
path.relative(packageName, specifier) || 'index',
161165
)
162166

163-
entry[chunkName] = resolve(`node_modules/${packageName}/${relativeEntryPoint}`)
167+
entry[chunkName] = path.join(packageDir, relativeEntryPoint)
164168
imports[specifier] = path.posix.join('/', basePath, VENDOR_DIR, `${chunkName}.mjs`)
165169
}
166170
}

packages/@sanity/cli/src/actions/schema/getExtractOptions.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,8 @@ export function getExtractOptions({
3131
const resolved = resolve(join(projectRoot.directory, pathFlag))
3232
const isExistingDirectory = existsSync(resolved) && statSync(resolved).isDirectory()
3333

34-
if (isExistingDirectory || !extname(resolved)) {
35-
outputPath = join(resolved, 'schema.json')
36-
} else {
37-
outputPath = resolved
38-
}
34+
outputPath =
35+
isExistingDirectory || !extname(resolved) ? join(resolved, 'schema.json') : resolved
3936
} else {
4037
outputPath = resolve(join(projectRoot.directory, 'schema.json'))
4138
}

packages/@sanity/cli/src/util/__tests__/getLocalPackageVersion.test.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {type PackageJson} from '@sanity/cli-core'
55
import {moduleResolve} from 'import-meta-resolve'
66
import {afterEach, describe, expect, test, vi} from 'vitest'
77

8-
import {getLocalPackageVersion} from '../getLocalPackageVersion.js'
8+
import {getLocalPackageDir, getLocalPackageVersion} from '../getLocalPackageVersion.js'
99

1010
const mockReadPackageJson = vi.hoisted(() => vi.fn())
1111

@@ -39,6 +39,7 @@ describe('getLocalPackageVersion', () => {
3939
const mockPackageUrl = pathToFileURL(
4040
resolve(mockWorkDir, 'node_modules', mockModuleId, 'package.json'),
4141
)
42+
const expectedPackageDir = resolve(mockWorkDir, 'node_modules', mockModuleId)
4243
const mockVersion = '1.0.0'
4344

4445
mockedModuleResolve.mockReturnValueOnce(mockPackageUrl)
@@ -53,31 +54,30 @@ describe('getLocalPackageVersion', () => {
5354
`${mockModuleId}/package.json`,
5455
pathToFileURL(resolve(mockWorkDir, 'noop.js')),
5556
)
56-
expect(mockReadPackageJson).toHaveBeenCalledWith(mockPackageUrl)
57+
expect(mockReadPackageJson).toHaveBeenCalledWith(join(expectedPackageDir, 'package.json'))
5758
expect(result).toBe(mockVersion)
5859
})
5960

6061
test('returns null when readPackageJson throws', async () => {
6162
const mockPackageUrl = pathToFileURL(
6263
resolve(mockWorkDir, 'node_modules', mockModuleId, 'package.json'),
6364
)
65+
const expectedPackageDir = resolve(mockWorkDir, 'node_modules', mockModuleId)
6466

6567
mockedModuleResolve.mockReturnValueOnce(mockPackageUrl)
6668
mockReadPackageJson.mockRejectedValueOnce(new Error('Failed to read package.json'))
6769

6870
const result = await getLocalPackageVersion(mockModuleId, mockWorkDir)
6971

7072
expect(mockedModuleResolve).toHaveBeenCalledOnce()
71-
expect(mockReadPackageJson).toHaveBeenCalledWith(mockPackageUrl)
73+
expect(mockReadPackageJson).toHaveBeenCalledWith(join(expectedPackageDir, 'package.json'))
7274
expect(result).toBeNull()
7375
})
7476

7577
test('returns version via fallback when package has strict exports', async () => {
7678
const mainEntryPath = resolve(mockWorkDir, 'node_modules', mockModuleId, 'dist', 'index.js')
7779
const mainEntryUrl = pathToFileURL(mainEntryPath)
78-
const expectedPackageJsonUrl = pathToFileURL(
79-
join(resolve(mockWorkDir, 'node_modules', mockModuleId), 'package.json'),
80-
)
80+
const expectedPackageDir = resolve(mockWorkDir, 'node_modules', mockModuleId)
8181
const mockVersion = '2.0.0'
8282
const dirUrl = pathToFileURL(resolve(mockWorkDir, 'noop.js'))
8383

@@ -97,7 +97,7 @@ describe('getLocalPackageVersion', () => {
9797
expect(mockedModuleResolve).toHaveBeenCalledTimes(2)
9898
expect(mockedModuleResolve).toHaveBeenNthCalledWith(1, `${mockModuleId}/package.json`, dirUrl)
9999
expect(mockedModuleResolve).toHaveBeenNthCalledWith(2, mockModuleId, dirUrl)
100-
expect(mockReadPackageJson).toHaveBeenCalledWith(expectedPackageJsonUrl)
100+
expect(mockReadPackageJson).toHaveBeenCalledWith(join(expectedPackageDir, 'package.json'))
101101
expect(result).toBe(mockVersion)
102102
})
103103

@@ -173,3 +173,79 @@ describe('getLocalPackageVersion', () => {
173173
expect(result).toBeNull()
174174
})
175175
})
176+
177+
describe('getLocalPackageDir', () => {
178+
const mockWorkDir = '/mock/work/dir'
179+
const mockModuleId = '@sanity/test'
180+
181+
afterEach(() => {
182+
vi.clearAllMocks()
183+
})
184+
185+
test('returns package directory when package.json is resolved', () => {
186+
const mockPackageUrl = pathToFileURL(
187+
resolve(mockWorkDir, 'node_modules', mockModuleId, 'package.json'),
188+
)
189+
190+
mockedModuleResolve.mockReturnValueOnce(mockPackageUrl)
191+
192+
const result = getLocalPackageDir(mockModuleId, mockWorkDir)
193+
194+
expect(result).toBe(resolve(mockWorkDir, 'node_modules', mockModuleId))
195+
expect(mockedModuleResolve).toHaveBeenCalledWith(
196+
`${mockModuleId}/package.json`,
197+
pathToFileURL(resolve(mockWorkDir, 'noop.js')),
198+
)
199+
})
200+
201+
test('resolves hoisted packages in monorepo root node_modules', () => {
202+
// Simulate a monorepo where react is hoisted to root
203+
const monorepoRoot = '/project'
204+
const workspaceDir = '/project/packages/frontend'
205+
const hoistedPackageUrl = pathToFileURL(
206+
resolve(monorepoRoot, 'node_modules', 'react', 'package.json'),
207+
)
208+
209+
mockedModuleResolve.mockReturnValueOnce(hoistedPackageUrl)
210+
211+
const result = getLocalPackageDir('react', workspaceDir)
212+
213+
expect(result).toBe(resolve(monorepoRoot, 'node_modules', 'react'))
214+
})
215+
216+
test('falls back to main entry point when package.json is not exported', () => {
217+
const mainEntryPath = resolve(mockWorkDir, 'node_modules', mockModuleId, 'dist', 'index.js')
218+
const mainEntryUrl = pathToFileURL(mainEntryPath)
219+
220+
mockedModuleResolve
221+
.mockImplementationOnce(() => {
222+
throw createNodeError('ERR_PACKAGE_PATH_NOT_EXPORTED', 'Package path not exported')
223+
})
224+
.mockReturnValueOnce(mainEntryUrl)
225+
226+
const result = getLocalPackageDir(mockModuleId, mockWorkDir)
227+
228+
expect(result).toBe(resolve(mockWorkDir, 'node_modules', mockModuleId))
229+
expect(mockedModuleResolve).toHaveBeenCalledTimes(2)
230+
})
231+
232+
test('throws when moduleResolve throws a non-fallback error', () => {
233+
mockedModuleResolve.mockImplementationOnce(() => {
234+
throw createNodeError('ERR_MODULE_NOT_FOUND', 'Module not found')
235+
})
236+
237+
expect(() => getLocalPackageDir(mockModuleId, mockWorkDir)).toThrow('Module not found')
238+
})
239+
240+
test('throws when both resolution strategies fail', () => {
241+
mockedModuleResolve
242+
.mockImplementationOnce(() => {
243+
throw createNodeError('ERR_PACKAGE_PATH_NOT_EXPORTED', 'Package path not exported')
244+
})
245+
.mockImplementationOnce(() => {
246+
throw createNodeError('ERR_MODULE_NOT_FOUND', 'Module not found')
247+
})
248+
249+
expect(() => getLocalPackageDir(mockModuleId, mockWorkDir)).toThrow('Module not found')
250+
})
251+
})

packages/@sanity/cli/src/util/getLocalPackageVersion.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,46 @@ export async function getLocalPackageVersion(
1717
workDir: string,
1818
): Promise<string | null> {
1919
try {
20-
// Handle import.meta.url being passed instead of a directory path
21-
const dir = workDir.startsWith('file://') ? dirname(fileURLToPath(workDir)) : workDir
22-
const dirUrl = pathToFileURL(resolve(dir, 'noop.js'))
20+
const packageDir = getLocalPackageDir(moduleName, workDir)
21+
return (await readPackageJson(join(packageDir, 'package.json'))).version
22+
} catch {
23+
return null
24+
}
25+
}
2326

24-
let packageJsonUrl: URL
25-
try {
26-
packageJsonUrl = moduleResolve(`${moduleName}/package.json`, dirUrl)
27-
} catch (err: unknown) {
28-
if (isErrPackagePathNotExported(err)) {
29-
// Fallback: resolve main entry point and derive package root
30-
const mainUrl = moduleResolve(moduleName, dirUrl)
31-
const mainPath = fileURLToPath(mainUrl)
32-
const normalizedName = normalize(moduleName)
33-
const idx = mainPath.lastIndexOf(normalizedName)
34-
const moduleRoot = mainPath.slice(0, idx + normalizedName.length)
35-
packageJsonUrl = pathToFileURL(join(moduleRoot, 'package.json'))
36-
} else {
37-
throw err
38-
}
27+
/**
28+
* Resolve the filesystem directory of a locally installed package using Node
29+
* module resolution. Works correctly with hoisted packages in monorepos/workspaces,
30+
* pnpm symlinks, and other non-standard node_modules layouts.
31+
*
32+
* @param moduleName - The name of the package in npm.
33+
* @param workDir - The working directory to resolve the module from. (aka project root)
34+
* @returns The absolute path to the package directory.
35+
* @internal
36+
*/
37+
export function getLocalPackageDir(moduleName: string, workDir: string): string {
38+
// Handle import.meta.url being passed instead of a directory path
39+
const dir = workDir.startsWith('file://') ? dirname(fileURLToPath(workDir)) : workDir
40+
const dirUrl = pathToFileURL(resolve(dir, 'noop.js'))
41+
42+
try {
43+
const packageJsonUrl = moduleResolve(`${moduleName}/package.json`, dirUrl)
44+
return dirname(fileURLToPath(packageJsonUrl))
45+
} catch (err: unknown) {
46+
if (!isErrPackagePathNotExported(err)) {
47+
throw err
3948
}
49+
}
4050

41-
return (await readPackageJson(packageJsonUrl)).version
42-
} catch {
43-
return null
51+
// Fallback: resolve main entry point and derive package root
52+
const mainUrl = moduleResolve(moduleName, dirUrl)
53+
const mainPath = fileURLToPath(mainUrl)
54+
const normalizedName = normalize(moduleName)
55+
const idx = mainPath.lastIndexOf(normalizedName)
56+
if (idx === -1) {
57+
throw new Error(`Could not determine package directory for '${moduleName}'`)
4458
}
59+
return mainPath.slice(0, idx + normalizedName.length)
4560
}
4661

4762
function isErrPackagePathNotExported(err: unknown): boolean {

0 commit comments

Comments
 (0)