feat: install config dependencies into the global virtual store (#10910)

* feat: install config dependencies into the global virtual store

Config dependencies are now imported into {storeDir}/links/ following the
same path convention as regular packages (@scope/name/version/hash), then
symlinked into node_modules/.pnpm-config/. When the package already exists
in the GVS, the fetch and import are skipped entirely.

* refactor: extract shared GVS path computation into @pnpm/calc-dep-state

Move the leaf node hash computation from config deps-installer into
calcLeafGlobalVirtualStorePath in @pnpm/calc-dep-state to avoid
duplicating the hash logic.
This commit is contained in:
Zoltan Kochan
2026-03-08 13:49:26 +01:00
committed by GitHub
parent 60d3a328bc
commit 821b36ac20
11 changed files with 84 additions and 36 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.deps-installer": minor
"pnpm": minor
---
Config dependencies are now installed into the global virtual store (`{storeDir}/links/`) and symlinked into `node_modules/.pnpm-config/`. This allows config dependencies to be shared across projects that use the same store, avoiding redundant fetches and imports.

View File

@@ -36,6 +36,7 @@ export async function getConfig (
registries: config.registries,
rootDir: config.lockfileDir ?? config.rootProjectManifestDir,
store: store.ctrl,
storeDir: store.dir,
})
}
if (!config.ignorePnpmfile) {

View File

@@ -33,6 +33,7 @@
"compile": "tsgo --build && pnpm run lint --fix"
},
"dependencies": {
"@pnpm/calc-dep-state": "workspace:*",
"@pnpm/config.config-writer": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/error": "workspace:*",
@@ -46,7 +47,8 @@
"@pnpm/read-package-json": "workspace:*",
"@pnpm/types": "workspace:*",
"@zkochan/rimraf": "catalog:",
"get-npm-tarball-url": "catalog:"
"get-npm-tarball-url": "catalog:",
"symlink-dir": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": "catalog:"

View File

@@ -1,19 +1,24 @@
import fs from 'fs'
import path from 'path'
import { calcLeafGlobalVirtualStorePath } from '@pnpm/calc-dep-state'
import { installingConfigDepsLogger } from '@pnpm/core-loggers'
import { readModulesDir } from '@pnpm/read-modules-dir'
import rimraf from '@zkochan/rimraf'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import type { StoreController } from '@pnpm/package-store'
import type { ConfigDependencies, Registries } from '@pnpm/types'
import symlinkDir from 'symlink-dir'
import { normalizeConfigDeps } from './normalizeConfigDeps.js'
export interface InstallConfigDepsOpts {
registries: Registries
rootDir: string
store: StoreController
storeDir: string
}
export async function installConfigDeps (configDeps: ConfigDependencies, opts: InstallConfigDepsOpts): Promise<void> {
const globalVirtualStoreDir = path.join(opts.storeDir, 'links')
const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')
const existingConfigDeps: string[] = await readModulesDir(configModulesDir) ?? []
await Promise.all(existingConfigDeps.map(async (existingConfigDep) => {
@@ -28,29 +33,37 @@ export async function installConfigDeps (configDeps: ConfigDependencies, opts: I
})
await Promise.all(Object.entries(normalizedConfigDeps).map(async ([pkgName, pkg]) => {
const configDepPath = path.join(configModulesDir, pkgName)
if (existingConfigDeps.includes(pkgName)) {
const configDepPkgJson = await safeReadPackageJsonFromDir(configDepPath)
if (configDepPkgJson == null || configDepPkgJson.name !== pkgName || configDepPkgJson.version !== pkg.version) {
await rimraf(configDepPath)
} else {
return
}
const existingPkgJson = existingConfigDeps.includes(pkgName)
? await safeReadPackageJsonFromDir(configDepPath)
: null
if (existingPkgJson != null && existingPkgJson.name === pkgName && existingPkgJson.version === pkg.version) {
return
}
installingConfigDepsLogger.debug({ status: 'started' })
const { fetching } = await opts.store.fetchPackage({
force: true,
lockfileDir: opts.rootDir,
pkg: {
id: `${pkgName}@${pkg.version}`,
resolution: pkg.resolution,
},
})
const { files: filesResponse } = await fetching()
await opts.store.importPackage(configDepPath, {
force: true,
requiresBuild: false,
filesResponse,
})
const fullPkgId = `${pkgName}@${pkg.version}:${pkg.resolution.integrity}`
const relPath = calcLeafGlobalVirtualStorePath(fullPkgId, pkgName, pkg.version)
const pkgDirInGlobalVirtualStore = path.join(globalVirtualStoreDir, relPath, 'node_modules', pkgName)
if (!fs.existsSync(path.join(pkgDirInGlobalVirtualStore, 'package.json'))) {
const { fetching } = await opts.store.fetchPackage({
force: true,
lockfileDir: opts.rootDir,
pkg: {
id: `${pkgName}@${pkg.version}`,
resolution: pkg.resolution,
},
})
const { files: filesResponse } = await fetching()
await opts.store.importPackage(pkgDirInGlobalVirtualStore, {
force: true,
requiresBuild: false,
filesResponse,
})
}
if (existingConfigDeps.includes(pkgName)) {
await rimraf(configDepPath)
}
await fs.promises.mkdir(path.dirname(configDepPath), { recursive: true })
await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath)
installedConfigDeps.push({
name: pkgName,
version: pkg.version,

View File

@@ -9,7 +9,7 @@ const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
test('configuration dependency is installed', async () => {
prepareEmpty()
const { storeController } = createTempStore()
const { storeController, storeDir } = createTempStore()
let configDeps: Record<string, string> = {
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
@@ -20,12 +20,15 @@ test('configuration dependency is installed', async () => {
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})
{
const configDepManifest = loadJsonFileSync<{ name: string, version: string }>('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json')
expect(configDepManifest.name).toBe('@pnpm.e2e/foo')
expect(configDepManifest.version).toBe('100.0.0')
// The local path should be a symlink to the global virtual store
expect(fs.lstatSync('node_modules/.pnpm-config/@pnpm.e2e/foo').isSymbolicLink()).toBe(true)
}
// Dependency is updated
@@ -37,6 +40,7 @@ test('configuration dependency is installed', async () => {
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})
{
@@ -54,6 +58,7 @@ test('configuration dependency is installed', async () => {
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})
expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json')).toBeFalsy()
@@ -61,7 +66,7 @@ test('configuration dependency is installed', async () => {
test('installation fails if the checksum of the config dependency is invalid', async () => {
prepareEmpty()
const { storeController } = createTempStore({
const { storeController, storeDir } = createTempStore({
clientOptions: {
retry: {
retries: 0,
@@ -78,12 +83,13 @@ test('installation fails if the checksum of the config dependency is invalid', a
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow('Got unexpected checksum for')
})
test('installation fails if the config dependency does not have a checksum', async () => {
prepareEmpty()
const { storeController } = createTempStore({
const { storeController, storeDir } = createTempStore({
clientOptions: {
retry: {
retries: 0,
@@ -100,5 +106,6 @@ test('installation fails if the config dependency does not have a checksum', asy
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow("doesn't have an integrity checksum")
})

View File

@@ -9,7 +9,7 @@ const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
test('configuration dependency is resolved', async () => {
prepareEmpty()
const { storeController } = createTempStore()
const { storeController, storeDir } = createTempStore()
await resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], {
registries: {
@@ -19,7 +19,7 @@ test('configuration dependency is resolved', async () => {
cacheDir: path.resolve('cache'),
userConfig: {},
store: storeController,
storeDir: '.store',
storeDir,
})
const workspaceManifest = readYamlFile<{ configDependencies: Record<string, string> }>('pnpm-workspace.yaml')

View File

@@ -21,6 +21,9 @@
{
"path": "../../network/fetch"
},
{
"path": "../../packages/calc-dep-state"
},
{
"path": "../../packages/core-loggers"
},

View File

@@ -133,14 +133,22 @@ export function calcGraphNodeHash<T extends PkgMeta> (
// so they survive Node.js upgrades and architecture changes.
const includeEngine = builtDepPaths === undefined ||
transitivelyRequiresBuild(graph, builtDepPaths, buildRequiredCache ??= {}, depPath, new Set())
const state = {
engine: includeEngine ? ENGINE_NAME : null,
deps: calcDepGraphHash(graph, cache, new Set(), depPath),
}
const hexDigest = hashObjectWithoutSorting(state, { encoding: 'hex' })
// Use @/ prefix for unscoped packages to maintain uniform 4-level directory depth
// Scoped: @scope/pkg/version/hash
// Unscoped: @/pkg/version/hash
const engine = includeEngine ? ENGINE_NAME : null
const deps = calcDepGraphHash(graph, cache, new Set(), depPath)
const hexDigest = hashObjectWithoutSorting({ engine, deps }, { encoding: 'hex' })
return formatGlobalVirtualStorePath(name, version, hexDigest)
}
export function calcLeafGlobalVirtualStorePath (fullPkgId: string, name: string, version: string): string {
const depsHash = hashObject({ id: fullPkgId, deps: {} })
const hexDigest = hashObjectWithoutSorting({ engine: null, deps: depsHash }, { encoding: 'hex' })
return formatGlobalVirtualStorePath(name, version, hexDigest)
}
// Use @/ prefix for unscoped packages to maintain uniform 4-level directory depth
// Scoped: @scope/pkg/version/hash
// Unscoped: @/pkg/version/hash
function formatGlobalVirtualStorePath (name: string, version: string, hexDigest: string): string {
const prefix = name.startsWith('@') ? '' : '@/'
return `${prefix}${name}/${version}/${hexDigest}`
}

View File

@@ -223,6 +223,7 @@ export async function handler (
await resolveConfigDeps(params, {
...opts,
store: store.ctrl,
storeDir: store.dir,
rootDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
})
return

6
pnpm-lock.yaml generated
View File

@@ -1753,6 +1753,9 @@ importers:
config/deps-installer:
dependencies:
'@pnpm/calc-dep-state':
specifier: workspace:*
version: link:../../packages/calc-dep-state
'@pnpm/config.config-writer':
specifier: workspace:*
version: link:../config-writer
@@ -1798,6 +1801,9 @@ importers:
get-npm-tarball-url:
specifier: 'catalog:'
version: 2.1.0
symlink-dir:
specifier: 'catalog:'
version: 7.1.0
devDependencies:
'@pnpm/config.deps-installer':
specifier: workspace:*

View File

@@ -35,7 +35,8 @@ test('patch from configuration dependency is applied via updateConfig hook', asy
expect(fs.existsSync('node_modules/@pnpm.e2e/foo/index.js')).toBeTruthy()
const lockfile = project.readLockfile()
expect(lockfile.patchedDependencies['@pnpm.e2e/foo'].path).toBe('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo/@pnpm.e2e__foo@100.0.0.patch')
// The patch path goes through the global virtual store since config deps are symlinked there
expect(lockfile.patchedDependencies['@pnpm.e2e/foo'].path).toContain('@pnpm.e2e/has-patch-for-foo/@pnpm.e2e__foo@100.0.0.patch')
})
test('catalog applied by configurational dependency hook', async () => {