refactor: rename the agent client and agent setting to pnpr (#12155)

* refactor: rename the agent client + setting to pnpr

The pnpm-side client and its config setting still carried the old
"agent" name after the server moved to pnpr. Align both with pnpr (and
with pacquet, which already uses `pnprServer`):

- Move `agent/client` → `pnpr/client` and rename the package
  `@pnpm/agent.client` → `@pnpm/pnpr.client` (exported `AgentProject`
  type → `PnprProject`).
- Rename the config setting `agent` → `pnprServer` (`--pnpr-server`
  CLI flag), matching pacquet's setting name.
- Rename the internal install-path symbols and the user-facing log /
  error strings that mentioned "pnpm agent" to "pnpr".

No behavioral change — only names. The e2e suite now drives
`--config.pnprServer`.

* fix: forward optionalDependencies to the pnpr server

`PnprProject` and the install-request body only carried `dependencies`
and `devDependencies`, so a project's `optionalDependencies` were
dropped on the way to the pnpr server — it resolved as if they didn't
exist, producing a different lockfile than the local resolver.

Thread `optionalDependencies` through the client request shape, the
deps-installer single-project and workspace request builders, and the
pnpr server (`InstallRequestProject` / `InstallRequest` + the throwaway
manifest it writes for resolution). Adds an e2e case asserting an
optional dependency is resolved through `pnprServer`.
This commit is contained in:
Zoltan Kochan
2026-06-03 12:01:48 +02:00
committed by GitHub
parent 930c9d7718
commit a017bf3394
33 changed files with 226 additions and 173 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/pnpr.client": patch
"@pnpm/installing.deps-installer": patch
"pnpm": patch
---
Fixed `optionalDependencies` being dropped when resolving through a `pnprServer`. The pnpr request now carries each project's optional dependencies (for both single-project and workspace installs), so the server resolves them like the local resolver does instead of producing a lockfile as if they did not exist.

View File

@@ -1,7 +1,7 @@
---
"@pnpm/agent.client": minor
"@pnpm/pnpr.client": minor
"@pnpm/installing.deps-installer": patch
"pnpm": patch
---
`pnpm install --lockfile-only` (and the `lockfileOnly` setting) is now honored when an agent / `pnprServer` is configured. The agent path resolves and writes `pnpm-lock.yaml` but fetches no files into the store and links no `node_modules`, matching the local lockfile-only behavior. The client ignores any file/index lines an older agent server still streams, so the store stays untouched even against a server that predates the resolve-only mode [#12146](https://github.com/pnpm/pnpm/issues/12146).
`pnpm install --lockfile-only` (and the `lockfileOnly` setting) is now honored when a `pnprServer` is configured. The pnpr path resolves and writes `pnpm-lock.yaml` but fetches no files into the store and links no `node_modules`, matching the local lockfile-only behavior. The client ignores any file/index lines an older pnpr server still streams, so the store stays untouched even against a server that predates the resolve-only mode [#12146](https://github.com/pnpm/pnpm/issues/12146).

View File

@@ -0,0 +1,10 @@
---
"@pnpm/pnpr.client": minor
"@pnpm/config.reader": minor
"@pnpm/types": minor
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.commands": patch
"pnpm": minor
---
Renamed the experimental `agent` setting to `pnprServer` so the pnpm CLI matches the same setting name pacquet uses for offloading resolution to a [pnpr](https://github.com/pnpm/pnpm/tree/main/pnpr) server. Point pnpm at a pnpr server with `pnprServer: <url>` in `pnpm-workspace.yaml` (or `--pnpr-server <url>`); the previous `agent` / `--agent` name no longer works. The client package was likewise renamed from `@pnpm/agent.client` to `@pnpm/pnpr.client`.

View File

@@ -18,7 +18,7 @@ const CLI_PKG_NAME = 'pnpm'
// Experimental packages that are versioned independently on the 0.0.x track
// and should not be normalized to the pnpm major version.
const EXPERIMENTAL_PKGS = new Set([
'@pnpm/agent.client',
'@pnpm/pnpr.client',
])
// Files that must be packed with mode 0755 in both `pnpm` and `@pnpm/exe`.

View File

@@ -223,7 +223,7 @@ export interface Config extends OptionsFromRootManifest {
packGzipLevel?: number
blockExoticSubdeps?: boolean
agent?: string
pnprServer?: string
registries: Registries
namedRegistries?: Record<string, string>

View File

@@ -8,7 +8,6 @@ type PnpmKey = keyof typeof pnpmTypes
* Keys from {@link pnpmTypes} that are valid fields in a global config file.
*/
export const pnpmConfigFileKeys = [
'agent',
'bail',
'ci',
'color',
@@ -47,6 +46,7 @@ export const pnpmConfigFileKeys = [
'npm-path',
'npmrc-auth-file',
'package-import-method',
'pnpr-server',
'prefer-frozen-lockfile',
'prefer-offline',
'prefer-symlinked-executables',

View File

@@ -23,7 +23,7 @@ export type OptionsFromRootManifest = {
supportedArchitectures?: SupportedArchitectures
allowBuilds?: Record<string, boolean | string>
requiredScripts?: string[]
} & Pick<PnpmSettings, 'configDependencies' | 'auditConfig' | 'agent' | 'updateConfig'>
} & Pick<PnpmSettings, 'configDependencies' | 'auditConfig' | 'pnprServer' | 'updateConfig'>
export function getOptionsFromPnpmSettings (manifestDir: string | undefined, pnpmSettings: PnpmSettings, manifest?: ProjectManifest): OptionsFromRootManifest {
const settings: OptionsFromRootManifest = replaceEnvInSettings(pnpmSettings)

View File

@@ -142,7 +142,7 @@ export const pnpmTypes = {
'embed-readme': Boolean,
'skip-manifest-obfuscation': Boolean,
'update-notifier': Boolean,
'agent': [null, String],
'pnpr-server': [null, String],
'registry-supports-time-field': Boolean,
'fail-if-no-match': Boolean,
'sync-injected-deps-after-scripts': Array,

View File

@@ -200,7 +200,7 @@ export interface PnpmSettings {
httpProxy?: string
httpsProxy?: string
noProxy?: string | boolean
agent?: string
pnprServer?: string
}
export interface ProjectManifest extends BaseManifest {

View File

@@ -48,8 +48,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
'node-linker',
'noproxy',
'package-import-method',
'agent',
'pnpmfile',
'pnpr-server',
'prefer-frozen-lockfile',
'prefer-offline',
'production',
@@ -350,7 +350,7 @@ export type InstallCommandOptions = Pick<Config,
| 'updateConfig'
| 'overrides'
| 'packageExtensions'
| 'agent'
| 'pnprServer'
| 'supportedArchitectures'
| 'packageConfigs'
> & Pick<ConfigContext,

View File

@@ -82,7 +82,7 @@ export type InstallDepsOptions = Pick<Config,
| 'linkWorkspacePackages'
| 'lockfileDir'
| 'lockfileOnly'
| 'agent'
| 'pnprServer'
| 'production'
| 'preferWorkspacePackages'
| 'registries'

View File

@@ -73,7 +73,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'lockfileDir'
| 'lockfileOnly'
| 'modulesDir'
| 'agent'
| 'pnprServer'
| 'allowBuilds'
| 'registries'
| 'runtime'

View File

@@ -57,7 +57,6 @@
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/agent.client": "workspace:*",
"@pnpm/bins.linker": "workspace:*",
"@pnpm/bins.remover": "workspace:*",
"@pnpm/building.after-install": "workspace:*",
@@ -102,6 +101,7 @@
"@pnpm/npm-package-arg": "catalog:",
"@pnpm/patching.config": "workspace:*",
"@pnpm/pkg-manifest.utils": "workspace:*",
"@pnpm/pnpr.client": "workspace:*",
"@pnpm/resolving.parse-wanted-dependency": "workspace:*",
"@pnpm/resolving.resolver-base": "workspace:*",
"@pnpm/store.controller-types": "workspace:*",

View File

@@ -257,11 +257,10 @@ export interface StrictInstallOptions {
*/
omitSummaryLog: boolean
/**
* URL of an agent server that resolves dependencies server-side and serves
* only the files missing from the client's store. The `pnpr` server
* implements this protocol.
* URL of a pnpr server that resolves dependencies server-side and serves
* only the files missing from the client's store.
*/
agent?: string
pnprServer?: string
}
export type InstallOptions =

View File

@@ -174,9 +174,9 @@ export async function install (
): Promise<InstallResult> {
const rootDir = (opts.dir ?? process.cwd()) as ProjectRootDir
// When a pnpm agent is configured, use server-side resolution
// When a pnpr server is configured, use server-side resolution
// instead of the normal resolution flow.
if (opts.agent) {
if (opts.pnprServer) {
return installFromPnpmRegistry(manifest, rootDir, opts)
}
@@ -305,13 +305,13 @@ export async function mutateModules (
const opts = extendOptions(maybeOpts)
// When a pnpm agent is configured, use server-side resolution. The agent
// When a pnpr server is configured, use server-side resolution. The pnpr server
// path supports `install`, `installSome` (pnpm add), and `uninstallSome`
// (pnpm remove). Mutations that need full client-side resolution (update
// flags) still fall through to the normal flow.
if (opts.agent && canUseAgentForMutations(projects)) {
const agentResult = await mutateModulesViaAgent(projects, opts)
if (agentResult) return agentResult
if (opts.pnprServer && canUsePnprForMutations(projects)) {
const pnprResult = await mutateModulesViaPnpr(projects, opts)
if (pnprResult) return pnprResult
}
const allowBuild = createAllowBuildFunction(opts)
@@ -1869,7 +1869,7 @@ function allMutationsAreInstalls (projects: MutatedProject[]): boolean {
* Run the pacquet binary if it's configured, otherwise run the JS
* `headlessInstall`. Callers can hand off any code path that materializes
* an already-resolved lockfile (workspace partial install, hoisted
* linker, agent-server install, frozen install) without restating the
* linker, pnpr server install, frozen install) without restating the
* delegation choice.
*
* Pacquet reads the wanted lockfile from disk and produces its own
@@ -1886,7 +1886,7 @@ async function materializeOrDelegate (
): Promise<{ stats?: InstallationResultStats, ignoredBuilds?: IgnoredBuilds }> {
if (opts.runPacquet != null) {
// Reached only from the resolve-then-materialize call sites
// (workspace-partial, hoisted-linker, agent install). Each ran a
// (workspace-partial, hoisted-linker, pnpr server install). Each ran a
// lockfileOnly resolve pass that emitted one
// `pnpm:progress status:resolved` per package, so pacquet's
// duplicate `resolved` events would double the reporter's count.
@@ -2072,13 +2072,13 @@ function getProjectsWithTargetDirs<T extends { id: ProjectId }> (
}
/**
* Whether the agent path can handle this batch of mutations. The agent flow
* Whether the pnpr server path can handle this batch of mutations. The pnpr server flow
* supports installing the manifest as-is (`install`), adding new deps
* (`installSome`), and removing deps (`uninstallSome`). It cannot model the
* client-side update-flag behavior (`update`/`updateMatching`/`updateToLatest`)
* yet, so those still go through the normal client-side resolver.
*/
function canUseAgentForMutations (projects: MutatedProject[]): boolean {
function canUsePnprForMutations (projects: MutatedProject[]): boolean {
if (projects.length === 0) return false
return projects.every((p) => {
if (p.mutation === 'uninstallSome') return true
@@ -2088,52 +2088,52 @@ function canUseAgentForMutations (projects: MutatedProject[]): boolean {
})
}
interface AgentNewDep {
interface PnprNewDep {
alias: string
/**
* Whether the user specified a spec (e.g. `pnpm add foo@^2`). If true, the
* manifest already has the right value and we must preserve it. If false
* we merged in `'latest'` and need to compute a save-prefix spec from the
* resolved version in the lockfile after the agent runs.
* resolved version in the lockfile after the pnpr server runs.
*/
userSpecified: boolean
}
interface AgentInstallProject {
interface PnprInstallProject {
rootDir: ProjectRootDir
/** The (possibly pre-processed) manifest we send to the agent. */
/** The (possibly pre-processed) manifest we send to the pnpr server. */
manifest: ProjectManifest
mutation: MutatedProject['mutation']
/** Newly added deps from an `installSome` mutation. Empty otherwise. */
newDeps: AgentNewDep[]
newDeps: PnprNewDep[]
/** Save-prefix config for `installSome`; applied to deps whose spec defaulted to `'latest'`. */
pinnedVersion?: PinnedVersion
}
/**
* Pre-process projects for the agent flow:
* Pre-process projects for the pnpr server flow:
* - `install`: send the manifest as-is.
* - `uninstallSome`: drop the named deps from the manifest before sending,
* so the agent's resolution naturally produces a lockfile without them.
* so the pnpr server's resolution naturally produces a lockfile without them.
* - `installSome`: parse selectors and merge them into the manifest. The
* agent server then resolves the merged manifest, and we read the resolved
* pnpr server then resolves the merged manifest, and we read the resolved
* specifiers (with the right save-prefix applied server-side) back from
* the lockfile importer entries to update the client-side manifest.
*
* Returns null if the projects don't map cleanly to allProjects (caller
* should fall through to the normal flow).
*/
async function prepareAgentProjects (
async function preparePnprProjects (
projects: MutatedProject[],
opts: MutateModulesOptions
): Promise<AgentInstallProject[] | null> {
): Promise<PnprInstallProject[] | null> {
const allProjects = opts.allProjects ?? []
const mutationByRootDir = new Map<ProjectRootDir, MutatedProject>()
for (const p of projects) {
mutationByRootDir.set(p.rootDir, p)
}
// Include every workspace project, not just the mutated ones — otherwise
// the agent's resulting lockfile would only contain the targeted importer
// the pnpr server's resulting lockfile would only contain the targeted importer
// and `headlessInstall` (or a later install) would crash on the missing
// entries for the other workspace projects. Projects without a mutation
// are sent with their current manifest (no-op for resolution).
@@ -2159,7 +2159,7 @@ async function prepareAgentProjects (
}
return Promise.all(targetSet.map(async (t) => {
let manifest: ProjectManifest = clone(t.manifest)
const newDeps: AgentNewDep[] = []
const newDeps: PnprNewDep[] = []
const mutation = t.mutation
let pinnedVersion: PinnedVersion | undefined
if (mutation?.mutation === 'uninstallSome') {
@@ -2192,7 +2192,7 @@ async function prepareAgentProjects (
* dependency field per the mutation's `targetDependenciesField` (or the
* existing field if the dep is already in the manifest, defaulting to
* `dependencies`). Selectors without a version use `'latest'` so the
* agent's resolver picks the newest matching release.
* pnpr server's resolver picks the newest matching release.
*/
function mergeInstallSelectors (manifest: ProjectManifest, mutation: InstallSomeDepsMutation): ProjectManifest {
const target = mutation.targetDependenciesField
@@ -2234,16 +2234,16 @@ function findExistingSpec (alias: string, manifest: ProjectManifest): string | u
}
/**
* After the agent resolves, copy the lockfile importer's per-dep specifier
* After the pnpr server resolves, copy the lockfile importer's per-dep specifier
* (which the server's resolver computed with the right save-prefix) back
* into the client manifest for any newly added aliases. We rely on the
* lockfile because the agent server applies catalog substitution,
* lockfile because the pnpr server applies catalog substitution,
* normalizedBareSpecifier, and save-prefix logic during resolution.
*/
function applyResolvedSpecsFromLockfile (
manifest: ProjectManifest,
importerSnapshot: ProjectSnapshot | undefined,
newDeps: AgentNewDep[],
newDeps: PnprNewDep[],
pinnedVersion?: PinnedVersion
): ProjectManifest {
if (!importerSnapshot || newDeps.length === 0) return manifest
@@ -2258,7 +2258,7 @@ function applyResolvedSpecsFromLockfile (
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies'] as const) {
const resolvedVersion = importerSnapshot[field]?.[dep.alias]
if (!resolvedVersion || manifest[field]?.[dep.alias] == null) continue
// The agent server resolved the tree but, on the plain-install path, it
// The pnpr server resolved the tree but, on the plain-install path, it
// writes the user's raw spec (`'latest'`) into the lockfile specifier
// rather than normalizing to a save-prefix range. Compute the
// save-prefix spec client-side from the resolved version.
@@ -2270,25 +2270,25 @@ function applyResolvedSpecsFromLockfile (
}
/**
* Drives the agent path for a `mutateModules` call across one or more
* projects. Returns null if the call can't be served by the agent (e.g. one
* Drives the pnpr server path for a `mutateModules` call across one or more
* projects. Returns null if the call can't be served by the pnpr server (e.g. one
* of the projects isn't in `allProjects`).
*/
async function mutateModulesViaAgent (
async function mutateModulesViaPnpr (
projects: MutatedProject[],
opts: MutateModulesOptions
): Promise<MutateModulesResult | null> {
const agentProjects = await prepareAgentProjects(projects, opts)
if (!agentProjects) return null
const pnprProjects = await preparePnprProjects(projects, opts)
if (!pnprProjects) return null
// installFromPnpmRegistry runs the headless install for the first
// project's root and the workspace path for the rest. Pass the
// pre-processed manifests so resolution sees the post-mutation state.
const result = await installFromPnpmRegistry(
agentProjects[0].manifest,
agentProjects[0].rootDir,
pnprProjects[0].manifest,
pnprProjects[0].rootDir,
opts,
agentProjects.map((p) => ({ rootDir: p.rootDir, manifest: p.manifest }))
pnprProjects.map((p) => ({ rootDir: p.rootDir, manifest: p.manifest }))
)
// For installSome projects, copy resolved specs from the lockfile importer
@@ -2296,7 +2296,7 @@ async function mutateModulesViaAgent (
// effect (the server applies these during its resolution step).
const lockfileDir = opts.lockfileDir ?? projects[0].rootDir
const mutatedRootDirs = new Set(projects.map((p) => p.rootDir))
const updatedProjects = agentProjects
const updatedProjects = pnprProjects
.filter((p) => mutatedRootDirs.has(p.rootDir))
.map((p) => {
if (p.mutation === 'installSome' && p.newDeps.length > 0) {
@@ -2317,7 +2317,7 @@ async function mutateModulesViaAgent (
}
/**
* When a pnpm agent is configured, resolve dependencies server-side
* When a pnpr server is configured, resolve dependencies server-side
* and download only the missing files. Then run a headless install to link
* packages into node_modules.
*/
@@ -2327,22 +2327,22 @@ async function installFromPnpmRegistry (
opts: Opts,
allInstallProjects?: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }>
): Promise<InstallResult & { stats: InstallationResultStats, lockfile: LockfileObject }> {
// The agent path skips client-side resolution, so resolver-side policies
// The pnpr server path skips client-side resolution, so resolver-side policies
// can't be enforced locally. `minimumReleaseAge` is forwarded to the
// agent and enforced server-side. `trustPolicy` has no server-side
// pnpr server and enforced server-side. `trustPolicy` has no server-side
// counterpart yet, so refuse to run under it instead of silently
// letting through a lockfile the local verifier would reject.
if (opts.trustPolicy === 'no-downgrade') {
throw new PnpmError(
'TRUST_POLICY_INCOMPATIBLE_WITH_AGENT',
'The pnpm agent does not yet enforce `trustPolicy: no-downgrade`, so running an install through the agent under this policy would produce a lockfile that the local verifier rejects.',
{ hint: 'Unset `trustPolicy` for this install, or disable the agent (unset `--agent` / `agent` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' }
'TRUST_POLICY_INCOMPATIBLE_WITH_PNPR',
'The pnpr server does not yet enforce `trustPolicy: no-downgrade`, so running an install through it under this policy would produce a lockfile that the local verifier rejects.',
{ hint: 'Unset `trustPolicy` for this install, or disable the pnpr server (unset `--pnpr-server` / `pnprServer` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' }
)
}
const { fetchFromPnpmRegistry } = await import('@pnpm/agent.client')
const { fetchFromPnpmRegistry } = await import('@pnpm/pnpr.client')
const { StoreIndex } = await import('@pnpm/store.index')
const { setImportConcurrency } = await import('@pnpm/worker')
// Raise import concurrency for this install only — the agent path has no
// Raise import concurrency for this install only — the pnpr server path has no
// concurrent fetching competing for workers. Restore afterwards so we
// don't leak a process-wide mutation to other installs (e.g. tests).
const restoreImportConcurrency = setImportConcurrency(6)
@@ -2351,38 +2351,40 @@ async function installFromPnpmRegistry (
const lockfileDir = opts.lockfileDir ?? rootDir
// Read the existing lockfile (if any) in its on-disk shape — that's
// what the agent protocol carries, so no conversion is needed before
// what the pnpr server protocol carries, so no conversion is needed before
// sending it.
const existingLockfile = await readWantedLockfileFile(lockfileDir, {
ignoreIncompatible: true,
}).catch(() => null)
logger.info({ message: 'Resolving dependencies via pnpm agent', prefix: rootDir })
logger.info({ message: 'Resolving dependencies via the pnpr server', prefix: rootDir })
// Open the store index to read integrities and write new entries.
// Close it in a finally so a failure in fetchFromPnpmRegistry doesn't
// leak an open SQLite handle (on Windows that also blocks store cleanup).
const storeIndex = new StoreIndex(opts.storeDir)
let lockfile, agentStats, fileDownloads, indexEntries
let lockfile, pnprStats, fileDownloads, indexEntries
try {
// Build projects list for workspace support.
// Normalize separators to POSIX — on Windows `path.relative` returns
// backslashes, which the agent server rejects (it treats `\` as an
// backslashes, which the pnpr server rejects (it treats `\` as an
// unsafe/YAML-injection character and normalizes paths as POSIX).
const projectsList = allInstallProjects && allInstallProjects.length > 1
? allInstallProjects.map(p => ({
dir: (path.relative(lockfileDir, p.rootDir) || '.').split(path.sep).join('/'),
dependencies: p.manifest.dependencies,
devDependencies: p.manifest.devDependencies,
optionalDependencies: p.manifest.optionalDependencies,
}))
: undefined
;({ lockfile, stats: agentStats, fileDownloads, indexEntries } = await fetchFromPnpmRegistry({
registryUrl: opts.agent!,
;({ lockfile, stats: pnprStats, fileDownloads, indexEntries } = await fetchFromPnpmRegistry({
registryUrl: opts.pnprServer!,
storeDir: opts.storeDir,
storeIndex,
dependencies: projectsList ? undefined : manifest.dependencies,
devDependencies: projectsList ? undefined : manifest.devDependencies,
optionalDependencies: projectsList ? undefined : manifest.optionalDependencies,
projects: projectsList,
overrides: opts.overrides,
minimumReleaseAge: opts.minimumReleaseAge,
@@ -2391,7 +2393,7 @@ async function installFromPnpmRegistry (
}))
// Write store index entries so headless install finds them.
const { writeRawIndexEntries } = await import('@pnpm/agent.client')
const { writeRawIndexEntries } = await import('@pnpm/pnpr.client')
writeRawIndexEntries(indexEntries, storeIndex)
storeIndex.checkpoint()
@@ -2409,11 +2411,11 @@ async function installFromPnpmRegistry (
})
logger.info({
message: `Resolved ${agentStats.totalPackages} packages: ${agentStats.alreadyInStore} cached, ${agentStats.filesToDownload} files to download`,
message: `Resolved ${pnprStats.totalPackages} packages: ${pnprStats.alreadyInStore} cached, ${pnprStats.filesToDownload} files to download`,
prefix: rootDir,
})
// `--lockfile-only`: the agent resolved and we wrote the lockfile, but
// `--lockfile-only`: the pnpr server resolved and we wrote the lockfile, but
// pnpm fetches nothing and links nothing in this mode — stop before the
// headless install. See https://github.com/pnpm/pnpm/issues/12146.
if (opts.lockfileOnly) {
@@ -2432,8 +2434,8 @@ async function installFromPnpmRegistry (
}
// Wrap fetchPackage to:
// 1. Wait for agent file downloads before checking the store
// 2. Skip integrity verification — files just written from the agent
// 1. Wait for pnpr server file downloads before checking the store
// 2. Skip integrity verification — files just written from the pnpr server
// are guaranteed correct (server verified, no rehashing needed)
const { readPkgFromCafs } = await import('@pnpm/worker')
const { storeIndexKey: _storeIndexKey } = await import('@pnpm/store.index')
@@ -2445,7 +2447,7 @@ async function installFromPnpmRegistry (
const integrity = resolution?.integrity
// Fall through to the regular store controller for git-hosted tarballs.
// Their cached entry lives under gitHostedStoreIndexKey (preserves the
// built/not-built dimension), not the integrity-keyed path the agent
// built/not-built dimension), not the integrity-keyed path the pnpr server
// uses for npm tarballs. See @pnpm/store.pkg-finder for the rationale.
if (integrity && !resolution?.gitHosted) {
const filesIndexFile = _storeIndexKey(integrity, fetchOpts.pkg.id)
@@ -2469,7 +2471,7 @@ async function installFromPnpmRegistry (
const headlessOpts = {
...opts,
// Skip re-verifying files just written from the agent — they're
// Skip re-verifying files just written from the pnpr server — they're
// guaranteed correct (server verified, no rehashing needed).
verifyStoreIntegrity: false,
storeController: wrappedStoreController,
@@ -2516,13 +2518,13 @@ async function installFromPnpmRegistry (
updatedManifest: manifest,
ignoredBuilds,
// Pacquet doesn't surface a structured stats return; default to
// zeros so the agent-path's non-optional `stats` slot is filled.
// zeros so the pnpr server's non-optional `stats` slot is filled.
// The reporter still renders accurate counts from pacquet's
// `pnpm:stats` log events.
stats: stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
lockfile,
// Server-side resolution (pnpm agent) enforces `minimumReleaseAge`
// itself — the agent picks only mature versions and the lockfile
// Server-side resolution (pnpr server) enforces `minimumReleaseAge`
// itself — the pnpr server picks only mature versions and the lockfile
// can't contain immature entries to auto-collect. `trustPolicy` is
// guarded above (we refuse to enter this path when it's set), so
// there's nothing for the install command to react to here.

View File

@@ -24,9 +24,6 @@
{
"path": "../../__utils__/test-ipc-server"
},
{
"path": "../../agent/client"
},
{
"path": "../../bins/linker"
},
@@ -147,6 +144,9 @@
{
"path": "../../pkg-manifest/utils"
},
{
"path": "../../pnpr/client"
},
{
"path": "../../resolving/parse-wanted-dependency"
},

View File

@@ -1,6 +1,6 @@
//! Client for pnpr's server-accelerated installs.
//!
//! Port of the TypeScript `@pnpm/agent.client` (`fetchFromPnpmRegistry`)
//! Port of the TypeScript `@pnpm/pnpr.client` (`fetchFromPnpmRegistry`)
//! plus the `fetch-and-write-cafs` worker. Given a set of dependencies
//! and the client's content-addressable store, it:
//!

62
pnpm-lock.yaml generated
View File

@@ -1510,34 +1510,6 @@ importers:
specifier: workspace:*
version: 'link:'
agent/client:
dependencies:
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/logger':
specifier: 'catalog:'
version: 1001.0.1
'@pnpm/store.cafs':
specifier: workspace:*
version: link:../../store/cafs
'@pnpm/store.index':
specifier: workspace:*
version: link:../../store/index
'@pnpm/worker':
specifier: workspace:^
version: link:../../worker
devDependencies:
'@pnpm/agent.client':
specifier: workspace:*
version: 'link:'
'@pnpm/types':
specifier: workspace:*
version: link:../../core/types
auth/commands:
dependencies:
'@inquirer/prompts':
@@ -5578,9 +5550,6 @@ importers:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.5.2(@types/node@25.9.1)
'@pnpm/agent.client':
specifier: workspace:*
version: link:../../agent/client
'@pnpm/bins.linker':
specifier: workspace:*
version: link:../../bins/linker
@@ -5713,6 +5682,9 @@ importers:
'@pnpm/pkg-manifest.utils':
specifier: workspace:*
version: link:../../pkg-manifest/utils
'@pnpm/pnpr.client':
specifier: workspace:*
version: link:../../pnpr/client
'@pnpm/resolving.parse-wanted-dependency':
specifier: workspace:*
version: link:../../resolving/parse-wanted-dependency
@@ -8084,6 +8056,34 @@ importers:
specifier: workspace:*
version: 'link:'
pnpr/client:
dependencies:
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/logger':
specifier: 'catalog:'
version: 1001.0.1
'@pnpm/store.cafs':
specifier: workspace:*
version: link:../../store/cafs
'@pnpm/store.index':
specifier: workspace:*
version: link:../../store/index
'@pnpm/worker':
specifier: workspace:^
version: link:../../worker
devDependencies:
'@pnpm/pnpr.client':
specifier: workspace:*
version: 'link:'
'@pnpm/types':
specifier: workspace:*
version: link:../../core/types
registry-access/client:
dependencies:
'@pnpm/error':

View File

@@ -39,7 +39,7 @@ packages:
- testing/*
- worker
- pnpm/artifacts/*
- agent/*
- pnpr/client
- registry-access/*
- releasing/*
- resolving/*

View File

@@ -12,7 +12,7 @@ import { execPnpm } from '../utils/index.js'
// The pnpr server started by the test harness (see the with-registry jest
// preset) serves the install-accelerator endpoints (/v1/install, /v1/files)
// on the registry-mock port, so it doubles as the agent under test.
// on the registry-mock port, so it doubles as the pnpr server under test.
const PNPR = `http://localhost:${REGISTRY_MOCK_PORT}`
let server: http.Server
@@ -21,7 +21,7 @@ let requestCount: number
beforeAll(async () => {
// Counting proxy — forwards to the pnpr server and counts /v1/install
// requests so we can assert that the agent path was actually taken.
// requests so we can assert that the pnpr server path was actually taken.
requestCount = 0
server = http.createServer((req, res) => {
if (!req.url) {
@@ -55,7 +55,7 @@ afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()))
})
test('pnpm install uses pnpm agent when configured', async () => {
test('pnpm install uses pnpr server when configured', async () => {
prepare({
dependencies: {
'is-positive': '1.0.0',
@@ -65,7 +65,7 @@ test('pnpm install uses pnpm agent when configured', async () => {
requestCount = 0
await execPnpm(
['install', `--config.agent=http://localhost:${serverPort}`]
['install', `--config.pnprServer=http://localhost:${serverPort}`]
)
// Verify the registry server received at least one request
@@ -78,25 +78,48 @@ test('pnpm install uses pnpm agent when configured', async () => {
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
})
test('a second resolution forwards the existing lockfile to the agent', async () => {
test('pnpm install resolves optionalDependencies via the pnpr server', async () => {
prepare({
dependencies: {
'is-positive': '1.0.0',
},
optionalDependencies: {
'is-negative': '1.0.0',
},
})
requestCount = 0
await execPnpm(
['install', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
// The optional dependency must be forwarded to the server and resolved,
// not silently dropped from the request.
expect(fs.existsSync('node_modules/is-negative')).toBe(true)
})
test('a second resolution forwards the existing lockfile to the pnpr server', async () => {
prepare({})
// First add creates the lockfile.
await execPnpm(['add', 'is-positive@1.0.0', `--config.agent=http://localhost:${serverPort}`])
await execPnpm(['add', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`])
expect(fs.existsSync(WANTED_LOCKFILE)).toBe(true)
// Second add reads that lockfile and forwards it to the agent for
// Second add reads that lockfile and forwards it to the pnpr server for
// incremental resolution — exercises the on-disk lockfile being sent
// over the wire without an in-memory round-trip.
requestCount = 0
await execPnpm(['add', 'is-negative@1.0.0', `--config.agent=http://localhost:${serverPort}`])
await execPnpm(['add', 'is-negative@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`])
expect(requestCount).toBeGreaterThanOrEqual(1)
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
expect(fs.existsSync('node_modules/is-negative')).toBe(true)
})
test('pnpm add uses pnpm agent when configured', async () => {
test('pnpm add uses pnpr server when configured', async () => {
prepare({
dependencies: {
'is-negative': '1.0.0',
@@ -106,7 +129,7 @@ test('pnpm add uses pnpm agent when configured', async () => {
requestCount = 0
await execPnpm(
['add', 'is-positive@1.0.0', `--config.agent=http://localhost:${serverPort}`]
['add', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
@@ -122,7 +145,7 @@ test('pnpm add uses pnpm agent when configured', async () => {
expect(manifest.dependencies?.['is-negative']).toBe('1.0.0')
})
test('pnpm remove uses pnpm agent when configured', async () => {
test('pnpm remove uses pnpr server when configured', async () => {
prepare({
dependencies: {
'is-positive': '1.0.0',
@@ -133,7 +156,7 @@ test('pnpm remove uses pnpm agent when configured', async () => {
requestCount = 0
await execPnpm(
['remove', 'is-negative', `--config.agent=http://localhost:${serverPort}`]
['remove', 'is-negative', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
@@ -147,32 +170,32 @@ test('pnpm remove uses pnpm agent when configured', async () => {
expect(manifest.dependencies?.['is-negative']).toBeUndefined()
})
test('pnpm add without a version uses the pnpm agent and writes the save-prefix spec from the lockfile', async () => {
test('pnpm add without a version uses the pnpr server and writes the save-prefix spec from the lockfile', async () => {
prepare({})
requestCount = 0
await execPnpm(
['add', 'is-positive', `--config.agent=http://localhost:${serverPort}`]
['add', 'is-positive', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
const manifest = loadJsonFileSync<{ dependencies?: Record<string, string> }>('package.json')
// The agent resolves "latest" to a concrete version and writes the
// The pnpr server resolves "latest" to a concrete version and writes the
// resolved version into the lockfile importer's `dependencies` map; the
// client computes the save-prefix spec from that version.
expect(manifest.dependencies?.['is-positive']).toMatch(/^\^\d+\.\d+\.\d+$/)
})
test('pnpm add -D uses pnpm agent and targets devDependencies', async () => {
test('pnpm add -D uses pnpr server and targets devDependencies', async () => {
prepare({})
requestCount = 0
await execPnpm(
['add', '-D', 'is-positive@1.0.0', `--config.agent=http://localhost:${serverPort}`]
['add', '-D', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
@@ -186,13 +209,13 @@ test('pnpm add -D uses pnpm agent and targets devDependencies', async () => {
expect(manifest.dependencies?.['is-positive']).toBeUndefined()
})
test('pnpm add with multiple selectors uses pnpm agent', async () => {
test('pnpm add with multiple selectors uses pnpr server', async () => {
prepare({})
requestCount = 0
await execPnpm(
['add', 'is-positive@1.0.0', 'is-negative@1.0.0', `--config.agent=http://localhost:${serverPort}`]
['add', 'is-positive@1.0.0', 'is-negative@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
@@ -204,7 +227,7 @@ test('pnpm add with multiple selectors uses pnpm agent', async () => {
expect(manifest.dependencies?.['is-negative']).toBe('1.0.0')
})
test('pnpm --filter remove inside a workspace uses pnpm agent', async () => {
test('pnpm --filter remove inside a workspace uses pnpr server', async () => {
preparePackages([
{
name: 'project-a',
@@ -228,7 +251,7 @@ test('pnpm --filter remove inside a workspace uses pnpm agent', async () => {
requestCount = 0
await execPnpm(
['--filter=project-b', 'remove', 'is-negative', `--config.agent=http://localhost:${serverPort}`]
['--filter=project-b', 'remove', 'is-negative', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
@@ -244,7 +267,7 @@ test('pnpm --filter remove inside a workspace uses pnpm agent', async () => {
expect(projectBManifest.dependencies?.['is-positive']).toBe('1.0.0')
})
test('pnpm add inside a workspace project uses pnpm agent', async () => {
test('pnpm add inside a workspace project uses pnpr server', async () => {
preparePackages([
{
name: 'project-a',
@@ -264,7 +287,7 @@ test('pnpm add inside a workspace project uses pnpm agent', async () => {
requestCount = 0
await execPnpm(
['--filter=project-b', 'add', 'is-negative@1.0.0', `--config.agent=http://localhost:${serverPort}`]
['--filter=project-b', 'add', 'is-negative@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
)
expect(requestCount).toBeGreaterThanOrEqual(1)
@@ -279,7 +302,7 @@ test('pnpm add inside a workspace project uses pnpm agent', async () => {
expect(projectBManifest.dependencies?.['is-negative']).toBe('1.0.0')
})
test('pnpm install with agent works in a workspace with multiple projects', async () => {
test('pnpm install with pnpr server works in a workspace with multiple projects', async () => {
preparePackages([
{
name: 'project-a',
@@ -302,10 +325,10 @@ test('pnpm install with agent works in a workspace with multiple projects', asyn
requestCount = 0
await execPnpm(
['install', `--config.agent=http://localhost:${serverPort}`]
['install', `--config.pnprServer=http://localhost:${serverPort}`]
)
// Verify the agent server was used
// Verify the pnpr server was used
expect(requestCount).toBeGreaterThanOrEqual(1)
// Verify the lockfile was created

View File

View File

@@ -1,11 +1,11 @@
# @pnpm/agent.client
# @pnpm/pnpr.client
Client library for the pnpm agent server. Reads the local store state, sends it to the server, and writes the received files into the content-addressable store.
Client library for the pnpr server. Reads the local store state, sends it to the server, and writes the received files into the content-addressable store.
## How it works
1. Reads integrity hashes from the local store index (`index.db`).
2. Sends `POST /v1/install` to the pnpm agent server with the project's dependencies and the store integrities.
2. Sends `POST /v1/install` to the pnpr server with the project's dependencies and the store integrities.
3. Parses the NDJSON streaming response — `D`-lines (missing file digests) are dispatched to worker downloads against `/v1/files`, `I`-lines are buffered as raw store-index entries, and the final `L`-line yields the resolved lockfile and stats.
4. File download workers write each received file directly to the local CAFS (`files/{hash[:2]}/{hash[2:]}`).
5. Writes store index entries for all new packages in a single SQLite transaction.
@@ -13,10 +13,10 @@ Client library for the pnpm agent server. Reads the local store state, sends it
## Usage
This package is used internally by pnpm when the `agent` config option is set. It is not intended to be called directly, but can be used programmatically:
This package is used internally by pnpm when the `pnprServer` config option is set. It is not intended to be called directly, but can be used programmatically:
```typescript
import { fetchFromPnpmRegistry } from '@pnpm/agent.client'
import { fetchFromPnpmRegistry } from '@pnpm/pnpr.client'
import { StoreIndex } from '@pnpm/store.index'
const storeIndex = new StoreIndex('/path/to/store')
@@ -39,5 +39,5 @@ console.log(`${stats.alreadyInStore} cached, ${stats.filesToDownload} files down
Add to `pnpm-workspace.yaml` to enable automatically during `pnpm install`:
```yaml
agent: http://localhost:4000
pnprServer: http://localhost:4000
```

View File

@@ -1,15 +1,15 @@
{
"name": "@pnpm/agent.client",
"name": "@pnpm/pnpr.client",
"version": "1.0.8",
"description": "Client for pnpm agent server — sends store state, receives resolved lockfile and missing files",
"description": "Client for the pnpr server — sends store state, receives resolved lockfile and missing files",
"keywords": [
"pnpm",
"pnpm11"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/agent/client",
"homepage": "https://github.com/pnpm/pnpm/tree/main/agent/client#readme",
"repository": "https://github.com/pnpm/pnpm/tree/main/pnpr/client",
"homepage": "https://github.com/pnpm/pnpm/tree/main/pnpr/client#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
@@ -41,7 +41,7 @@
"@pnpm/worker": "workspace:^"
},
"devDependencies": {
"@pnpm/agent.client": "workspace:*",
"@pnpm/pnpr.client": "workspace:*",
"@pnpm/types": "workspace:*"
},
"engines": {

View File

@@ -9,15 +9,16 @@ import { fetchAndWriteCafsFiles } from '@pnpm/worker'
import type { ResponseMetadata } from './protocol.js'
export interface AgentProject {
export interface PnprProject {
/** Relative dir within the workspace (e.g. "." or "packages/foo") */
dir: string
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
}
export interface FetchFromPnpmRegistryOptions {
/** URL of the pnpm agent server */
/** URL of the pnpr server */
registryUrl: string
/** Client's store directory */
storeDir: string
@@ -27,8 +28,10 @@ export interface FetchFromPnpmRegistryOptions {
dependencies?: Record<string, string>
/** Dev dependencies to resolve (single project) */
devDependencies?: Record<string, string>
/** Optional dependencies to resolve (single project) */
optionalDependencies?: Record<string, string>
/** Multiple projects in a workspace */
projects?: AgentProject[]
projects?: PnprProject[]
/** Overrides */
overrides?: Record<string, string>
/** Node.js version for resolution */
@@ -62,7 +65,7 @@ export interface FetchFromPnpmRegistryResult {
}
/**
* Fetch resolved dependencies from a pnpm agent server.
* Fetch resolved dependencies from a pnpr server.
*
* The response is a streaming NDJSON where each line is one message:
* - `D\t{digest}\t{size}\t{executable}\n` file digest (streamed as packages resolve)
@@ -81,6 +84,7 @@ export async function fetchFromPnpmRegistry (
dir: '.',
dependencies: opts.dependencies,
devDependencies: opts.devDependencies,
optionalDependencies: opts.optionalDependencies,
}]
const requestBody = JSON.stringify({
@@ -169,7 +173,7 @@ export async function fetchFromPnpmRegistry (
} else if (type === 'E') {
// Server emitted a structured error after headers were sent.
// Record it so stream `end` / `catch` can reject with the payload.
let message = 'pnpm agent server error'
let message = 'pnpr server error'
try {
const payload = JSON.parse(line.substring(tabIdx + 1)) as { error?: string }
if (payload?.error) message = payload.error
@@ -189,7 +193,7 @@ export async function fetchFromPnpmRegistry (
if (serverError) {
reject(serverError)
} else if (!resolved) {
reject(new Error('pnpm agent server closed the stream without emitting a lockfile'))
reject(new Error('pnpr server closed the stream without emitting a lockfile'))
}
}, reject)
})
@@ -203,7 +207,7 @@ function readStoreIntegrities (storeIndex: StoreIndex): string[] {
const integrity = key.slice(0, tabIdx)
// StoreIndex also stores non-integrity keys (e.g. git-hosted entries
// keyed by URL). Filter to actual SRI hashes — sending those over to
// the agent server would just bloat the request without ever matching.
// the pnpr server would just bloat the request without ever matching.
if (!isIntegrityLike(integrity)) continue
seen.add(integrity)
}
@@ -231,7 +235,7 @@ async function streamNdjsonRequest (
): Promise<void> {
// `urlPath` is expected to be relative (e.g. "v1/install"). We normalize
// the base to end with "/" so `new URL(rel, base)` preserves any path
// prefix configured on the agent URL (e.g. https://host/pnpr/).
// prefix configured on the pnpr server URL (e.g. https://host/pnpr/).
const base = registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`
const url = new URL(urlPath, base)
const isHttps = url.protocol === 'https:'
@@ -251,7 +255,7 @@ async function streamNdjsonRequest (
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
reject(new Error(`pnpm agent responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
reject(new Error(`pnpr server responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
})
return
}
@@ -274,11 +278,11 @@ async function streamNdjsonRequest (
})
req.on('timeout', () => {
req.destroy(new Error(`pnpm agent request timed out after ${REQUEST_TIMEOUT / 1000}s (${registryUrl})`))
req.destroy(new Error(`pnpr server request timed out after ${REQUEST_TIMEOUT / 1000}s (${registryUrl})`))
})
req.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ECONNREFUSED') {
reject(new Error(`Could not connect to pnpm agent at ${registryUrl}. Is the server running?`))
reject(new Error(`Could not connect to pnpr server at ${registryUrl}. Is the server running?`))
} else {
reject(err)
}

View File

@@ -1,5 +1,5 @@
//! Wire types for the pnpr install-accelerator endpoints, matching the
//! `@pnpm/agent.client` TypeScript client's request shapes.
//! `@pnpm/pnpr.client` TypeScript client's request shapes.
use std::collections::BTreeMap;
@@ -18,6 +18,8 @@ pub struct InstallRequestProject {
pub dependencies: DepMap,
#[serde(default)]
pub dev_dependencies: DepMap,
#[serde(default)]
pub optional_dependencies: DepMap,
}
fn root_dir() -> String {
@@ -39,6 +41,8 @@ pub struct InstallRequest {
#[serde(default)]
pub dev_dependencies: Option<DepMap>,
#[serde(default)]
pub optional_dependencies: Option<DepMap>,
#[serde(default)]
pub projects: Option<Vec<InstallRequestProject>>,
#[serde(default)]
pub store_integrities: Vec<String>,
@@ -119,6 +123,7 @@ pub struct ProjectDeps {
pub dir: String,
pub dependencies: DepMap,
pub dev_dependencies: DepMap,
pub optional_dependencies: DepMap,
}
impl InstallRequest {
@@ -134,6 +139,7 @@ impl InstallRequest {
dir: project.dir.clone(),
dependencies: project.dependencies.clone(),
dev_dependencies: project.dev_dependencies.clone(),
optional_dependencies: project.optional_dependencies.clone(),
})
.collect();
}
@@ -141,6 +147,7 @@ impl InstallRequest {
dir: root_dir(),
dependencies: self.dependencies.clone().unwrap_or_default(),
dev_dependencies: self.dev_dependencies.clone().unwrap_or_default(),
optional_dependencies: self.optional_dependencies.clone().unwrap_or_default(),
}]
}
}

View File

@@ -101,6 +101,7 @@ pub async fn resolve(
"version": "0.0.0",
"dependencies": project.dependencies,
"devDependencies": project.dev_dependencies,
"optionalDependencies": project.optional_dependencies,
});
let manifest_bytes = serde_json::to_vec(&manifest_json)
.map_err(|err| ResolveError::Install(err.to_string()))?;

View File

@@ -284,7 +284,7 @@ export async function readPkgFromCafs (
let limitImportingPackage = pLimit(4)
/**
* Temporarily change import concurrency. Called by the pnpm agent code path
* Temporarily change import concurrency. Called by the pnpr server code path
* where there's no concurrent fetching competing for workers. Returns a
* disposer that restores the previous limiter — callers must invoke it (in a
* finally block) to avoid leaking the mutation to other installs in the same

View File

@@ -508,7 +508,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
const { createGunzip } = await import('node:zlib')
const { contentPathFromHex } = await import('@pnpm/store.cafs')
// Preserve any path prefix on the agent URL (e.g. https://host/pnpr/)
// Preserve any path prefix on the pnpr server URL (e.g. https://host/pnpr/)
// by normalizing the base and using a relative URL.
const base = message.registryUrl.endsWith('/') ? message.registryUrl : `${message.registryUrl}/`
const url = new URL('v1/files', base)
@@ -517,7 +517,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
const createdDirs = new Set<string>()
// Build a set of digests we actually requested, so we can reject a
// misbehaving agent that streams unrelated entries and tries to write
// misbehaving pnpr server that streams unrelated entries and tries to write
// unbounded files into our CAFS. The set is keyed by `${digest}:${exec}`
// because the same digest may appear with different modes.
const requestedDigests = new Set<string>()
@@ -565,7 +565,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
const requestKey = `${digest}:${executable ? 'x' : ''}`
if (!requestedDigests.has(requestKey)) {
throw new Error(`pnpm agent /v1/files returned an entry that was not requested: digest=${digest} executable=${String(executable)}`)
throw new Error(`pnpr server /v1/files returned an entry that was not requested: digest=${digest} executable=${String(executable)}`)
}
// Consume the request so duplicates past the requested count also fail.
requestedDigests.delete(requestKey)
@@ -588,7 +588,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
// EEXIST means the same digest is already at this CAFS path. CAFS
// is content-addressed, so a complete file is by definition correct.
// But a previous process could have crashed mid-write and left a
// truncated file — the agent path skips integrity verification, so
// truncated file — the pnpr server path skips integrity verification, so
// we'd silently install garbage. Detect truncation by size and
// overwrite atomically if the on-disk file is the wrong length.
const onDiskSize = fs.statSync(fullPath).size
@@ -630,13 +630,13 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
'Accept-Encoding': 'gzip',
},
}, (res: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
// Non-2xx responses are JSON error bodies from the agent server; read
// Non-2xx responses are JSON error bodies from the pnpr server; read
// and reject so we never try to gunzip an error payload as a file stream.
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
reject(new Error(`pnpm agent /v1/files responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
reject(new Error(`pnpr server /v1/files responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
})
res.on('error', reject)
return
@@ -660,11 +660,11 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
// partial entry still in `buf`, fail — otherwise we'd silently leave
// the CAFS missing files.
if (!endMarkerSeen) {
reject(new Error('pnpm agent /v1/files stream ended without the end marker'))
reject(new Error('pnpr server /v1/files stream ended without the end marker'))
return
}
if (buf.length > 0) {
reject(new Error(`pnpm agent /v1/files stream left ${buf.length} unparsed bytes after end marker`))
reject(new Error(`pnpr server /v1/files stream left ${buf.length} unparsed bytes after end marker`))
return
}
// Every received entry was drained from `requestedDigests` as it was
@@ -672,7 +672,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
// but omitted files, which would silently leave the CAFS incomplete.
if (requestedDigests.size > 0) {
const sample = [...requestedDigests].slice(0, 3).join(', ')
reject(new Error(`pnpm agent /v1/files omitted ${requestedDigests.size} requested entries (e.g. ${sample})`))
reject(new Error(`pnpr server /v1/files omitted ${requestedDigests.size} requested entries (e.g. ${sample})`))
return
}
resolve({ status: 'success', filesWritten })
@@ -680,7 +680,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
stream.on('error', reject)
})
req.on('timeout', () => {
req.destroy(new Error(`pnpm agent /v1/files request timed out after ${FILES_REQUEST_TIMEOUT_MS / 1000}s`))
req.destroy(new Error(`pnpr server /v1/files request timed out after ${FILES_REQUEST_TIMEOUT_MS / 1000}s`))
})
req.on('error', reject)
req.write(body)