Files
pnpm/agent/server/src/createRegistryServer.ts
Zoltan Kochan ccc606ed15 feat: pnpm agent — server-side resolution for faster installs (#11251)
## Summary

Adds an opt-in **pnpm agent** server that resolves dependencies server-side and streams only the files missing from the client's content-addressable store.

- **`@pnpm/agent.server`** — multi-process HTTP server (Node.js `cluster`) with SQLite-backed metadata and file caches
- **`@pnpm/agent.client`** — streams an NDJSON response, dispatches worker threads to fetch files while the server is still resolving
- **New config**: `agent` in `pnpm-workspace.yaml` (opt-in)

## How it works

1. Client reads integrity hashes from its local store index
2. Sends `POST /v1/install` with dependencies + store integrities
3. Server resolves the dependency tree using pnpm's `install({ lockfileOnly: true })`, with a SQLite-backed `PackageMetaCache` for fast repeat resolution
4. As each package resolves, a wrapped `storeController.requestPackage` looks up its files and immediately streams digests the client is missing (NDJSON `D` lines)
5. Client reads the stream line by line; digest batches fill up and dispatch worker threads to `POST /v1/files` — file downloads overlap with server-side resolution
6. After resolution, server sends index entries (`I` lines) and lockfile (`L` line)
7. Client writes index entries to store, then runs headless install with a wrapped `fetchPackage` that calls `readPkgFromCafs` with `verifyStoreIntegrity: false` (files are trusted from the agent)
8. `/v1/files` response is gzip-streamed (274MB → ~80MB) — server pipes through `createGzip`, worker pipes through `createGunzip`, parsing and writing files to CAFS as data arrives

## Performance

1351-package project, cold local store, warm server (localhost):

| Scenario | Time |
|----------|------|
| Vanilla pnpm install (cold OS cache) | ~48s |
| Vanilla pnpm install (warm OS cache) | ~34s |
| With pnpm agent (consistent) | **~33s** |

### Key optimizations

1. **SQLite metadata cache** — server-side resolution drops from ~3.4s to ~0.9s
2. **SQLite file store** — consistent read performance regardless of OS file cache state
3. **Streaming `/v1/install`** — file digests stream during resolution, downloads start before resolution finishes
4. **Gzip-streamed `/v1/files`** — whole-stream gzip (274MB → ~80MB), significant savings on remote servers
5. **Worker-thread streaming HTTP** — workers pipe gzip → parse → write to CAFS as data arrives, no buffering
6. **No rehashing** — server-provided digests used directly, skipping 33K SHA-512 computations
7. **No re-verification** — wrapped `fetchPackage` calls `readPkgFromCafs` with `verifyStoreIntegrity: false`
8. **Direct `writeFileSync` with `wx`** — no stat + temp + rename
9. **Pre-packed msgpack** — server sends raw store index buffers, client writes directly to SQLite
10. **WAL checkpoint** — ensures store index entries written by agent are visible to headless install's worker threads

## Usage

Start the server:
```bash
node agent/server/lib/bin.js
```

Configure in `pnpm-workspace.yaml`:
```yaml
agent: http://localhost:4873
```
2026-04-20 11:56:46 +02:00

575 lines
21 KiB
TypeScript

import { promises as fs, readFileSync } from 'node:fs'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import { createGzip } from 'node:zlib'
import { createClient } from '@pnpm/installing.client'
import type { InstallOptions } from '@pnpm/installing.deps-installer'
import { install } from '@pnpm/installing.deps-installer'
import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs'
import type { LockfileObject } from '@pnpm/lockfile.types'
import { getFilePathByModeInCafs } from '@pnpm/store.cafs'
import { createPackageStore, type StoreController } from '@pnpm/store.controller'
import { StoreIndex } from '@pnpm/store.index'
import type { Registries } from '@pnpm/types'
import { buildIntegrityIndex, computeDiff, getFilesEntries, type IntegrityEntry } from './diff.js'
import { FileStore } from './fileStore.js'
import { MetadataStore } from './metadataStore.js'
export interface RegistryServerOptions {
/** Directory for the server's content-addressable store */
storeDir: string
/** Directory for metadata cache */
cacheDir: string
/** Upstream registries to resolve from */
registries?: Registries
/**
* Skip importing .jsonl metadata files into SQLite on startup. Set this
* to true in forked cluster workers when the primary has already run
* {@link warmupMetadataCache} so workers don't re-scan the cache dir.
*/
skipCacheImport?: boolean
}
/**
* Import .jsonl metadata files from the cache directory into SQLite.
*
* This is idempotent and safe to run multiple times, but it's expensive on
* large stores. When running with `cluster`, invoke this once in the primary
* before forking workers so every worker doesn't independently re-scan.
*/
export async function warmupMetadataCache (opts: {
storeDir: string
cacheDir: string
}): Promise<number> {
await fs.mkdir(opts.storeDir, { recursive: true })
await fs.mkdir(opts.cacheDir, { recursive: true })
const metadataStore = new MetadataStore(path.join(opts.storeDir, 'metadata.db'))
try {
return metadataStore.importFromCacheDir(opts.cacheDir)
} finally {
metadataStore.close()
}
}
interface ServerContext {
storeController: StoreController
storeIndex: StoreIndex
storeDir: string
cacheDir: string
registries: Registries
integrityIndex: Map<string, IntegrityEntry>
fileStore: FileStore
}
export async function createRegistryServer (opts: RegistryServerOptions): Promise<http.Server> {
const storeDir = opts.storeDir
const cacheDir = opts.cacheDir
const registries: Registries = opts.registries ?? { default: 'https://registry.npmjs.org/' }
await fs.mkdir(storeDir, { recursive: true })
await fs.mkdir(cacheDir, { recursive: true })
const storeIndex = new StoreIndex(storeDir)
// Pre-populate metadata cache from .jsonl files into SQLite.
// This makes resolution fast — indexed DB lookup instead of
// reading/parsing hundreds of multi-MB JSON files from disk.
// Skipped when `skipCacheImport` is set (e.g. cluster workers where the
// primary already ran this step).
const metadataStore = new MetadataStore(path.join(storeDir, 'metadata.db'))
if (!opts.skipCacheImport) {
const metaImported = metadataStore.importFromCacheDir(cacheDir)
if (metaImported > 0) {
console.log(` imported ${metaImported} metadata entries to SQLite`)
}
}
const { resolve, fetchers, clearResolutionCache } = createClient({
cacheDir,
storeDir,
storeIndex,
registries,
configByUri: {},
metaCache: metadataStore as any, // eslint-disable-line @typescript-eslint/no-explicit-any
retry: {
retries: 3,
factor: 10,
maxTimeout: 60_000,
minTimeout: 10_000,
},
strictSsl: true,
})
const storeController = createPackageStore(resolve, fetchers, {
cacheDir,
storeDir,
storeIndex,
verifyStoreIntegrity: true,
virtualStoreDirMaxLength: 120,
clearResolutionCache,
})
const integrityIndex = buildIntegrityIndex(storeIndex)
const fileStore = new FileStore(path.join(storeDir, 'files.db'))
const ctx: ServerContext = {
storeController,
storeIndex,
storeDir,
cacheDir,
registries,
integrityIndex,
fileStore,
}
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/v1/install') {
await handleInstall(req, res, ctx)
} else if (req.method === 'POST' && req.url === '/v1/files') {
await handleFiles(req, res, ctx)
} else {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Internal server error'
const statusCode = typeof (err as { statusCode?: unknown })?.statusCode === 'number'
? (err as { statusCode: number }).statusCode
: 500
if (!res.headersSent) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: message }))
return
}
// Headers already sent — emit an NDJSON error line for /v1/install so the
// client can reject with a structured error. For the binary /v1/files
// stream there's no recoverable framing, so just destroy the response.
const contentType = res.getHeader('Content-Type')
if (typeof contentType === 'string' && contentType.includes('ndjson')) {
res.end(`E\t${JSON.stringify({ error: message })}\n`)
} else {
res.destroy()
}
}
})
// Close SQLite databases when the server shuts down.
// On Windows, files can't be deleted while SQLite has them open.
server.on('close', () => {
fileStore.close()
metadataStore.close()
storeIndex.close()
})
return server
}
interface InstallRequestProject {
dir: string
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
interface InstallRequest {
/** Single project (legacy) */
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
/** Multiple projects (workspace) */
projects?: InstallRequestProject[]
overrides?: Record<string, string>
peerDependencyRules?: Record<string, unknown>
nodeVersion?: string
os?: string
arch?: string
minimumReleaseAge?: number
lockfile?: LockfileObject
storeIntegrities?: string[]
}
async function handleInstall (
req: http.IncomingMessage,
res: http.ServerResponse,
ctx: ServerContext
): Promise<void> {
const t0 = performance.now()
const body = await readBody(req, INSTALL_BODY_MAX_BYTES)
let request: InstallRequest
try {
request = JSON.parse(body)
} catch (err: unknown) {
res.writeHead(400, { 'Content-Type': 'application/json' })
const message = err instanceof Error ? err.message : 'Invalid JSON body'
res.end(JSON.stringify({ error: message }))
return
}
// Build the set of integrities the client already has, so we can
// compute per-package diffs as packages resolve.
const clientIntegrities = new Set(request.storeIntegrities ?? [])
const emittedDigests = new Set<string>()
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
})
// Wrap the store controller to intercept package resolutions.
// As each package resolves, stream file digest lines immediately.
// Only D lines here — I lines come from the diff computation which
// uses depPath-based keys matching what headless install expects.
const wrappedStoreController: StoreController = {
...ctx.storeController,
requestPackage: async (...args: Parameters<StoreController['requestPackage']>) => {
const result = await ctx.storeController.requestPackage(...args)
const integrity = (result.body as any)?.resolution?.integrity as string | undefined // eslint-disable-line @typescript-eslint/no-explicit-any
if (integrity && !clientIntegrities.has(integrity)) {
const entry = ctx.integrityIndex.get(integrity)
if (entry) {
for (const [, fileInfo] of getFilesEntries(entry.decoded)) {
const executable = (fileInfo.mode & 0o111) !== 0
const dedupeKey = `${fileInfo.digest}:${executable ? 'x' : ''}`
if (!emittedDigests.has(dedupeKey)) {
emittedDigests.add(dedupeKey)
res.write(`D\t${fileInfo.digest}\t${fileInfo.size}\t${executable ? 1 : 0}\n`)
}
}
}
}
return result
},
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-agent-'))
try {
// Build project list — support both single-project and workspace
const projects = request.projects ?? [{
dir: '.',
dependencies: request.dependencies,
devDependencies: request.devDependencies,
}]
// Validate every `dir` is a safe relative path. A malicious client
// could pass "../../etc" or an absolute path, which would escape
// tmpDir and let the server write files outside its workspace.
for (const project of projects) {
if (!isSafeRelativeDir(project.dir)) {
res.end(`E\t${JSON.stringify({ error: `Invalid project dir: ${project.dir}` })}\n`)
return
}
}
// Create package.json for each project
await Promise.all(projects.map(async (project) => {
const projectDir = path.join(tmpDir, project.dir)
await fs.mkdir(projectDir, { recursive: true })
await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify({
name: `pnpm-agent-resolve-${project.dir.replace(/[/\\]/g, '-')}`,
version: '0.0.0',
dependencies: project.dependencies ?? {},
devDependencies: project.devDependencies ?? {},
}, null, 2)
)
}))
if (request.lockfile) {
await writeWantedLockfile(tmpDir, request.lockfile)
}
// Resolution — the wrapped store controller streams digest info
// to the client as each package resolves.
if (projects.length === 1) {
const manifest = {
name: 'pnpm-agent-resolve',
version: '0.0.0',
dependencies: projects[0].dependencies ?? {},
devDependencies: projects[0].devDependencies ?? {},
}
await install(manifest, {
dir: path.join(tmpDir, projects[0].dir),
lockfileDir: tmpDir,
storeController: wrappedStoreController,
storeDir: ctx.storeDir,
cacheDir: ctx.cacheDir,
registries: ctx.registries,
ignoreScripts: true,
lockfileOnly: true,
lockfileIncludeTarballUrl: true,
saveLockfile: true,
preferFrozenLockfile: false,
minimumReleaseAge: request.minimumReleaseAge,
} as InstallOptions)
} else {
// Workspace: create pnpm-workspace.yaml and resolve all projects
const packagesYaml = `packages:\n${projects.map(p => ` - '${p.dir}'`).join('\n')}\n`
await fs.writeFile(path.join(tmpDir, 'pnpm-workspace.yaml'), packagesYaml)
const { mutateModules } = await import('@pnpm/installing.deps-installer')
await mutateModules(
projects.map(p => ({
mutation: 'install' as const,
rootDir: path.join(tmpDir, p.dir) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
})),
{
allProjects: projects.map((p, i) => ({
buildIndex: i,
manifest: {
name: `pnpm-agent-resolve-${p.dir.replace(/[/\\]/g, '-')}`,
version: '0.0.0',
dependencies: p.dependencies ?? {},
devDependencies: p.devDependencies ?? {},
},
rootDir: path.join(tmpDir, p.dir) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
})),
lockfileDir: tmpDir,
storeController: wrappedStoreController,
storeDir: ctx.storeDir,
cacheDir: ctx.cacheDir,
registries: ctx.registries,
ignoreScripts: true,
lockfileOnly: true,
lockfileIncludeTarballUrl: true,
saveLockfile: true,
preferFrozenLockfile: false,
minimumReleaseAge: request.minimumReleaseAge,
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
)
}
const resolvedLockfile = await readWantedLockfile(tmpDir, {
ignoreIncompatible: false,
})
if (!resolvedLockfile) {
res.end('E\t{"error":"Resolution produced no lockfile"}\n')
return
}
// Fetch tarballs for new packages (hot server = no-op)
const integrityIndexBefore = buildIntegrityIndex(ctx.storeIndex)
const fetchPromises: Array<Promise<void>> = []
for (const [, pkgSnapshot] of Object.entries(resolvedLockfile.packages ?? {})) {
const resolution = pkgSnapshot.resolution as { integrity?: string, tarball?: string } | undefined
if (!resolution?.integrity || !resolution?.tarball) continue
if (integrityIndexBefore.has(resolution.integrity)) continue
fetchPromises.push((async () => {
const result = await ctx.storeController.fetchPackage({
force: false,
lockfileDir: tmpDir,
pkg: {
id: resolution.tarball as any, // eslint-disable-line @typescript-eslint/no-explicit-any
name: '',
version: '',
resolution: resolution as any, // eslint-disable-line @typescript-eslint/no-explicit-any
},
})
if (result.fetching) {
await result.fetching()
}
})())
}
if (fetchPromises.length > 0) {
await Promise.all(fetchPromises)
}
const integrityIndex = buildIntegrityIndex(ctx.storeIndex)
ctx.integrityIndex = integrityIndex
// Emit remaining digests + index entries for packages not caught
// by the wrapper (e.g. resolved from a cached lockfile path).
const diff = computeDiff(
resolvedLockfile,
request.storeIntegrities ?? [],
integrityIndex,
ctx.storeDir
)
for (const f of diff.missingFiles) {
const dedupeKey = `${f.digest}:${f.executable ? 'x' : ''}`
if (!emittedDigests.has(dedupeKey)) {
emittedDigests.add(dedupeKey)
res.write(`D\t${f.digest}\t${f.size}\t${f.executable ? 1 : 0}\n`)
}
}
for (const [depPath, { integrity, rawBuffer }] of diff.packageIndexBuffers) {
const pkgId = depPath.includes('(') ? depPath.substring(0, depPath.indexOf('(')) : depPath
const key = `${integrity}\t${pkgId}`
res.write(`I\t${key}\t${Buffer.from(rawBuffer).toString('base64')}\n`)
}
// Send lockfile AFTER all I lines so the client has all index
// entries before it resolves and starts headless install.
res.write(`L\t${JSON.stringify({ lockfile: resolvedLockfile, stats: diff.metadata.stats })}\n`)
res.end()
console.log(`[SERVER /v1/install] ${(performance.now() - t0).toFixed(0)}ms digests=${emittedDigests.size}`)
} finally {
await fs.rm(tmpDir, { recursive: true, force: true })
}
}
/**
* Handle POST /v1/files — serve a batch of files by digest.
* Reads from SQLite file store for fast batch access (one DB
* vs 33K individual readFileSync calls).
*/
async function handleFiles (
req: http.IncomingMessage,
res: http.ServerResponse,
ctx: ServerContext
): Promise<void> {
const body = await readBody(req, FILES_BODY_MAX_BYTES)
let parsed: { digests?: unknown }
try {
parsed = JSON.parse(body) as { digests?: unknown }
} catch (err: unknown) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Invalid JSON body' }))
return
}
if (!Array.isArray(parsed.digests)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: '`digests` must be an array' }))
return
}
// Validate each entry's shape up front. Reading `d.digest` on a `null` or
// `{ digest: 123 }` entry would otherwise throw and surface as a 500 after
// headers may have been written. A valid sha512 digest is 128 lowercase hex
// characters (64 bytes); also reject the all-zero digest since it collides
// with the 64-byte end-of-stream marker.
const digests: Array<{ digest: string, executable: boolean }> = []
for (let i = 0; i < parsed.digests.length; i++) {
const entry = parsed.digests[i]
if (entry === null || typeof entry !== 'object') {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `digests[${i}] must be an object` }))
return
}
const { digest, executable } = entry as { digest?: unknown, executable?: unknown }
if (typeof digest !== 'string' || !isValidSha512Hex(digest)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `digests[${i}].digest must be a valid sha512 hex string` }))
return
}
if (typeof executable !== 'boolean') {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `digests[${i}].executable must be a boolean` }))
return
}
digests.push({ digest, executable })
}
// Stream file entries through gzip — no buffering. The server reads
// one file at a time and pipes it through gzip to the response.
// The worker starts writing to CAFS as soon as the first bytes arrive.
res.writeHead(200, {
'Content-Type': 'application/x-pnpm-install',
'Content-Encoding': 'gzip',
'Transfer-Encoding': 'chunked',
})
const gzip = createGzip({ level: 1 })
gzip.pipe(res)
// JSON header
const jsonBuffer = Buffer.from('{}', 'utf-8')
const lengthBuf = Buffer.alloc(4)
lengthBuf.writeUInt32BE(jsonBuffer.length, 0)
gzip.write(lengthBuf)
gzip.write(jsonBuffer)
// File entries — streamed through gzip. On a SQLite cache miss we read
// from CAFS and also insert the already-read buffer into the file store
// so subsequent batches can serve the same digest from SQLite (one
// `.get()` vs `readFileSync`) without re-reading from disk.
const missing: Array<{ digest: string, content: Buffer, executable: boolean }> = []
for (const d of digests) {
const cached = ctx.fileStore.get(d.digest)
let content: Buffer
if (cached) {
content = cached.content
} else {
content = readFileSync(getFilePathByModeInCafs(ctx.storeDir, d.digest, d.executable ? 0o755 : 0o644))
missing.push({ digest: d.digest, content, executable: d.executable })
}
const header = Buffer.alloc(69)
Buffer.from(d.digest, 'hex').copy(header, 0)
header.writeUInt32BE(content.length, 64)
header[68] = d.executable ? 0x01 : 0x00
gzip.write(header)
gzip.write(content)
}
if (missing.length > 0) {
ctx.fileStore.importMany(missing)
}
// End marker + close gzip stream (which ends the response)
gzip.end(Buffer.alloc(64, 0))
}
const SHA512_HEX_RE = /^[0-9a-f]{128}$/
// The wire protocol uses a 64-byte all-zero buffer as the end-of-stream marker.
// A sha512 whose hex representation is all zeros would serialize to the same
// 64 bytes and collide with the framing marker, so treat it as invalid input.
const ALL_ZERO_SHA512_HEX = '0'.repeat(128)
function isValidSha512Hex (digest: string): boolean {
return typeof digest === 'string' && SHA512_HEX_RE.test(digest) && digest !== ALL_ZERO_SHA512_HEX
}
// Characters that would let a crafted `dir` break out of the YAML single-quoted
// scalar we emit into `pnpm-workspace.yaml`, or inject shell metacharacters.
// A legitimate project directory never contains any of these.
const UNSAFE_DIR_CHAR_RE = /[\x00-\x1f'"`\\]/ // eslint-disable-line no-control-regex
function isSafeRelativeDir (dir: string): boolean {
if (typeof dir !== 'string' || dir.length === 0) return false
if (UNSAFE_DIR_CHAR_RE.test(dir)) return false
if (dir === '.') return true
if (path.isAbsolute(dir)) return false
// Reject Windows drive letters (e.g. "C:foo", "C:\\foo") even on POSIX.
if (/^[a-z]:/i.test(dir)) return false
const normalized = path.posix.normalize(dir.replace(/\\/g, '/'))
if (normalized.startsWith('../') || normalized === '..') return false
// Normalize must not produce an absolute-style path either.
if (normalized.startsWith('/')) return false
return true
}
// /v1/install bodies carry an optional lockfile + projects list; allow a
// generous ceiling but bound memory. /v1/files bodies only carry a list of
// digests, so the limit is smaller.
const INSTALL_BODY_MAX_BYTES = 64 * 1024 * 1024
const FILES_BODY_MAX_BYTES = 8 * 1024 * 1024
function readBody (req: http.IncomingMessage, maxBytes: number): Promise<string> {
return new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = []
let total = 0
req.on('data', (chunk: Buffer) => {
total += chunk.length
if (total > maxBytes) {
reject(Object.assign(new Error(`request body exceeds ${maxBytes} bytes`), { statusCode: 413 }))
req.destroy()
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
req.on('error', reject)
})
}