Files
pnpm/pnpr/docs/shared-side-effects-cache.md
Zoltan Kochan 7fb2bfab4a docs(pnpr): correct configuration keys, defaults, and commands (#17245)
Part of https://github.com/pnpm/tasks/issues/191.

Wrong claims:
- cli: drop the nonexistent `add-user --password` flag and document the
  terminal/stdin password read; `oci-gc --min-age` and `cache-gc --max-age`
  are the flags (the `-secs` forms are aliases); `--from` defaults to the
  `storage` key, not an internal field; `info` and `http` log the same
  access records; only global and server options have PNPR_* variables.
- configuration: `packumentTtl` defaults to 5m; the minimal config now
  sets `auth.htpasswd.file`, and the auth section says accounts live in
  memory without it or a backend; grouped hosted registries default their
  org to `<ecosystem>~<name>`; the handshake is served for any surface;
  the pipeline surface also satisfies "something must be served"; add
  `package.update_maintainers` to the audit list.
- tls-and-reverse-proxy: remove the invalid `publicUrl:` YAML key and the
  nonexistent `rate_limiting` setting.
- backup-and-restore: `pnpr verify` hashes by default (`--check-hashes`
  does not exist, `--skip-hashes` skips only the hash
  comparison); `migrate-storage
  --verify` compares bytes.
- webhooks: audit events do not cover publishes or token creation; the
  example uses a real action and the `actor` field.
- cli: the `RUST_LOG` example keeps `pnpr::audit=info`.
- install-acceleration: the `--public-url` host is exempt from the
  private-address refusal.
- shared-side-effects-cache: `pnpm pipeline` publishes workspace-task
  artifacts.

Missing facts: `routes.allowedPrivateNetworks`, the upstream `search` key
and canonical `maxAge`, built-in `npm.jsr.io` route, relative path
resolution, `log` defaults and `type`, `${VAR:-default}` substitution and
the full secret-field list, `oci` defaults, `artifacts.quota` bounds,
`migrate-storage` destination rules, `--osv false`, `--packument-ttl`
default, S3 `forcePathStyle`/`allowHttp` defaults, grouped org layout,
OIDC key table, container image section, `backend:` ignoring the htpasswd
and token files.

Duplicates and links: health probe bodies, reload procedure, and the
resolver allowlist now link to their one home; broken `#secret` anchor
fixed; links into the pnpm docs use absolute pnpm.io URLs.

Stale history: drop "pnpr v1" and the per-paragraph "Since alpha.N" /
"Since pnpm 12.x" prefixes, and the contributor-only extensibility text.

Also corrects the `as_filter_directive` doc comment, which claimed `http`
quiets the rest of the crate.
2026-10-23 00:11:00 +02:00

284 lines
13 KiB
Markdown

---
id: shared-side-effects-cache
title: Shared side-effects cache
---
Added in: pnpr v0.1.0-alpha.8, pnpm v11.25.0 and v12.0.0
:::caution Proof of concept
This is a proof of concept for
[pnpm/rfcs#20](https://github.com/pnpm/rfcs/pull/20). It is off on both sides
unless you turn it on, and its protocol may change without notice. It supports
Linux/glibc, macOS, and Windows on x64 and arm64. Lockfile pinning,
publisher-owned artifacts, and key lifecycle policy are deliberately left out.
:::
When a dependency has a build script, every machine that installs it pays for
that build. pnpm's local [`sideEffectsCache`](https://pnpm.io/settings/build#sideeffectscache)
makes the second install on the *same* machine cheap; this feature extends the
same idea across machines, by having one trusted builder publish the build
output and everyone else restore it.
The output is not trusted because it came from the server. It is trusted because
it is **signed**, with a P-256 key the consumer configured independently of the
server, and because the signed payload names exactly which package, which source
tarball, and which platform it belongs to.
## How an install uses it
1. pnpm plans which packages are eligible to build, before contacting pnpr.
2. It sends one batched `POST /-/pnpr/v0/artifacts/resolve` for the candidate
input keys. The server returns at most eight signed variants per key.
3. For each candidate, pnpm verifies the ECDSA P-256 signature against a
*locally configured* public key, then checks the signed package name and
version, source tarball integrity, owner, input key, manifest, and platform
compatibility, and picks the most preferred compatible variant.
4. It downloads that variant's blobs with `POST /-/pnpr/v0/artifacts/blob`, one
request per unique SHA-512, recomputing the digest before accepting the
bytes, and hydrates them into the store.
5. The restored side-effects map and its signed origin are persisted in the
shared store, then selected before lifecycle scripts run, so the package's
scripts are not executed.
On a later install, pnpm does not trust the persisted mapping merely because it
is local. It verifies the envelope again against the current public key and
checks its channel, owner, package and source identity, builder profile,
platform, manifest, policy, and stored files. An invalid remote envelope is
removed from consideration and quarantined for that pnpr server. The quarantine
is persisted in the store, so the same bad variant is not retried on every
install; another server remains an independent channel.
Any failure along that path (an unreachable server, an unverifiable signature,
an incompatible platform, a digest mismatch) falls back to the ordinary local
build. The feature can make an install faster; it can never make it fail.
## Enabling it on the server
The artifact surface is off by default and is independent of the resolver:
```yaml title="config.yaml"
artifacts:
enabled: true
```
With `artifacts.enabled: false` (the default) the three routes are not mounted
at all, and the handshake at `GET /-/pnpr` does not advertise them. An artifact-only tier may set `resolver.enabled: false`,
declare no registries, and enable only `artifacts`.
By default artifacts live at `<cache>/shared-artifacts/v0`. When the pnpr config
has an [`s3:` block](storage.md), they live under the reserved
`.pnpr-artifacts/v0/` namespace in that bucket instead. The S3 layout and
conditional quota updates let several stateless pnpr replicas share one
artifact tier.
Artifacts are stored per owner, an organization the server declares under
[`artifacts.orgs`](configuration.md#registry-resolver-and-artifact-surfaces).
Its `access` list decides who may restore its artifacts and its `publish` list
who may publish them:
```yaml title="config.yaml"
artifacts:
enabled: true
orgs:
acme:
access: [ci-builder, alice, bob]
publish: ci-builder
```
Publisher-owned artifacts are rejected until publisher discovery is defined.
pnpr enforces its own storage bounds: at most eight variants per input key, and
the [`artifacts.quota`](configuration.md#registry-resolver-and-artifact-surfaces)
per owner and across the server's artifact cache. Local storage
serializes updates with an advisory lock; S3 replicas coordinate the quota
counter with conditional object writes. A lookup's scanned envelope bytes plus
its serialized response share one 16 MiB budget.
## Configuring a consumer
The repository declares eligibility, and nothing else:
```yaml title="pnpm-workspace.yaml"
pnprServer: http://127.0.0.1:7677
allowBuilds:
native-addon: true
sideEffectsCache:
remote:
org: acme
packages:
- native-addon
```
`packages` is an independent allowlist: a package is only a candidate when it is
listed there *and* has `requiresBuild: true`, *and* passes
[`allowBuilds`](https://pnpm.io/settings/build#allowbuilds), *and* has a verified source
integrity. `--ignore-scripts` returns without making a request at all.
The trust material (`trustedKeys`, `privateKey`, and everything else that
describes the act of signing) is refused in `pnpm-workspace.yaml` with
`ERR_PNPM_WORKSPACE_REMOTE_SIDE_EFFECTS_TRUST`. The repository being installed is
not a trust root: if it could name the signing key, a cloned repository could
turn the machine's key into a signing oracle. Those fields come from the [global
configuration file](https://pnpm.io/cli/config) instead, which travels with the machine:
```yaml title="~/.config/pnpm/config.yaml"
sideEffectsCache:
remote:
trustedKeys:
acme-2026: '<base64 P-256 SubjectPublicKeyInfo DER public key>'
```
The repository and the machine each declare one half of the same section, and
they compose: neither file drops what the other set. `remoteSideEffectsCache` is
the older spelling of `sideEffectsCache.remote`, and `organization` of `org`;
both still work, so a machine configured before the rename keeps its trust
material.
Every field of the section is also settable from the environment, which wins
over both files. This is the shape a CI runner wants for material it must not commit:
| Environment variable | Setting |
| --- | --- |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_TRUSTED_KEYS` | `trustedKeys` (JSON object) |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_PRIVATE_KEY` | `privateKey` |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_PUBLISH` | `publish` |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_KEY_ID` | `keyId` |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_BUILDER_ID` | `builderId` |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_IMAGE_DIGEST` | `imageDigest` |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_ARCHITECTURE_BASELINE` | `architectureBaseline` |
| `PNPM_SIDE_EFFECTS_CACHE_REMOTE_BUILD_ENV` | `buildEnv` (JSON object) |
The `PNPM_REMOTE_SIDE_EFFECTS_CACHE_*` names still work, and the ones above win
when both are set.
`org` and the trust material can also be set under the
[`remoteCache`](https://pnpm.io/settings/build#remotecache) setting, which the task cache of
`pnpm pipeline` shares. A field set there wins over the same field under
`sideEffectsCache.remote`. `remoteCache.url` stores the artifacts on a server
that speaks the Turborepo Remote Cache API instead of on pnpr.
The repository and the machine each contribute the half they own: a workspace
naming `org` and `packages` keeps whatever trust material the global
file or the environment supplied.
## Publishing from a builder
Publication is off unless a build explicitly turns it on, so only a trusted
builder uploads the diff its own build produced:
```sh
export PNPM_SIDE_EFFECTS_CACHE_REMOTE_PUBLISH=true
export PNPM_SIDE_EFFECTS_CACHE_REMOTE_KEY_ID=acme-2026
export PNPM_SIDE_EFFECTS_CACHE_REMOTE_PRIVATE_KEY='<base64 P-256 PKCS#8 DER private key>'
export PNPM_SIDE_EFFECTS_CACHE_REMOTE_BUILDER_ID='ci/main/42'
```
`pnpm install` then runs the lifecycle scripts as usual, captures the actual
post-build diff, and signs it. It uploads each blob the server lacks with
`PUT /-/pnpr/v0/artifacts/blob`, then stores the signed envelope with
`PUT /-/pnpr/v0/artifacts`. `imageDigest`, `architectureBaseline` and `buildEnv`
are optional provenance recorded in the signed payload. Never commit the private
key.
A published artifact is immutable, the way a published `name@version` is. One
input key and one set of compatibility constraints admit one artifact, so
publishing a different build over an existing one answers `409 Conflict` while
republishing the identical one succeeds unchanged. A consumer that resolved an
artifact once is therefore never handed different bytes for it later, and no
publishing credential can replace one. Releasing a claimed slot is an operator
action against the server's storage.
Immutability covers every consumer a variant can reach, not only an exactly
equal compatibility tag. pnpr rejects a new variant when its compatibility set
overlaps an existing variant for the same input key. A later `universal` build,
a broader build, or a higher-floor build therefore cannot take precedence for a
machine already served by an earlier artifact. Disjoint platform builds still
coexist: different operating systems, architectures, or Node.js majors have no
consumer in common.
Publication does not switch restoring off: a builder still looks the artifact up
first, and a hit skips the build the same way it does anywhere else, which
leaves nothing new to sign, since only an actual local build produces a diff to
publish. So a builder republishes exactly when it had to build, and to force one,
give it something the cache cannot answer (a package or platform with no stored
variant), rather than expecting `publish` to bypass the lookup.
Generate a key pair with Node.js:
<!-- cspell:disable -->
```sh
node -e "const {generateKeyPairSync}=require('node:crypto');const {privateKey,publicKey}=generateKeyPairSync('ec',{namedCurve:'prime256v1'});console.log('private='+privateKey.export({format:'der',type:'pkcs8'}).toString('base64'));console.log('public='+publicKey.export({format:'der',type:'spki'}).toString('base64'))"
```
<!-- cspell:enable -->
Keep the private key in the builder's environment, and hand the public key to
every machine that installs, under the same key id.
## Platform compatibility
An artifact says which platforms it is valid for, and the proof of concept
defines one narrow vocabulary rather than interpreting claims it does not
understand. `universal` is the positive claim for platform-independent output.
The tagged forms are:
```text
pnpm:v1:linux-<architecture>-node<major>-glibc<major>.<minor>
pnpm:v1:darwin-<architecture>-node<major>-macos<major>.<minor>
pnpm:v1:win32-<architecture>-node<major>-windows<major>.<minor>.<build>
```
`architecture` is `x64` or `arm64`, and every numeric component is a canonical
unsigned decimal. A consumer generates the tags for its own glibc version down
to minor zero, most recent floor first (glibc 2.3 advertises `glibc2.3`,
`glibc2.2`, `glibc2.1`, `glibc2.0`), and matching is exact against that ordered
set, so an artifact built against a 2.1 floor serves a 2.3 consumer.
A macOS consumer advertises its product-version major and minor, and a Windows
consumer advertises its NT kernel major, minor, and build. For both, the
operating system, architecture, and Node.js major must match exactly, while the
consumer's OS version must be at least the artifact's declared floor. The
greatest compatible floor wins. A tagged match beats `universal`, and equal-rank
variants are ordered by ascending signed-envelope digest.
An unknown schema, platform, or dimension, and any malformed tag, is a miss. No
other platform or libc family is treated as compatible by guessing.
## Signed envelopes
The base64 `payload` of a signed envelope is the exact UTF-8 JSON covered by the
`ecdsa-p256-sha256` signature, and verification always uses those unchanged
bytes. Signing opaque bytes avoids having to agree on JSON canonicalization
between clients. Signatures are canonical ASN.1
DER; verification keys are P-256 `SubjectPublicKeyInfo` DER; `keyId` is an
opaque case-sensitive UTF-8 string of 1 to 256 bytes with no control characters.
The envelope's own digest is SHA-256 over these fields, in this order, regardless
of how the outer JSON object was ordered:
```text
pnpm-shared-artifact-envelope-v1\0
algorithm\0
keyId\0
decoded payload\0
decoded DER signature
```
Every candidate and signed payload carries a
discriminated subject. Dependency side effects use
`{ kind: 'dependency-side-effects', package, sourceIntegrity }` and an input key
beginning with `dependency-side-effects:v1:`. The protocol also defines
`{ kind: 'workspace-task', project, task }` with a `workspace-task:v1:` input
key, so workspace-task artifacts cannot be confused with dependency builds.
pnpm publishes and restores dependency side effects under the first subject,
and the remote task cache of `pnpm pipeline` signs and publishes task results
under the second.
Input keys carry no host platform identity; compatibility tags live in the
signed payload instead. The artifact kind and input-key prefix must match the
subject, and its input key, subject, and owner must match the candidate. For a
dependency, that binds the signed package name and version and source tarball
integrity to the package being installed. Organization eligibility is supplied
independently by the caller and checked before lookup.