@vzn/vx-cache-server 0.0.0 → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Dockerfile ADDED
@@ -0,0 +1,20 @@
1
+ # vx-cache-server as a container, built from the repo root:
2
+ # docker build -f packages/vx-cache-server/Dockerfile -t vx-cache-server .
3
+ # docker run -p 8080:8080 -e ALLOW='https://token.actions.githubusercontent.com:acme/app:ci.yml' -e S3_BUCKET=… -e S3_ENDPOINT=… vx-cache-server
4
+ # One bundled file: the server and the CI token check it shares with
5
+ # vx's other servers (@vzn/vx-devtools), with jose.
6
+ FROM oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4 AS build
7
+ WORKDIR /repo
8
+ COPY packages/vx-devtools/package.json packages/vx-devtools/
9
+ # jose as @vzn/vx-devtools pins it: the image names no version of its own.
10
+ RUN cd packages/vx-devtools && bun install --production --ignore-scripts
11
+ COPY packages/vx-devtools/src packages/vx-devtools/src/
12
+ COPY packages/vx-cache-server/src packages/vx-cache-server/src/
13
+ RUN mkdir -p node_modules/@vzn && ln -s ../../packages/vx-devtools node_modules/@vzn/vx-devtools \
14
+ && bun build packages/vx-cache-server/src/bun.ts --target=bun --outfile=/out/vx-cache-server.js
15
+
16
+ FROM oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4
17
+ COPY --from=build /out/vx-cache-server.js /app/
18
+ USER bun
19
+ EXPOSE 8080
20
+ ENTRYPOINT ["bun", "--no-env-file", "--no-install", "/app/vx-cache-server.js"]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VZN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROTOCOL.md ADDED
@@ -0,0 +1,151 @@
1
+ # vx-cache protocol, v1
2
+
3
+ An HTTP API for task artifacts. `@vzn/vx-cache-server` speaks it on Bun
4
+ and on Cloudflare Workers; any server that does can take its place.
5
+ Every route sits under `/v1`: a later version gets its own prefix, and a
6
+ server may serve both.
7
+
8
+ Turbo's routes (`/v8/artifacts`), under `/v1`:
9
+
10
+ | Route | What it does |
11
+ | --------------------------- | ---------------------------------------- |
12
+ | `GET /v1/artifacts/<hash>` | one artifact's bytes |
13
+ | `HEAD /v1/artifacts/<hash>` | whether it is held, and its size |
14
+ | `PUT /v1/artifacts/<hash>` | one artifact's bytes |
15
+ | `POST /v1/artifacts` | which of many hashes are held, and sizes |
16
+ | `GET /v1/capabilities` | the server's limits |
17
+
18
+ Where it differs from Turbo: a read names its scopes (§ Keys), since an
19
+ artifact lives in its branch's scope; every request names its issuer
20
+ and repository by their hashes (§ Repository), so `teamId` and `slug`
21
+ are refused; there is no events route.
22
+
23
+ ## Opaque strings
24
+
25
+ The server never reads a hash or an artifact's bytes. It only stores
26
+ them and compares hashes for equality, so a client may send them
27
+ encrypted (a keyed hash for names, a cipher for bytes). No ref goes on
28
+ the wire plain: a scope is its ref's hash (§ Keys), and the server
29
+ hashes the token's ref the same way to find where a write goes.
30
+
31
+ ## Repository
32
+
33
+ Every request names its repository: `vx-issuer`, the hash (§ Keys) of
34
+ the CI issuer's URL as its tokens name it, no trailing slash
35
+ (`https://token.actions.githubusercontent.com`), and `vx-repository`,
36
+ the hash of its `org/name` there, lowercased. One missing or not a hash
37
+ is `400`. A token of another repository than the one named is `403`.
38
+
39
+ A server whose `READ_ACCESS` is `public` takes a request with no token
40
+ too: it reads a repository its rules name (another is `403`) and writes
41
+ nothing (`403`, `this request reads only`). Any other server answers a
42
+ request with no token `401`.
43
+
44
+ ## Tokens
45
+
46
+ A request's `Authorization: Bearer <token>` is a CI job's OIDC token
47
+ (GitHub Actions, GitLab CI): there is no other. Its signature,
48
+ `RS256`, `exp` and `aud` (the server's own) are checked, and the token
49
+ alone decides what it reads and where it writes, by the server's
50
+ `ALLOW` (`issuer:owner/repo:workflow` entries):
51
+
52
+ - Its issuer and repository name (`repository` on GitHub, `project_path`
53
+ on GitLab, `org/name`, any case) are the repository it reads and writes; one `ALLOW`
54
+ does not name is `403`, and so is a job of a workflow it does not name
55
+ (`job_workflow_ref`, GitLab's `ci_config_ref_uri`). A repository's artifacts are never another's.
56
+ - It writes its own ref's scope: on GitHub a `pull_request` run its merge
57
+ ref (`refs/pull/<n>/merge`), never its `head_ref`, which a fork names
58
+ and no claim tells from the repository's own; a merge queue's (`merge_group`) run
59
+ (`refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`, that exact form)
60
+ its base branch (`refs/heads/<base>`), which its code is about to
61
+ become, else its `ref`; on GitLab its `ref_path`.
62
+ - A `pull_request_target`, `issue_comment` or `workflow_run` run writes
63
+ nothing (a write is `403`): its `ref` is the base while it may run a
64
+ pull request's code. Nor does a GitLab `merge_request_event` pipeline unless its
65
+ `source_project_path` (GitLab 19.5+) names its own project: a fork's
66
+ runs in the parent project with the fork's branch as its `ref_path`.
67
+
68
+ A bad token is `401`. A server with `ALLOW` empty does not start; a
69
+ Worker, which cannot refuse to, refuses every request: `401`,
70
+ `ALLOW is empty: set it to the CI workflows this server serves`. A server that
71
+ cannot fetch the issuer's keys, or reads a broken key set (none, or two
72
+ keys for one `kid`), answers `502`: the token is not refused,
73
+ so a client keeps the remote for its next request.
74
+
75
+ ## Keys
76
+
77
+ An artifact lives in one scope, and a key names both: `{ scope, hash }`.
78
+ `scope` is a ref's hash: the first 128 bits of the SHA-256 of its UTF-8
79
+ (`refs/heads/main`), 32 lowercase hex digits; a plain ref is `400`.
80
+ `GET` and `HEAD` name one scope in `vx-scope`; a check names
81
+ its scopes in its body.
82
+ `hash` is 1 to 128 hex digits. A read is exact: the server answers the scope named and no
83
+ other, so a client that wants its branch's work and then main's asks
84
+ for both. Every read names a scope.
85
+
86
+ An artifact is immutable. A server may expire one at any time: a
87
+ download after a check that held it may miss, which a client reads as a
88
+ miss.
89
+
90
+ `GET /v1/artifacts/<hash>` (as Turbo's `GET /v8/artifacts/<hash>`) with
91
+ `vx-scope: <scope>`: `200` with the bytes (`application/octet-stream`,
92
+ `Cache-Control: private, max-age=31536000, immutable`: the bytes under a
93
+ key never change), or `404`. `HEAD` is the same with no bytes, and names
94
+ the size as `Content-Length`. A download may come chunked with no
95
+ `Content-Length` (Bun.serve sends any streamed answer so), so a body cut
96
+ short can end as cleanly as a whole one: a client checks the bytes
97
+ themselves (vx refuses an artifact whose zstd frame, tar end or recorded
98
+ key is missing) and reads one that fails as a miss.
99
+
100
+ `POST /v1/artifacts` (Turbo's batch query) with `{ "hashes": [<hash>,
101
+ …], "scopes": [<scope>, …] }`, the scopes in the order to read them, up
102
+ to `maxCheckHashes` lookups (hashes × scopes): `{ "<hash>": { "size":
103
+ <bytes>, "scope": <scope> } | null, … }`, one per hash, naming the first
104
+ scope asked that holds it, which its download names. Each lookup is
105
+ exact, and its time does not grow with what a scope holds. Every
106
+ `/v1/artifacts` answer, a refusal too, names the server's limits, which
107
+ a client chunks its checks by, sending the chunks at once:
108
+
109
+ ```
110
+ vx-max-check-hashes: 500
111
+ vx-max-upload-bytes: 100000000
112
+ ```
113
+
114
+ `PUT /v1/artifacts/<hash>` (a token that writes, as Turbo's
115
+ `PUT /v8/artifacts/<hash>`), with `Content-Length`: the body is the
116
+ artifact's bytes, stored in the token's scope. One the scope already
117
+ holds keeps its bytes, answered before they are read. `204`; past
118
+ `maxUploadBytes`, `413`. A client may `check` first to skip what is
119
+ held.
120
+
121
+ Downloads and uploads go one artifact at a time, many at once: there is
122
+ no batch of either (2026-10-25: "use the put and get"). A batch's
123
+ bytes pass through the server's code, which on Workers costs CPU by the
124
+ byte (a 40 MB one ran out of it), and its artifacts arrive one after
125
+ another; a `GET`'s and a `PUT`'s bytes pass untouched, in parallel.
126
+
127
+ ## Limits
128
+
129
+ An operator sets each; `GET /v1/capabilities` names them:
130
+
131
+ ```json
132
+ { "maxCheckHashes": 500, "maxUploadBytes": 100000000 }
133
+ ```
134
+
135
+ A client keeps to the limits the server names.
136
+
137
+ ## Errors
138
+
139
+ Every refusal is `{ code, message }`, `application/json`, its `message`
140
+ saying what to fix:
141
+
142
+ ```json
143
+ { "code": "forbidden", "message": "this request reads only" }
144
+ ```
145
+
146
+ A JSON body without `Content-Type: application/json` is `400`; an unknown
147
+ route `404`; a route's wrong method `405` with `Allow`; a bucket that
148
+ cannot be read or written `502` (an upload whose client left before
149
+ its body ended is `400`, and logs nothing), and a failure the server
150
+ did not expect `500`, each `message` what failed. Turbo's `teamId`
151
+ and `slug` query parameters are `400`: the token names the repository.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
1
  # @vzn/vx-cache-server
2
2
 
3
- Placeholder. Part of [vx](https://github.com/vznjs/vx).
3
+ Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
4
+
5
+ A cache server for vx that you host yourself, for CI jobs. It stores
6
+ task artifacts in one bucket and nothing else (no database), and speaks
7
+ the [vx-cache protocol](./PROTOCOL.md) (`/v1`). Three ways to run it
8
+ answer alike:
9
+
10
+ - **Bun**, with any S3-compatible bucket; any number of nodes share one.
11
+ - **Docker**, the same server as an image (`Dockerfile`).
12
+ - **Cloudflare Workers**, with R2.
13
+
14
+ `vxCache()`
15
+ ([`@vzn/vx-cache`](https://github.com/vznjs/vx/tree/main/packages/vx-cache))
16
+ points `vx` at it.
17
+
18
+ ## CI tokens
19
+
20
+ Every request names its issuer and repository by their hashes
21
+ (`vx-issuer`, `vx-repository`; `vxCache({ issuer, repository })`). A
22
+ CI job's carries its own OIDC token: there are no stored tokens
23
+ (`vxCache()` asks GitHub for it; on GitLab name an `id_tokens` entry
24
+ `VX_CACHE_TOKEN`). The token must be of the repository named, and it
25
+ decides where the job writes: its own branch's scope, never another's.
26
+
27
+ `ALLOW` says who may take part, the same string the DTE relay reads:
28
+ entries separated by spaces, each `issuer:owner/repo:workflow`. For
29
+ example:
30
+
31
+ ```sh
32
+ ALLOW='https://token.actions.githubusercontent.com:acme/app:ci.yml https://gitlab.com:acme/web:.gitlab-ci.yml'
33
+ ```
34
+
35
+ Only jobs of a workflow it names get in; any other job is refused. With
36
+ `ALLOW` empty, the server does not start (a Worker refuses every request). A job writes its branch's own
37
+ scope, which only runs of that branch read:
38
+
39
+ - A push writes its branch, so a run of `main` writes `main`.
40
+ - A pull request writes its merge ref, `refs/pull/<n>/merge`, never a
41
+ branch: its work never reaches `main`'s runs, nor a branch a fork names.
42
+ - A merge queue's (`merge_group`) run (`refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`)
43
+ writes its base branch, since queued code is about to land.
44
+ - A `pull_request_target`, `issue_comment` or `workflow_run` run writes
45
+ nothing: it may run a fork's code.
46
+
47
+ `AUDIENCE` is the `aud` a token must name: the origin clients reach the
48
+ server at (`https://cache.example.com`), which is what `vxCache()` asks
49
+ GitHub for. On Workers, unset, it is that origin: Cloudflare routes only
50
+ the Worker's own hostnames to it. On Bun and Docker it is required, since
51
+ there the origin is the request's `Host` header, which a client picks.
52
+ `READ_ACCESS=public` lets a request with no token read a repository
53
+ `ALLOW` names (a developer's laptop): it writes nothing. Unset, a
54
+ request with no token is `401`.
55
+
56
+ ## On Bun
57
+
58
+ ```sh
59
+ ALLOW='https://token.actions.githubusercontent.com:acme/app:ci.yml' \
60
+ AUDIENCE=https://cache.example.com \
61
+ S3_BUCKET=vx-cache S3_ENDPOINT=https://s3.example.com \
62
+ S3_ACCESS_KEY_ID=… S3_SECRET_ACCESS_KEY=… \
63
+ bun packages/vx-cache-server/src/bun.ts --port 8080
64
+ ```
65
+
66
+ S3 settings are those Bun's S3 client reads (`S3_REGION` too). A node keeps no state of its own, so
67
+ any number of them serve one bucket behind a balancer.
68
+
69
+ ## With Docker
70
+
71
+ From the repo root:
72
+
73
+ ```sh
74
+ docker build -f packages/vx-cache-server/Dockerfile -t vx-cache-server .
75
+ docker run -p 8080:8080 -e ALLOW='…' -e AUDIENCE=https://cache.example.com -e S3_BUCKET=… -e S3_ENDPOINT=… \
76
+ -e S3_ACCESS_KEY_ID=… -e S3_SECRET_ACCESS_KEY=… vx-cache-server
77
+ ```
78
+
79
+ With vx's other servers: [`compose.yaml`](https://github.com/vznjs/vx/blob/main/private/vx-servers/compose.yaml)
80
+ builds and runs all three (`docker compose up -d`).
81
+
82
+ ## Limits
83
+
84
+ `MAX_CHECK_HASHES` (lookups, hashes × scopes, a check makes; default
85
+ 500, 6 on Workers) and
86
+ `MAX_UPLOAD_BYTES` (default 100 MB, one artifact) set
87
+ the limits, each in decimal digits (on Workers, a JSON number too). Every `/v1/artifacts` answer, a refusal too, names them to
88
+ clients (`vx-max-*`), and
89
+ `GET /v1/capabilities` too.
90
+
91
+ ## On Cloudflare Workers
92
+
93
+ From this directory, with `ALLOW` filled in `wrangler.jsonc`:
94
+
95
+ ```sh
96
+ bun install
97
+ bunx wrangler login
98
+ bunx wrangler deploy
99
+ ```
100
+
101
+ Wrangler makes the R2 bucket `wrangler.jsonc` names on the first deploy;
102
+ the Worker reads it as the binding `BLOBS`.
103
+ By default a check makes 6 lookups: a Worker request has 6 calls
104
+ waiting at once and queues the rest, so checks of 6 sent at once are
105
+ the fastest (from a GitHub runner, 134 hashes: 168 to 216 ms, against
106
+ 1.9 s for one check of 134, 2026-10-25). Each lookup is one subrequest
107
+ (an R2 head), so a higher `MAX_CHECK_HASHES` must fit your plan's
108
+ subrequests a request, and `MAX_UPLOAD_BYTES` your plan's request body.
109
+ The server lowers neither. A download is read through the data center's cache: an
110
+ artifact never changes, so after R2 serves it once, that data center
111
+ answers it without R2 (free on every plan). Whether R2 holds an
112
+ artifact (a check, a `HEAD`, an upload's skip) is asked of R2 alone: the
113
+ data center's copy can outlive R2's, and a writer told "held" there
114
+ would skip an upload another data center then cannot read. An upload
115
+ drops the copy in the data center it reaches: R2 had let those bytes go.
116
+
117
+ ## Expiry
118
+
119
+ The bucket's own lifecycle rule expires artifacts, by age since they
120
+ were written; the server runs no sweep. An artifact's object name is
121
+ `<issuer>/<repository>/<scope>/<hash>`, the first three each hashed (a
122
+ client sends a scope as its ref's hash, never the ref), so one rule
123
+ for the whole bucket is the one there is, for example 1 day:
124
+
125
+ ```sh
126
+ bunx wrangler r2 bucket lifecycle add vx-cache all '' --expire-days 1
127
+ ```
128
+
129
+ On S3 the same rule goes in the bucket's lifecycle configuration. A
130
+ main run that misses an expired artifact runs the task and stores it
131
+ again.
132
+
133
+ ## API
134
+
135
+ The same server as a `fetch` handler, to mount in a server of your own,
136
+ over a bucket of your own (`src/index.ts`):
137
+
138
+ - `createCacheServer(options: ServerOptions)` returns
139
+ `(req: Request) => Promise<Response>`. `ServerOptions` is
140
+ `{ blobs, authorize, limits? }`: `blobs` is a `Blobs` bucket
141
+ (`{ put(name, body, size), get(name), size(name) }`, `get` answering a
142
+ `Blob`, `{ body, size }`), `authorize` an `Authorize`, which maps a
143
+ request to its `Access` (`{ issuer, repository, write? }`: the token's issuer and
144
+ repository name, which it reads, and the scope it may write) or throws, and `limits` any of `Limits`
145
+ (`{ maxCheckHashes, maxUploadBytes }`) over
146
+ `DEFAULT_LIMITS`.
147
+ - `blobName(issuer, repository, scope, hash)` is an artifact's object name,
148
+ `<issuer>/<repository>/<scope>/<hash>`, issuer and repository each the first 128 bits
149
+ of their SHA-256 in hex and `scope` the ref's hash as a client sends
150
+ it (PROTOCOL.md § Keys), so no segment is a plain value.
151
+ - `ALLOW` and the CI token check are [`@vzn/vx-devtools`](https://github.com/vznjs/vx/tree/main/packages/vx-devtools)'s,
152
+ the check every vx server shares.
153
+ - `SCOPE_HEADER` (`vx-scope`) and `LIMIT_HEADERS` are the header names
154
+ (PROTOCOL.md).
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun 1.4.0's `--compile` binaries resolve an on-disk
2
+ // package by `<pkg>/index.ts` and ignore package.json `exports` / `main`
3
+ // (see packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,8 +1,57 @@
1
1
  {
2
2
  "name": "@vzn/vx-cache-server",
3
- "version": "0.0.0",
4
- "description": "Placeholder; real releases are published by CI.",
3
+ "version": "0.0.648",
5
4
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/vznjs/vx.git" },
7
- "publishConfig": { "access": "public" }
5
+ "description": "A remote cache server for vx that you host: on Bun or Docker with an S3-compatible bucket, or on Cloudflare Workers with R2.",
6
+ "keywords": [
7
+ "vx",
8
+ "monorepo",
9
+ "remote-cache",
10
+ "cache-server"
11
+ ],
12
+ "type": "module",
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "bin": {
16
+ "vx-cache-server": "./src/bun.ts"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.ts",
21
+ "import": "./src/index.ts"
22
+ },
23
+ "./worker": {
24
+ "types": "./src/worker.ts",
25
+ "import": "./src/worker.ts"
26
+ }
27
+ },
28
+ "files": [
29
+ "index.ts",
30
+ "src",
31
+ "Dockerfile",
32
+ "PROTOCOL.md",
33
+ "wrangler.jsonc",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "bun": ">=1.4.4"
39
+ },
40
+ "//": [
41
+ "wrangler: deploys src/worker.ts by wrangler.jsonc.",
42
+ "@vzn/vx: vx.config.ts's core; `*`, not workspace:, which npm refuses when a deploy installs this directory alone.",
43
+ "@vzn/vx-devtools: checks the CI tokens, the check vx's servers share (it imports only jose); `*` for the same reason.",
44
+ "jose: the tests sign the CI tokens the server checks.",
45
+ "miniflare: tests/worker.test.ts runs src/worker.ts in workerd with R2, as Workers deploy it."
46
+ ],
47
+ "dependencies": {
48
+ "@vzn/vx-devtools": "0.0.648"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/vznjs/vx.git",
53
+ "directory": "packages/vx-cache-server"
54
+ },
55
+ "homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-cache-server#readme",
56
+ "bugs": "https://github.com/vznjs/vx/issues"
8
57
  }
package/src/bun.ts ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env -S bun --no-env-file --no-install
2
+ // `vx-cache-server` on Bun: artifacts in an S3-compatible bucket, nothing
3
+ // else, so any number of nodes share one bucket (PROTOCOL.md, README.md).
4
+
5
+ import { parseArgs } from 'node:util'
6
+ import { oidcAuth, parseAllow, Refusal, statusLine } from '@vzn/vx-devtools'
7
+ import { isCount } from './protocol.js'
8
+ import { createCacheServer, DEFAULT_LIMITS, limitsFrom } from './server.js'
9
+ import type { Blobs } from './store.js'
10
+
11
+ const USAGE = `usage: vx-cache-server [--port 8080] [--host 0.0.0.0]
12
+ ALLOW who may take part, space separated, each issuer:owner/repo:workflow
13
+ (README.md § CI tokens); only their jobs' OIDC tokens are taken
14
+ AUDIENCE the aud a CI token names: the URL clients reach this server at
15
+ READ_ACCESS public: a request with no token reads a repository ALLOW names
16
+ S3_BUCKET the bucket; S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID and
17
+ S3_SECRET_ACCESS_KEY as Bun's S3 client reads them
18
+ MAX_CHECK_HASHES lookups one check makes, hashes × scopes (default ${DEFAULT_LIMITS.maxCheckHashes})
19
+ MAX_UPLOAD_BYTES the largest upload, one artifact (default ${DEFAULT_LIMITS.maxUploadBytes})`
20
+
21
+ /**
22
+ * An S3-compatible bucket through Bun's S3 client. Reads go through a
23
+ * presigned URL: one request answers a GET's bytes and size, or its 404,
24
+ * and Bun answers `new Response(s3File)` with a redirect a client may not
25
+ * reach.
26
+ */
27
+ export function s3Blobs(client: Bun.S3Client): Blobs {
28
+ const ask = async (name: string, method: 'GET' | 'HEAD'): Promise<Response | null> => {
29
+ // Identity: Bun's fetch asks for gzip and inflates it, and a
30
+ // compressed answer's Content-Length is not the artifact's size.
31
+ const res = await fetch(client.presign(name, { method }), {
32
+ method,
33
+ headers: { 'accept-encoding': 'identity' },
34
+ })
35
+ if (res.status === 404) {
36
+ await res.body?.cancel()
37
+ return null
38
+ }
39
+ if (!res.ok) {
40
+ await res.body?.cancel()
41
+ throw new Error(`the bucket answered ${method} ${res.status}`)
42
+ }
43
+ return res
44
+ }
45
+ return {
46
+ async put(name, body, size) {
47
+ let seen = 0
48
+ const counted = body.pipeThrough(
49
+ new TransformStream<Uint8Array, Uint8Array>({
50
+ transform(chunk, controller) {
51
+ seen += chunk.byteLength
52
+ if (seen > size) throw new Refusal(400, `the body is longer than ${size} bytes`)
53
+ controller.enqueue(chunk)
54
+ },
55
+ flush() {
56
+ if (seen !== size) throw new Refusal(400, `the body is ${seen} bytes, not ${size}`)
57
+ },
58
+ }),
59
+ )
60
+ await client.write(name, new Response(counted))
61
+ },
62
+ async get(name) {
63
+ const res = await ask(name, 'GET')
64
+ if (res === null) return null
65
+ return {
66
+ body: res.body ?? new Blob([]).stream(),
67
+ size: Number(res.headers.get('content-length')),
68
+ }
69
+ },
70
+ async size(name) {
71
+ const res = await ask(name, 'HEAD')
72
+ if (res === null) return null
73
+ // Number(null) is 0: a HEAD naming no length is no size, not an empty artifact.
74
+ const length = res.headers.get('content-length')
75
+ const size = length === null || length.trim() === '' ? Number.NaN : Number(length)
76
+ if (!isCount(size)) throw new Error('the bucket answered HEAD with no Content-Length')
77
+ return size
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The handler on Bun.serve. Bun's own body limit is lifted: the handler
84
+ * refuses an upload past `MAX_UPLOAD_BYTES` by its Content-Length, as a
85
+ * `{ code, message }`, where Bun's 413 had no body and the client warned
86
+ * with no reason.
87
+ */
88
+ export function listen(
89
+ fetch: (req: Request) => Promise<Response>,
90
+ hostname: string,
91
+ port: number,
92
+ ): Bun.Server<undefined> {
93
+ return Bun.serve({ hostname, port, maxRequestBodySize: Number.MAX_SAFE_INTEGER, fetch })
94
+ }
95
+
96
+ function main(argv: string[]): void {
97
+ const { values } = parseArgs({
98
+ args: argv,
99
+ options: {
100
+ port: { type: 'string', default: '8080' },
101
+ host: { type: 'string', default: '0.0.0.0' },
102
+ help: { type: 'boolean', short: 'h', default: false },
103
+ },
104
+ })
105
+ if (values.help) {
106
+ console.log(USAGE)
107
+ return
108
+ }
109
+ const env = Bun.env
110
+ if (env.S3_BUCKET === undefined || env.S3_BUCKET === '') fail(`set S3_BUCKET\n${USAGE}`)
111
+ // Digits only: Number('') is 0, a random port for `--port "$PORT"` with
112
+ // PORT unset, and Number read 0x50 as 80 and 1e1 as 10.
113
+ const port = /^\d+$/.test(values.port) ? Number(values.port) : Number.NaN
114
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
115
+ fail(`--port: not a port: ${values.port}`)
116
+ // Bun.serve reads '' as localhost: unreachable from another host or container.
117
+ if (values.host.trim() === '') fail(`--host: needs an address, got '${values.host}'`)
118
+ let allow
119
+ let limits
120
+ try {
121
+ allow = parseAllow(env.ALLOW ?? '')
122
+ } catch (err) {
123
+ fail(`ALLOW: ${(err as Error).message}`)
124
+ }
125
+ // An empty one started a server that refused every request.
126
+ if (allow.length === 0) fail(`set ALLOW\n${USAGE}`)
127
+ try {
128
+ limits = { ...DEFAULT_LIMITS, ...limitsFrom(env) }
129
+ } catch (err) {
130
+ fail((err as Error).message)
131
+ }
132
+ const client = new Bun.S3Client({ bucket: env.S3_BUCKET })
133
+ try {
134
+ // Signs offline: refuses missing credentials now, not every request 502.
135
+ client.presign('vx-cache-server')
136
+ } catch (err) {
137
+ fail(`S3: ${(err as Error).message}`)
138
+ }
139
+ // Unset, the audience would be the origin a request names: its Host
140
+ // header, which the client picks. Workers know their own hostnames.
141
+ const audience = env.AUDIENCE?.trim() ?? ''
142
+ if (audience === '')
143
+ fail(`set AUDIENCE: the aud a CI token names, the URL clients reach this server at\n${USAGE}`)
144
+ const handle = createCacheServer({
145
+ blobs: s3Blobs(client),
146
+ authorize: oidcAuth({
147
+ allow,
148
+ readAccess: env.READ_ACCESS,
149
+ audience,
150
+ }),
151
+ limits,
152
+ })
153
+ const server = listen(handle, values.host, port)
154
+ process.stdout.write(statusLine('ok', 'vx-cache-server', `listening on ${server.url.href}`))
155
+ const stop = (): void => void server.stop().then(() => process.exit(0))
156
+ process.on('SIGINT', stop)
157
+ process.on('SIGTERM', stop)
158
+ }
159
+
160
+ function fail(message: string): never {
161
+ process.stderr.write(statusLine('err', 'vx-cache-server', message))
162
+ process.exit(2)
163
+ }
164
+
165
+ if (import.meta.main) {
166
+ try {
167
+ main(process.argv.slice(2))
168
+ } catch (err) {
169
+ // parseArgs names the flag it refused, not the ones there are.
170
+ const usage = (err as { code?: string }).code?.startsWith('ERR_PARSE_ARGS') ? `\n${USAGE}` : ''
171
+ fail(`${err instanceof Error ? err.message : String(err)}${usage}`)
172
+ }
173
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // @vzn/vx-cache-server: a remote cache server for vx, on Bun with an
2
+ // S3-compatible bucket or on Cloudflare Workers with R2.
3
+
4
+ export { LIMIT_HEADERS, SCOPE_HEADER } from './protocol.js'
5
+ export { createCacheServer, DEFAULT_LIMITS, type Limits, type ServerOptions } from './server.js'
6
+ export { type Blob, blobName, type Blobs } from './store.js'
@@ -0,0 +1,23 @@
1
+ // The wire of vx-cache v1 (PROTOCOL.md): names, limits and the parsers of
2
+ // what a request carries. Every string a client sends is opaque to the
3
+ // server, which only compares it, so a client can send it encrypted.
4
+
5
+ /** The scope (a ref's hash) a download reads: `GET /v1/artifacts/<hash>`. */
6
+ export const SCOPE_HEADER = 'vx-scope'
7
+
8
+ /**
9
+ * The limits a server sets, as headers on every `/v1/artifacts`
10
+ * answer: a client batches checks only by what it was told.
11
+ */
12
+ export const LIMIT_HEADERS = {
13
+ maxCheckHashes: 'vx-max-check-hashes',
14
+ maxUploadBytes: 'vx-max-upload-bytes',
15
+ } as const
16
+
17
+ /** An artifact's hash, as Turbo's spec has it (hex), and at most 128 long. */
18
+ const HASH = /^[a-fA-F0-9]{1,128}$/
19
+
20
+ export const isHash = (s: string): boolean => HASH.test(s)
21
+
22
+ export const isCount = (n: unknown): n is number =>
23
+ typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
package/src/server.ts ADDED
@@ -0,0 +1,294 @@
1
+ // vx-cache v1 (PROTOCOL.md) over any `Blobs` bucket: one fetch handler
2
+ // the Bun server, the Worker and the Docker image all serve. No database:
3
+ // an artifact is one object, named by its issuer, repository name, scope and
4
+ // hash, and a read names the exact scope it wants (2026-10-25).
5
+
6
+ import {
7
+ type Access,
8
+ type Authorize,
9
+ errorJson,
10
+ hashPart,
11
+ notAllowed,
12
+ Refusal,
13
+ } from '@vzn/vx-devtools'
14
+ import { isCount, isHash, LIMIT_HEADERS, SCOPE_HEADER } from './protocol.js'
15
+ import { blobName, type Blobs } from './store.js'
16
+
17
+ /** What an operator may set; each defaults, and a backend may cap them. */
18
+ export interface Limits {
19
+ /** The lookups one check makes: its hashes times its scopes. */
20
+ readonly maxCheckHashes: number
21
+ /** The largest upload, in bytes: one artifact. */
22
+ readonly maxUploadBytes: number
23
+ }
24
+
25
+ export const DEFAULT_LIMITS: Limits = {
26
+ maxCheckHashes: 500,
27
+ maxUploadBytes: 100_000_000,
28
+ }
29
+
30
+ export interface ServerOptions {
31
+ blobs: Blobs
32
+ authorize: Authorize
33
+ limits?: Partial<Limits>
34
+ }
35
+
36
+ const VARS = {
37
+ maxCheckHashes: 'MAX_CHECK_HASHES',
38
+ maxUploadBytes: 'MAX_UPLOAD_BYTES',
39
+ } as const
40
+
41
+ /**
42
+ * `MAX_CHECK_HASHES` and `MAX_UPLOAD_BYTES` from the environment, each a
43
+ * whole number above 0: a string, or a number as wrangler.jsonc may write
44
+ * it (a number was skipped, unsaid, for the default).
45
+ */
46
+ export function limitsFrom(env: object): Partial<Limits> {
47
+ const out: Partial<Record<keyof Limits, number>> = {}
48
+ for (const [field, name] of Object.entries(VARS) as [keyof Limits, string][]) {
49
+ const v = (env as Record<string, unknown>)[name]
50
+ if (v === undefined || v === '') continue
51
+ // A Worker var may be JSON: a number was skipped, the default served.
52
+ // A string is digits only, as --port is: Number read 0x10 as 16.
53
+ const n =
54
+ typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : Number.NaN
55
+ if (!(Number.isSafeInteger(n) && n > 0))
56
+ throw new Error(
57
+ `${name}: not a whole number above 0: ${typeof v === 'string' || typeof v === 'number' ? v : JSON.stringify(v)}`,
58
+ )
59
+ out[field] = n
60
+ }
61
+ return out
62
+ }
63
+
64
+ /** A held artifact's bytes never change (2026-10-25). */
65
+ const IMMUTABLE = 'private, max-age=31536000, immutable'
66
+
67
+ /** A scope on the wire: its ref's `hashPart`, so no ref is ever sent or stored plain (2026-10-25). */
68
+ const SCOPE = /^[0-9a-f]{32}$/
69
+
70
+ export function createCacheServer(opts: ServerOptions): (req: Request) => Promise<Response> {
71
+ const { blobs, authorize } = opts
72
+ const limits: Limits = { ...DEFAULT_LIMITS, ...opts.limits }
73
+ const limitHeaders = Object.fromEntries(
74
+ Object.entries(LIMIT_HEADERS).map(([k, h]) => [h, String(limits[k as keyof Limits])]),
75
+ )
76
+
77
+ /** The scope a client names: a ref's hash, read as it is. */
78
+ const scopeOf = (scope: unknown): string => {
79
+ if (typeof scope !== 'string' || !SCOPE.test(scope)) {
80
+ throw new Refusal(400, "scope: a ref's hash, 32 hex digits (PROTOCOL.md § Keys)")
81
+ }
82
+ return scope
83
+ }
84
+ const nameOf = (access: Access, scope: string, hash: string): Promise<string> =>
85
+ blobName(access.issuer, access.repository, scope, hash)
86
+ /** A read of the bucket: one it fails is its fault, `502`, never the server's `500`. */
87
+ const read = async <T>(ask: Promise<T>): Promise<T> => {
88
+ try {
89
+ return await ask
90
+ } catch (err) {
91
+ throw new Refusal(502, `the bucket could not be read: ${message(err)}`)
92
+ }
93
+ }
94
+
95
+ /** The token's own scope, the one place it writes. */
96
+ const writeScope = (access: Access): Promise<string> => {
97
+ if (access.write === undefined) throw new Refusal(403, 'this request reads only')
98
+ return hashPart(access.write)
99
+ }
100
+
101
+ /**
102
+ * `{ hashes, scopes }`: Turbo's batch query, with the scopes to read in
103
+ * the order to read them; up to `max` lookups, hashes times scopes.
104
+ */
105
+ async function queryOf(req: Request, max: number) {
106
+ const body = await readJson(req, max * 140 + 100)
107
+ const hashes = field(body, 'hashes')
108
+ const scopes = field(body, 'scopes')
109
+ if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === 'string' && isHash(h))) {
110
+ throw new Refusal(400, 'hashes: a list of 1 to 128 hex digits each')
111
+ }
112
+ if (!Array.isArray(scopes) || scopes.length === 0) {
113
+ throw new Refusal(400, "scopes: a list of refs' hashes, in the order to read them")
114
+ }
115
+ if (hashes.length * scopes.length > max) {
116
+ throw new Refusal(400, `hashes × scopes: up to ${max} lookups`)
117
+ }
118
+ return { hashes: hashes as string[], scopes: scopes.map(scopeOf) }
119
+ }
120
+
121
+ async function artifact(req: Request, hash: string): Promise<Response> {
122
+ if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
123
+ const access = await authorize(req)
124
+ const scope = scopeOf(req.headers.get(SCOPE_HEADER))
125
+ const blob = await read(blobs.get(await nameOf(access, scope, hash)))
126
+ if (blob === null) return errorJson(404, 'no artifact under this key')
127
+ return new Response(blob.body, {
128
+ headers: {
129
+ 'content-length': String(blob.size),
130
+ 'content-type': 'application/octet-stream',
131
+ // An artifact never changes under its key; private: its token chose the repository.
132
+ 'cache-control': IMMUTABLE,
133
+ },
134
+ })
135
+ }
136
+
137
+ /** Turbo's `HEAD /v8/artifacts/<hash>`: whether the scope named holds it, and its size, with no bytes. */
138
+ async function exists(req: Request, hash: string): Promise<Response> {
139
+ if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
140
+ const access = await authorize(req)
141
+ const scope = scopeOf(req.headers.get(SCOPE_HEADER))
142
+ const size = await read(blobs.size(await nameOf(access, scope, hash)))
143
+ if (size === null) return new Response(null, { status: 404 })
144
+ return new Response(null, {
145
+ headers: { 'content-length': String(size), 'cache-control': IMMUTABLE },
146
+ })
147
+ }
148
+
149
+ // Every lookup at once, each exact: a hash answers the first scope
150
+ // asked that holds it, and names it, for the download to read.
151
+ async function check(req: Request): Promise<Response> {
152
+ const access = await authorize(req)
153
+ const { hashes, scopes } = await queryOf(req, limits.maxCheckHashes)
154
+ const sizes = await Promise.all(
155
+ hashes.map((hash) =>
156
+ Promise.all(scopes.map(async (s) => read(blobs.size(await nameOf(access, s, hash))))),
157
+ ),
158
+ )
159
+ const held = Object.fromEntries(
160
+ hashes.map((hash, i) => {
161
+ const at = sizes[i]!.findIndex((size) => size !== null)
162
+ return [hash, at < 0 ? null : { size: sizes[i]![at], scope: scopes[at] }]
163
+ }),
164
+ )
165
+ return Response.json(held)
166
+ }
167
+
168
+ /**
169
+ * An upload: one artifact, its bytes the whole body (Turbo's
170
+ * `PUT /v8/artifacts/<hash>`), stored in the token's scope. They reach
171
+ * the bucket without passing through this code, so a large one costs
172
+ * no CPU here (2026-10-25: no batch upload; a batch is split by this
173
+ * code, and on Workers a 40 MB one ran out of CPU).
174
+ */
175
+ async function put(req: Request, hash: string): Promise<Response> {
176
+ if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
177
+ const access = await authorize(req)
178
+ const name = await nameOf(access, await writeScope(access), hash)
179
+ const length = contentLength(req)
180
+ if (length > limits.maxUploadBytes) {
181
+ throw new Refusal(413, `an upload is at most ${limits.maxUploadBytes} bytes`)
182
+ }
183
+ if ((await read(blobs.size(name))) !== null) {
184
+ await req.body?.cancel()
185
+ return new Response(null, { status: 204 })
186
+ }
187
+ try {
188
+ await blobs.put(name, req.body ?? new Blob([]).stream(), length)
189
+ } catch (err) {
190
+ if (err instanceof Refusal) throw err
191
+ // A client that left mid-body (a cancelled run, a dropped link) fails
192
+ // the bucket's write too: no fault of the bucket's, nor the operator's.
193
+ if (req.signal.aborted) {
194
+ throw new Refusal(400, 'the upload ended before its body did: the client left')
195
+ }
196
+ throw new Refusal(502, `the bucket refused the artifact: ${message(err)}`)
197
+ }
198
+ return new Response(null, { status: 204 })
199
+ }
200
+
201
+ return async (req) => {
202
+ const res = await answer(req)
203
+ // Every /v1/artifacts answer, a refusal too: a client holding older
204
+ // limits (a restart with new ones, a Worker's rollout) learns them from
205
+ // the answer that refused it, or it was refused every check of the run.
206
+ if (/^\/v1\/artifacts(\/|$)/.test(new URL(req.url).pathname))
207
+ for (const [h, v] of Object.entries(limitHeaders)) res.headers.set(h, v)
208
+ return res
209
+ }
210
+
211
+ async function answer(req: Request): Promise<Response> {
212
+ const url = new URL(req.url)
213
+ try {
214
+ const path = url.pathname
215
+ // Turbo's team params are not taken: a repository is its token's.
216
+ if (url.searchParams.has('teamId') || url.searchParams.has('slug')) {
217
+ throw new Refusal(400, 'teamId and slug are not taken: the token names the repository')
218
+ }
219
+ if (path === '/v1/capabilities') {
220
+ if (req.method !== 'GET') return notAllowed('GET')
221
+ await authorize(req)
222
+ return Response.json(limits)
223
+ }
224
+ if (path === '/v1/artifacts') {
225
+ return req.method === 'POST' ? await check(req) : notAllowed('POST')
226
+ }
227
+ if (path.startsWith('/v1/artifacts/') && !path.slice('/v1/artifacts/'.length).includes('/')) {
228
+ const hash = path.slice('/v1/artifacts/'.length)
229
+ if (req.method === 'GET') return await artifact(req, hash)
230
+ if (req.method === 'HEAD') return await exists(req, hash)
231
+ return req.method === 'PUT' ? await put(req, hash) : notAllowed('GET, HEAD, PUT')
232
+ }
233
+ return errorJson(
234
+ 404,
235
+ `no route ${req.method} ${path.slice(0, 200)}: name the server's base URL; it serves /v1/ (PROTOCOL.md)`,
236
+ )
237
+ } catch (err) {
238
+ if (err instanceof Refusal) {
239
+ // The bucket's or the issuer's fault is the operator's to see too.
240
+ if (err.status >= 500)
241
+ console.error(`vx-cache-server: ${req.method} ${url.pathname}: ${err.message}`)
242
+ return errorJson(err.status, err.message)
243
+ }
244
+ // Never swallowed: the client warns with what the server says.
245
+ console.error(`vx-cache-server: ${req.method} ${url.pathname}: ${String(err)}`)
246
+ return errorJson(500, message(err))
247
+ }
248
+ }
249
+ }
250
+
251
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err))
252
+
253
+ async function readJson(req: Request, max: number): Promise<unknown> {
254
+ if (!/^application\/json\b/i.test(req.headers.get('content-type') ?? '')) {
255
+ throw new Refusal(400, 'send the body as Content-Type: application/json')
256
+ }
257
+ if (Number(req.headers.get('content-length') ?? 0) > max) {
258
+ throw new Refusal(413, `the body is at most ${max} bytes`)
259
+ }
260
+ const text = await textUpTo(req, max)
261
+ try {
262
+ return JSON.parse(text)
263
+ } catch {
264
+ throw new Refusal(400, 'the body is not JSON')
265
+ }
266
+ }
267
+
268
+ function contentLength(req: Request): number {
269
+ const length = req.headers.get('content-length')
270
+ if (length === null) throw new Refusal(400, 'an upload names its Content-Length')
271
+ const size = Number(length)
272
+ if (!isCount(size)) throw new Refusal(400, 'Content-Length is not a byte count')
273
+ return size
274
+ }
275
+
276
+ const field = (body: unknown, name: string): unknown =>
277
+ typeof body === 'object' && body !== null ? (body as Record<string, unknown>)[name] : undefined
278
+
279
+ /**
280
+ * The body as text, refused past `max` bytes as they arrive: a chunked body
281
+ * names no length, and was read whole before it was measured.
282
+ */
283
+ async function textUpTo(req: Request, max: number): Promise<string> {
284
+ const decoder = new TextDecoder()
285
+ let text = ''
286
+ let seen = 0
287
+ // Leaving the loop by a throw cancels the stream.
288
+ for await (const chunk of req.body ?? []) {
289
+ seen += chunk.byteLength
290
+ if (seen > max) throw new Refusal(413, `the body is at most ${max} bytes`)
291
+ text += decoder.decode(chunk, { stream: true })
292
+ }
293
+ return text + decoder.decode()
294
+ }
package/src/store.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Where a server keeps what it is sent: one bucket (S3 or R2), no
2
+ // database (2026-10-25). An artifact is one object whose name is its
3
+ // issuer, repository name, scope and hash and whose size is the bucket's
4
+ // own, so any number of nodes share a bucket and nothing else.
5
+
6
+ import { hashPart } from '@vzn/vx-devtools'
7
+
8
+ /** A blob's bytes and its size, as the bucket holds them. */
9
+ export interface Blob {
10
+ readonly body: ReadableStream<Uint8Array>
11
+ readonly size: number
12
+ }
13
+
14
+ /** A bucket of blobs by name; a put replaces a name whole. */
15
+ export interface Blobs {
16
+ /** Stores `size` bytes; rejects when the stream ends at another length. */
17
+ put(name: string, body: ReadableStream<Uint8Array>, size: number): Promise<void>
18
+ /** The blob, or null when the bucket has none by that name. */
19
+ get(name: string): Promise<Blob | null>
20
+ /** Its size, read off its metadata, or null when there is none. */
21
+ size(name: string): Promise<number | null>
22
+ }
23
+
24
+ /**
25
+ * An artifact's object name, `<issuer>/<id>/<scope>/<hash>`
26
+ * (2026-10-25): issuer and id each hashed (`hashPart`), `scope` the ref's
27
+ * hash the client sent, so no segment is a plain value and a ref's slashes
28
+ * or any byte stay one segment. The server knows no tier.
29
+ */
30
+ export const blobName = async (
31
+ issuer: string,
32
+ id: string,
33
+ scope: string,
34
+ hash: string,
35
+ ): Promise<string> => `${await hashPart(issuer)}/${await hashPart(id)}/${scope}/${hash}`
package/src/worker.ts ADDED
@@ -0,0 +1,181 @@
1
+ // `vx-cache-server` on Cloudflare Workers: artifacts in R2, nothing else.
2
+ // `wrangler.jsonc` deploys it; ALLOW, AUDIENCE and the limits are vars in
3
+ // it (README.md § On Cloudflare Workers).
4
+
5
+ import { type Authorize, errorJson, oidcAuth, parseAllow, Refusal } from '@vzn/vx-devtools'
6
+ import { createCacheServer, DEFAULT_LIMITS, type Limits, limitsFrom } from './server.js'
7
+ import type { Blobs } from './store.js'
8
+
9
+ /** The parts of the Workers runtime this file uses, so it needs no types package. */
10
+ export interface R2Bucket {
11
+ put(name: string, body: ReadableStream<Uint8Array>): Promise<unknown>
12
+ get(name: string): Promise<{ body: ReadableStream<Uint8Array>; size: number } | null>
13
+ head(name: string): Promise<{ size: number } | null>
14
+ }
15
+ /** The data center's cache (Workers Cache API): free, and per data center. */
16
+ interface WorkersCache {
17
+ match(key: Request): Promise<Response | undefined>
18
+ put(key: Request, res: Response): Promise<void>
19
+ delete(key: Request): Promise<boolean>
20
+ }
21
+ declare const caches: { open(name: string): Promise<WorkersCache> }
22
+ export interface Context {
23
+ waitUntil(p: Promise<unknown>): void
24
+ }
25
+ declare const FixedLengthStream: new (length: number) => {
26
+ readable: ReadableStream<Uint8Array>
27
+ writable: WritableStream<Uint8Array>
28
+ }
29
+
30
+ export interface Env {
31
+ readonly BLOBS?: R2Bucket
32
+ /** Who may take part (`parseAllow`): `issuer:owner/repo:workflow` entries, space separated. */
33
+ readonly ALLOW?: string
34
+ /** The `aud` a CI token names; unset, the origin the Worker is reached at. */
35
+ readonly AUDIENCE?: string
36
+ /** `public`: a request with no token reads a repository ALLOW names. */
37
+ readonly READ_ACCESS?: string
38
+ /** A string, or a number as wrangler.jsonc may write it. */
39
+ readonly MAX_CHECK_HASHES?: string | number
40
+ readonly MAX_UPLOAD_BYTES?: string | number
41
+ }
42
+
43
+ /** An object's key in the data center's cache. */
44
+ const keyOf = (name: string): Request => new Request(`https://vx-cache.blobs/${name}`)
45
+
46
+ /**
47
+ * R2, read through the data center's cache: an artifact never changes
48
+ * under its name while R2 holds it, so a copy needs no expiry of its own;
49
+ * an upload drops the copy, of bytes R2 has since let go. The server has
50
+ * checked the token and named the object before a read reaches here.
51
+ */
52
+
53
+ export function r2Blobs(bucket: R2Bucket, cache: Promise<WorkersCache>, ctx: Context): Blobs {
54
+ return {
55
+ async put(name, body, size) {
56
+ // R2 takes a stream of known length; this one errors at any other.
57
+ const fixed = new FixedLengthStream(size)
58
+ const piped = body.pipeTo(fixed.writable)
59
+ const [stored, read] = await Promise.allSettled([bucket.put(name, fixed.readable), piped])
60
+ // A body of another length fails the pipe and R2's read of it with
61
+ // one error. R2 failing of its own is the bucket's fault (a 502),
62
+ // never the client's 400, though its stream's cancel fails the pipe.
63
+ if (
64
+ stored.status === 'rejected' &&
65
+ (read.status === 'fulfilled' || stored.reason !== read.reason)
66
+ )
67
+ throw stored.reason
68
+ if (read.status === 'rejected')
69
+ throw new Refusal(400, `the body is not ${size} bytes: ${String(read.reason)}`)
70
+ // R2 takes a name only once it holds none: a copy here is of bytes
71
+ // R2 expired, a corrupt one too, and outlived the new ones a year.
72
+ await (await cache).delete(keyOf(name))
73
+ },
74
+ get: async (name) => {
75
+ const key = keyOf(name)
76
+ const hit = await (await cache).match(key)
77
+ if (hit?.body) return { body: hit.body, size: Number(hit.headers.get('content-length')) }
78
+ const o = await bucket.get(name)
79
+ if (o === null) return null
80
+ // The copy is R2 read again, never a tee of the client's body: a tee
81
+ // on Workers reads at its slower branch's pace, and the cache write
82
+ // held a download for 19 minutes (main, 2026-10-28).
83
+ ctx.waitUntil(
84
+ (async () => {
85
+ const again = await bucket.get(name)
86
+ if (again === null) return
87
+ const headers = {
88
+ 'content-length': String(again.size),
89
+ 'cache-control': 'public, max-age=31536000, immutable',
90
+ }
91
+ await (await cache).put(key, new Response(again.body, { headers }))
92
+ })(),
93
+ )
94
+ return { body: o.body, size: o.size }
95
+ },
96
+ // R2 alone answers whether it holds an object: a check, a HEAD and a
97
+ // PUT's skip. The data center's copy outlives R2's (expiry, a wipe), so
98
+ // a writer told "held" there skipped its upload and a reader in another
99
+ // data center found nothing (2026-10-27: a DTE worker's save, then the
100
+ // run's read, a 404).
101
+ size: async (name) => (await bucket.head(name))?.size ?? null,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * A check's lookups by default: a Worker request has 6 calls waiting on R2
107
+ * at once and queues the rest, so a check of 6 answers in one round and a
108
+ * client sends many at once (measured, 2026-10-25: 6 heads 64 ms, 24 341
109
+ * ms, 134 1.1-1.4 s; from a GitHub runner, 134 hashes, half held: 23
110
+ * checks of 6 at once 168-216 ms median, 134 single lookups 246-349 ms,
111
+ * one check of 134 1.9 s). A list is no way round it: a scope may hold
112
+ * billions.
113
+ */
114
+ const CHECK_LOOKUPS = 6
115
+
116
+ /**
117
+ * The limits as set, unclamped: a plan's limits are the operator's to set
118
+ * (2026-10-25: never optimize for the free plan).
119
+ */
120
+ function workerLimits(env: Env): Limits {
121
+ return { ...DEFAULT_LIMITS, maxCheckHashes: CHECK_LOOKUPS, ...limitsFrom(env) }
122
+ }
123
+
124
+ /** What a request shares per isolate and settings. */
125
+ let made: { key: string; authorize: Authorize; limits: Limits } | undefined
126
+
127
+ export default {
128
+ async fetch(req: Request, env: Env, ctx: Context): Promise<Response> {
129
+ // A deploy's mistake, not R2's outage: unbound, every read was a 502.
130
+ if (env.BLOBS === undefined) {
131
+ return errorJson(500, 'BLOBS: bind an R2 bucket as BLOBS (wrangler.jsonc r2_buckets)')
132
+ }
133
+ const key = [
134
+ env.ALLOW,
135
+ env.AUDIENCE,
136
+ env.READ_ACCESS,
137
+ env.MAX_CHECK_HASHES,
138
+ env.MAX_UPLOAD_BYTES,
139
+ ].join('\0')
140
+ if (made?.key !== key) {
141
+ let allow
142
+ let limits
143
+ // wrangler.jsonc may write a var as JSON: a list reached parseAllow.
144
+ if (env.ALLOW !== undefined && typeof env.ALLOW !== 'string') {
145
+ return errorJson(
146
+ 500,
147
+ 'ALLOW: one string of issuer:owner/repo:workflow entries, space separated',
148
+ )
149
+ }
150
+ // Likewise AUDIENCE: "env.AUDIENCE.trim is not a function".
151
+ if (env.AUDIENCE !== undefined && typeof (env.AUDIENCE as unknown) !== 'string') {
152
+ return errorJson(500, 'AUDIENCE: one string, the aud CI tokens name')
153
+ }
154
+ try {
155
+ allow = parseAllow(env.ALLOW ?? '')
156
+ } catch (err) {
157
+ return errorJson(500, `ALLOW: ${(err as Error).message}`)
158
+ }
159
+ let authorize
160
+ try {
161
+ limits = workerLimits(env)
162
+ authorize = oidcAuth({
163
+ allow,
164
+ readAccess: env.READ_ACCESS,
165
+ // jose matches aud exactly: a pasted newline refused every
166
+ // token as the wrong audience.
167
+ ...(env.AUDIENCE ? { audience: env.AUDIENCE.trim() } : {}),
168
+ })
169
+ } catch (err) {
170
+ return errorJson(500, (err as Error).message)
171
+ }
172
+ made = { key, authorize, limits }
173
+ }
174
+ // The bucket and cache are this request's: a Worker's I/O objects do not outlive it.
175
+ return createCacheServer({
176
+ blobs: r2Blobs(env.BLOBS, caches.open('vx-cache'), ctx),
177
+ authorize: made.authorize,
178
+ limits: made.limits,
179
+ })(req)
180
+ },
181
+ }
package/wrangler.jsonc ADDED
@@ -0,0 +1,16 @@
1
+ // vx-cache-server on Cloudflare Workers (src/worker.ts): `bunx wrangler
2
+ // deploy` from this directory. Artifacts live in R2, and nothing else.
3
+ {
4
+ "name": "vx-cache-server",
5
+ "main": "src/worker.ts",
6
+ "compatibility_date": "2026-10-01",
7
+ "workers_dev": true,
8
+ // ALLOW: who may take part, issuer:owner/repo:workflow entries
9
+ // separated by spaces (README.md § CI tokens); empty, every request is
10
+ // refused with "set ALLOW". AUDIENCE: the aud a
11
+ // CI token names, unset the Worker's own origin. MAX_CHECK_HASHES
12
+ // and MAX_UPLOAD_BYTES set the limits, a string or a number (PROTOCOL.md).
13
+ "vars": { "ALLOW": "" },
14
+ // Expiry is the bucket's lifecycle rules (README.md § Expiry).
15
+ "r2_buckets": [{ "binding": "BLOBS", "bucket_name": "vx-cache" }],
16
+ }