@vzn/vx-cache-server
Website: https://vznjs.github.io/vx/ · source: https://github.com/vznjs/vx
A cache server for vx that you host yourself, for CI jobs. It stores
task artifacts in one bucket and nothing else (no database), and speaks
the vx-cache protocol (/v1). Three ways to run it
answer alike:
- Bun, with any S3-compatible bucket; any number of nodes share one.
- Docker, the same server as an image (
Dockerfile). - Cloudflare Workers, with R2.
vxCache()
(@vzn/vx-cache)
points vx at it.
CI tokens
Section titled “CI tokens”Every request carries a CI job’s own OIDC token: there are no stored
tokens (vxCache() asks GitHub for it; on GitLab name an id_tokens
entry VX_CACHE_TOKEN). The token’s repository is what it reads and
writes, and the token decides where the job writes: its own branch’s
scope, never another’s.
- A ref a rule names (
refs/heads/main) is protected: only a job matching the rule writes it. Any other job on it only reads. - Any other job of the same repository writes its branch’s own scope: a pull request’s runs reuse each other’s work, never main’s runs.
- A job of another repository is refused.
TRUST is a JSON list of rules. Each names the issuer (iss), the
repository’s id (repository_id on GitHub, project_id on GitLab), the
refs it protects (ref, ref_path on GitLab) and any other claim the
job writing them holds. A value is a string (* matches anything) or a
list of them. With TRUST empty, every request is refused. For example:
[ { "iss": "https://token.actions.githubusercontent.com", "repository_id": "123456789", "event_name": ["push", "merge_group"], "ref": ["refs/heads/main", "refs/heads/gh-readonly-queue/main/*"], "ref_protected": "true" }, { "iss": "https://gitlab.com", "project_id": "4567", "ref_path": "refs/heads/main", "ref_protected": "true", "pipeline_source": ["push", "schedule"] }]Name event_name: pull_request_target and issue_comment runs carry
main’s ref while running a pull request’s code. A merge queue’s runs
write their queue ref before their code lands; queued code is approved code.
AUDIENCE is the aud a token must name, by default the origin the
server is reached at, which is what vxCache() asks GitHub for.
On Bun
Section titled “On Bun”TRUST='[…]' \S3_BUCKET=vx-cache S3_ENDPOINT=https://s3.example.com \S3_ACCESS_KEY_ID=… S3_SECRET_ACCESS_KEY=… \bun packages/vx-cache-server/src/bun.ts --port 8080S3 settings are those Bun’s S3 client reads (S3_REGION too). AUDIENCE
is read from the environment too. A node keeps no state of its own, so
any number of them serve one bucket behind a balancer.
With Docker
Section titled “With Docker”From the repo root:
docker build -f packages/vx-cache-server/Dockerfile -t vx-cache-server .docker run -p 8080:8080 -e TRUST='[…]' -e S3_BUCKET=… -e S3_ENDPOINT=… \ -e S3_ACCESS_KEY_ID=… -e S3_SECRET_ACCESS_KEY=… vx-cache-serverWith vx’s other servers: compose.yaml
builds and runs all three (docker compose up -d).
Limits
Section titled “Limits”MAX_CHECK_KEYS (default 500), MAX_BATCH_ARTIFACTS (default 100) and
MAX_UPLOAD_BYTES (default 100 MB, one artifact) set
the limits. Every check names them to clients (x-vx-max-*), and
GET /v1/capabilities too.
On Cloudflare Workers
Section titled “On Cloudflare Workers”From this directory, with TRUST filled in wrangler.jsonc:
bun installbunx wrangler loginbunx wrangler deployWrangler makes the R2 bucket wrangler.jsonc names on the first deploy.
It fits Workers’ free plan: a request makes at most 1000 calls to R2, so
a check reads up to 990 keys and a batch download carries up to 495
artifacts (a higher setting is lowered, and capabilities say so);
a request body is at most 100 MB. R2’s free plan holds 10 GB.
Expiry
Section titled “Expiry”The bucket’s own lifecycle rule expires artifacts, by age since they
were written; the server runs no sweep. An artifact’s object name is
<issuer>/<id>/<scope>/<hash>, the first three each hashed (a client
sends a scope as its ref’s hash, never the ref), so one rule
for the whole bucket is the one there is, for example 7 days:
bunx wrangler r2 bucket lifecycle add vx-cache all '' --expire-days 7On S3 the same rule goes in the bucket’s lifecycle configuration. A main run that misses an expired artifact runs the task and stores it again.
The same server as a fetch handler, to mount in a server of your own,
over a bucket of your own (src/index.ts):
createCacheServer(options: ServerOptions)returns(req: Request) => Promise<Response>.ServerOptionsis{ blobs, authorize, limits? }:blobsis aBlobsbucket ({ put(name, body, size), get(name), size(name) },getanswering aBlob,{ body, size }),authorizeanAuthorize, which maps a request to itsAccess({ issuer, id, write? }: the token’s issuer and repository id, which it reads, and the scope it may write) or throws, andlimitsany ofLimits({ maxCheckKeys, maxBatchArtifacts, maxUploadBytes }) overDEFAULT_LIMITS.blobName(issuer, id, scope, hash)is an artifact’s object name,<issuer>/<id>/<scope>/<hash>, issuer and id each the first 128 bits of their SHA-256 in hex andscopethe ref’s hash as a client sends it (PROTOCOL.md § Keys), so no segment is a plain value.TRUSTand the CI token check are@vzn/vx-devtools’s, the check every vx server shares.SCOPE_HEADER(x-vx-scope) andLIMIT_HEADERSare the header names (PROTOCOL.md).