mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-20 20:52:33 -04:00
## 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
```
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
import { readdirSync, readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import path from 'node:path'
|
|
import type { DatabaseSync as _DatabaseSync } from 'node:sqlite'
|
|
|
|
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite')
|
|
|
|
// Matches @pnpm/resolving.npm-resolver's PackageMetaCache interface
|
|
// without adding a dependency on it.
|
|
interface PackageMetaCache {
|
|
get: (key: string) => unknown | undefined
|
|
set: (key: string, meta: unknown) => void
|
|
has: (key: string) => boolean
|
|
}
|
|
|
|
/**
|
|
* SQLite-backed PackageMetaCache for the pnpm agent server.
|
|
*
|
|
* Stores package metadata as pre-serialized JSON blobs keyed by cache key.
|
|
* Much faster than reading hundreds of .jsonl files from disk on every
|
|
* resolution — one indexed SQLite lookup vs file open + JSON parse.
|
|
*
|
|
* The server populates this at startup from the existing .jsonl cache files.
|
|
* Subsequent metadata fetches (from npm) also update SQLite via `set()`.
|
|
*/
|
|
export class MetadataStore implements PackageMetaCache {
|
|
private db: _DatabaseSync
|
|
private getStmt!: ReturnType<_DatabaseSync['prepare']>
|
|
private setStmt!: ReturnType<_DatabaseSync['prepare']>
|
|
private hasStmt!: ReturnType<_DatabaseSync['prepare']>
|
|
|
|
constructor (dbPath: string) {
|
|
this.db = new DatabaseSync(dbPath)
|
|
this.db.exec('PRAGMA busy_timeout=5000')
|
|
this.db.exec('PRAGMA journal_mode=WAL')
|
|
this.db.exec('PRAGMA synchronous=NORMAL')
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)
|
|
`)
|
|
this.getStmt = this.db.prepare('SELECT value FROM meta WHERE key = ?')
|
|
this.setStmt = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)')
|
|
this.hasStmt = this.db.prepare('SELECT 1 FROM meta WHERE key = ?')
|
|
}
|
|
|
|
get (key: string): unknown | undefined {
|
|
const row = this.getStmt.get(key) as { value: string } | undefined
|
|
if (!row) return undefined
|
|
return JSON.parse(row.value)
|
|
}
|
|
|
|
set (key: string, meta: unknown): void {
|
|
this.setStmt.run(key, JSON.stringify(meta))
|
|
}
|
|
|
|
has (key: string): boolean {
|
|
return this.hasStmt.get(key) !== undefined
|
|
}
|
|
|
|
/**
|
|
* Import all .jsonl metadata files from the cache directory into SQLite.
|
|
* Each .jsonl file has: line 1 = headers (etag/modified), line 2 = metadata JSON.
|
|
* We store the parsed metadata (with etag attached) under the cache key.
|
|
*/
|
|
importFromCacheDir (cacheDir: string): number {
|
|
const metaDirs = ['v11/metadata', 'v11/metadata-full', 'v11/metadata-full-filtered']
|
|
let imported = 0
|
|
|
|
this.db.exec('BEGIN')
|
|
try {
|
|
for (const metaDir of metaDirs) {
|
|
const baseDir = path.join(cacheDir, metaDir)
|
|
let registries: string[]
|
|
try {
|
|
registries = readdirSync(baseDir)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
for (const registry of registries) {
|
|
const regDir = path.join(baseDir, registry)
|
|
let files: string[]
|
|
try {
|
|
files = readdirSync(regDir)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
const isFullMeta = metaDir.includes('full')
|
|
|
|
for (const file of files) {
|
|
if (!file.endsWith('.jsonl')) continue
|
|
const pkgName = decodeURIComponent(file.replace('.jsonl', ''))
|
|
const cacheKey = isFullMeta ? `${pkgName}:full` : pkgName
|
|
|
|
if (this.has(cacheKey)) continue
|
|
|
|
try {
|
|
const content = readFileSync(path.join(regDir, file), 'utf-8')
|
|
const newlineIdx = content.indexOf('\n')
|
|
if (newlineIdx === -1) continue
|
|
|
|
const headerLine = content.substring(0, newlineIdx)
|
|
const metaLine = content.substring(newlineIdx + 1)
|
|
|
|
const headers = JSON.parse(headerLine)
|
|
const meta = JSON.parse(metaLine)
|
|
meta.etag = headers.etag
|
|
meta.modified = headers.modified
|
|
|
|
this.setStmt.run(cacheKey, JSON.stringify(meta))
|
|
imported++
|
|
} catch {
|
|
// Skip corrupt files
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.db.exec('COMMIT')
|
|
} catch (err) {
|
|
this.db.exec('ROLLBACK')
|
|
throw err
|
|
}
|
|
|
|
return imported
|
|
}
|
|
|
|
close (): void {
|
|
this.db.close()
|
|
}
|
|
}
|