mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 10:30:58 -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
```
263 lines
8.3 KiB
TypeScript
263 lines
8.3 KiB
TypeScript
import { describe, expect, it } from '@jest/globals'
|
|
import type { LockfileObject } from '@pnpm/lockfile.types'
|
|
import type { PackageFilesIndex } from '@pnpm/store.cafs'
|
|
import { packForStorage } from '@pnpm/store.index'
|
|
import { computeDiff, type IntegrityEntry } from 'pnpm-agent'
|
|
|
|
function createIntegrityIndex (entries: Record<string, PackageFilesIndex>): Map<string, IntegrityEntry> {
|
|
return new Map(Object.entries(entries).map(([k, v]) => [k, {
|
|
decoded: v,
|
|
rawBuffer: packForStorage(v) as Uint8Array,
|
|
}]))
|
|
}
|
|
|
|
describe('computeDiff', () => {
|
|
const storeDir = '/tmp/test-store'
|
|
|
|
it('identifies all files as missing when client store is empty', () => {
|
|
const lockfile: LockfileObject = {
|
|
lockfileVersion: '9.0',
|
|
importers: {},
|
|
packages: {
|
|
'/is-positive/1.0.0': {
|
|
resolution: { integrity: 'sha512-pkg1' },
|
|
},
|
|
},
|
|
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
const integrityIndex = createIntegrityIndex({
|
|
'sha512-pkg1': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'aaa111', size: 100, mode: 0o644, checkedAt: 0 }],
|
|
['package.json', { digest: 'bbb222', size: 50, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
})
|
|
|
|
const { metadata, missingFiles } = computeDiff(
|
|
lockfile,
|
|
[], // empty store
|
|
integrityIndex,
|
|
storeDir
|
|
)
|
|
|
|
expect(metadata.stats.totalPackages).toBe(1)
|
|
expect(metadata.stats.alreadyInStore).toBe(0)
|
|
expect(metadata.stats.packagesToFetch).toBe(1)
|
|
expect(metadata.stats.filesToDownload).toBe(2)
|
|
expect(metadata.stats.filesAlreadyInCafs).toBe(0)
|
|
expect(missingFiles).toHaveLength(2)
|
|
expect(missingFiles.map(f => f.digest).sort()).toEqual(['aaa111', 'bbb222'])
|
|
})
|
|
|
|
it('skips packages the client already has', () => {
|
|
const lockfile: LockfileObject = {
|
|
lockfileVersion: '9.0',
|
|
importers: {},
|
|
packages: {
|
|
'/is-positive/1.0.0': {
|
|
resolution: { integrity: 'sha512-pkg1' },
|
|
},
|
|
'/is-negative/1.0.0': {
|
|
resolution: { integrity: 'sha512-pkg2' },
|
|
},
|
|
},
|
|
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
const integrityIndex = createIntegrityIndex({
|
|
'sha512-pkg1': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'aaa111', size: 100, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
'sha512-pkg2': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'ccc333', size: 200, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
})
|
|
|
|
const { metadata, missingFiles } = computeDiff(
|
|
lockfile,
|
|
['sha512-pkg1'], // client has pkg1
|
|
integrityIndex,
|
|
storeDir
|
|
)
|
|
|
|
expect(metadata.stats.totalPackages).toBe(2)
|
|
expect(metadata.stats.alreadyInStore).toBe(1)
|
|
expect(metadata.stats.packagesToFetch).toBe(1)
|
|
expect(metadata.stats.filesToDownload).toBe(1)
|
|
expect(missingFiles).toHaveLength(1)
|
|
expect(missingFiles[0].digest).toBe('ccc333')
|
|
})
|
|
|
|
it('deduplicates files shared across packages', () => {
|
|
const sharedDigest = 'shared_license_hash'
|
|
|
|
const lockfile: LockfileObject = {
|
|
lockfileVersion: '9.0',
|
|
importers: {},
|
|
packages: {
|
|
'/pkg-a/1.0.0': {
|
|
resolution: { integrity: 'sha512-pkgA' },
|
|
},
|
|
'/pkg-b/1.0.0': {
|
|
resolution: { integrity: 'sha512-pkgB' },
|
|
},
|
|
},
|
|
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
const integrityIndex = createIntegrityIndex({
|
|
'sha512-pkgA': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'unique_a', size: 100, mode: 0o644, checkedAt: 0 }],
|
|
['LICENSE', { digest: sharedDigest, size: 1089, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
'sha512-pkgB': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'unique_b', size: 200, mode: 0o644, checkedAt: 0 }],
|
|
['LICENSE', { digest: sharedDigest, size: 1089, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
})
|
|
|
|
const { metadata, missingFiles } = computeDiff(
|
|
lockfile,
|
|
[],
|
|
integrityIndex,
|
|
storeDir
|
|
)
|
|
|
|
// 4 total file references, but sharedDigest only sent once
|
|
expect(metadata.stats.filesInNewPackages).toBe(4)
|
|
expect(metadata.stats.filesToDownload).toBe(3) // unique_a + unique_b + shared (once)
|
|
expect(metadata.stats.filesAlreadyInCafs).toBe(1) // shared deduped
|
|
expect(missingFiles).toHaveLength(3)
|
|
|
|
const digestsSent = missingFiles.map(f => f.digest)
|
|
expect(digestsSent).toContain(sharedDigest)
|
|
expect(digestsSent.filter(d => d === sharedDigest)).toHaveLength(1)
|
|
})
|
|
|
|
it('detects file-level dedup across store packages and new packages', () => {
|
|
// Client has pkg-old which shares a file digest with pkg-new
|
|
const lockfile: LockfileObject = {
|
|
lockfileVersion: '9.0',
|
|
importers: {},
|
|
packages: {
|
|
'/pkg-new/2.0.0': {
|
|
resolution: { integrity: 'sha512-new' },
|
|
},
|
|
},
|
|
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
const integrityIndex = createIntegrityIndex({
|
|
'sha512-old': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'old_unique', size: 100, mode: 0o644, checkedAt: 0 }],
|
|
['utils.js', { digest: 'shared_util', size: 500, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
'sha512-new': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'new_unique', size: 150, mode: 0o644, checkedAt: 0 }],
|
|
['utils.js', { digest: 'shared_util', size: 500, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
})
|
|
|
|
const { metadata, missingFiles } = computeDiff(
|
|
lockfile,
|
|
['sha512-old'], // client has pkg-old
|
|
integrityIndex,
|
|
storeDir
|
|
)
|
|
|
|
// pkg-new needs 2 files, but shared_util already in store via pkg-old
|
|
expect(metadata.stats.packagesToFetch).toBe(1)
|
|
expect(metadata.stats.filesInNewPackages).toBe(2)
|
|
expect(metadata.stats.filesToDownload).toBe(1) // only new_unique
|
|
expect(metadata.stats.filesAlreadyInCafs).toBe(1) // shared_util
|
|
expect(missingFiles).toHaveLength(1)
|
|
expect(missingFiles[0].digest).toBe('new_unique')
|
|
})
|
|
|
|
it('handles executable files correctly', () => {
|
|
const lockfile: LockfileObject = {
|
|
lockfileVersion: '9.0',
|
|
importers: {},
|
|
packages: {
|
|
'/has-bin/1.0.0': {
|
|
resolution: { integrity: 'sha512-bin' },
|
|
},
|
|
},
|
|
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
const integrityIndex = createIntegrityIndex({
|
|
'sha512-bin': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['bin/cli.js', { digest: 'exec_hash', size: 300, mode: 0o755, checkedAt: 0 }],
|
|
['lib/index.js', { digest: 'lib_hash', size: 200, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
})
|
|
|
|
const { missingFiles } = computeDiff(
|
|
lockfile,
|
|
[],
|
|
integrityIndex,
|
|
storeDir
|
|
)
|
|
|
|
const execFile = missingFiles.find(f => f.digest === 'exec_hash')!
|
|
const libFile = missingFiles.find(f => f.digest === 'lib_hash')!
|
|
|
|
expect(execFile.executable).toBe(true)
|
|
expect(libFile.executable).toBe(false)
|
|
|
|
// CAFS paths should differ for exec vs non-exec
|
|
expect(execFile.cafsPath).toContain('-exec')
|
|
expect(libFile.cafsPath).not.toContain('-exec')
|
|
})
|
|
|
|
it('includes package file indexes in metadata', () => {
|
|
const lockfile: LockfileObject = {
|
|
lockfileVersion: '9.0',
|
|
importers: {},
|
|
packages: {
|
|
'/my-pkg/1.0.0': {
|
|
resolution: { integrity: 'sha512-test' },
|
|
},
|
|
},
|
|
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
const integrityIndex = createIntegrityIndex({
|
|
'sha512-test': {
|
|
algo: 'sha512',
|
|
files: new Map([
|
|
['index.js', { digest: 'hash1', size: 100, mode: 0o644, checkedAt: 0 }],
|
|
['README.md', { digest: 'hash2', size: 50, mode: 0o644, checkedAt: 0 }],
|
|
]),
|
|
},
|
|
})
|
|
|
|
const { packageIndexBuffers } = computeDiff(lockfile, [], integrityIndex, storeDir)
|
|
|
|
const entry = packageIndexBuffers.get('/my-pkg/1.0.0')
|
|
expect(entry).toBeTruthy()
|
|
expect(entry!.integrity).toBe('sha512-test')
|
|
expect(entry!.rawBuffer).toBeInstanceOf(Uint8Array)
|
|
expect(entry!.rawBuffer.length).toBeGreaterThan(0)
|
|
})
|
|
})
|