@vzn/vx-cache-server 0.0.652 → 0.0.653

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/PROTOCOL.md CHANGED
@@ -45,7 +45,7 @@ request with no token `401`.
45
45
 
46
46
  A request's `Authorization: Bearer <token>` is a CI job's OIDC token
47
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
48
+ `RS256`, `exp` and `aud` (the server's own origin) are checked, and the token
49
49
  alone decides what it reads and where it writes, by the server's
50
50
  `ALLOW` (`issuer:owner/repo:workflow` entries):
51
51
 
@@ -83,20 +83,23 @@ An artifact lives in one scope, and a key names both: `{ scope, hash }`.
83
83
  does (`refs/heads/main` is `refs%2Fheads%2Fmain`), at most 512
84
84
  characters. Any other spelling (a plain ref, another encoding of it, a
85
85
  name not under `refs/`) is `400`, so one ref is one scope.
86
- `GET` and `HEAD` name one scope in `vx-scope`; a check names
87
- its scopes in its body.
88
- `hash` is 1 to 128 hex digits. A read is exact: the server answers the scope named and no
89
- other, so a client that wants its branch's work and then main's asks
90
- for both. Every read names a scope.
86
+ `GET` and `HEAD` name their scopes in `vx-scope`, in the order to read
87
+ them (`<scope>, <scope>`), up to `maxCheckHashes`; a check names its
88
+ scopes in its body.
89
+ `hash` is 1 to 128 hex digits. A read is exact: the server answers the scopes named and no
90
+ other, the first that holds the hash, so a client that wants its
91
+ branch's work and then main's names both. Every read names a scope.
91
92
 
92
93
  An artifact is immutable. A server may expire one at any time: a
93
94
  download after a check that held it may miss, which a client reads as a
94
95
  miss.
95
96
 
96
97
  `GET /v1/artifacts/<hash>` (as Turbo's `GET /v8/artifacts/<hash>`) with
97
- `vx-scope: <scope>`: `200` with the bytes (`application/octet-stream`,
98
+ `vx-scope: <scope>, …`: `200` with the bytes of the first scope that
99
+ holds them (`application/octet-stream`; from the first scope named,
98
100
  `Cache-Control: private, max-age=31536000, immutable`: the bytes under a
99
- key never change), or `404`. `HEAD` is the same with no bytes, and names
101
+ key never change; from a later one, `private, no-cache`: an earlier
102
+ scope may come to hold the hash), or `404`. `HEAD` is the same with no bytes, and names
100
103
  the size as `Content-Length`. A download may come chunked with no
101
104
  `Content-Length` (Bun.serve sends any streamed answer so), so a body cut
102
105
  short can end as cleanly as a whole one: a client checks the bytes
@@ -138,7 +141,12 @@ An operator sets each; `GET /v1/capabilities` names them:
138
141
  { "maxCheckHashes": 500, "maxUploadBytes": 100000000 }
139
142
  ```
140
143
 
141
- A client keeps to the limits the server names.
144
+ A client keeps to the limits the server names. It need not ask them
145
+ apart: every `/v1/artifacts` answer names them, so a client may send its
146
+ first check whole and, refused for its size (`400` past
147
+ `maxCheckHashes`, `413` past the bytes they take), send it again in
148
+ chunks of the limits its refusal names. vx does: a run the local cache
149
+ answers asks the server nothing.
142
150
 
143
151
  ## Errors
144
152
 
package/README.md CHANGED
@@ -46,7 +46,8 @@ scope, which only runs of that branch read:
46
46
 
47
47
  `AUDIENCE` is the `aud` a token must name: the origin clients reach the
48
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
49
+ GitHub for. A URL is read as its origin (`https://cache.example.com/v1`
50
+ is `https://cache.example.com`); one that is no URL stops the server. On Workers, unset, it is that origin: Cloudflare routes only
50
51
  the Worker's own hostnames to it. On Bun and Docker it is required, since
51
52
  there the origin is the request's `Host` header, which a client picks.
52
53
  `READ_ACCESS=public` lets a request with no token read a repository
@@ -113,16 +114,22 @@ artifact (a check, a `HEAD`, an upload's skip) is asked of R2, never
113
114
  of that copy: it can outlive R2's, and a writer told "held" there would
114
115
  skip an upload another data center then cannot read. An upload drops
115
116
  the copy in the data center it reaches: R2 had let those bytes go.
117
+ A read R2 answers "try again" (10001, 10058) is asked up to three
118
+ times before the request fails `502`. The deploy clears any cron: the
119
+ Worker has no `scheduled()`.
116
120
 
117
121
  Set `EXPIRY_DAYS` to the bucket's lifecycle rule (below) and R2's
118
122
  "held" is kept in the data center's cache too: an upload, R2's
119
123
  answer to a lookup, or a download R2 serves marks the artifact held,
120
124
  in the background, until the rule may drop it, counted from its upload. A repeat check or `HEAD` in that data center then
121
125
  skips R2. Only "held" is kept: a miss always asks R2, so a later
122
- upload is never hidden. Unset, every lookup asks R2. A larger value
126
+ upload is never hidden. An upload's skip always asks R2, never a
127
+ mark: a client uploads only what its check missed, which no mark
128
+ holds, so this costs no head a mark saved. Unset, every lookup asks R2. A larger value
123
129
  than the rule is wrong: a mark would outlive the artifact. After
124
- deleting artifacts by hand, a mark may still say "held" for up to
125
- `EXPIRY_DAYS`; vx then misses the download and runs the task.
130
+ deleting artifacts by hand, a mark may still answer a check "held";
131
+ vx then misses the download and runs the task, the miss drops that
132
+ mark, and the task's upload is stored again.
126
133
 
127
134
  ## Expiry
128
135
 
@@ -149,8 +156,9 @@ over a bucket of your own (`src/index.ts`):
149
156
  - `createCacheServer(options: ServerOptions)` returns
150
157
  `(req: Request) => Promise<Response>`. `ServerOptions` is
151
158
  `{ blobs, authorize, limits? }`: `blobs` is a `Blobs` bucket
152
- (`{ put(name, body, size), get(name), size(name) }`, `get` answering a
153
- `Blob`, `{ body, size }`), `authorize` an `Authorize`, which maps a
159
+ (`{ put(name, body, size), get(name), size(name, bucket?) }`, `get` answering a
160
+ `Blob`, `{ body, size }`, `size` with `bucket` the bucket's own answer,
161
+ never one the backend kept: an upload's skip; a backend that keeps none ignores it), `authorize` an `Authorize`, which maps a
154
162
  request to its `Access` (`{ issuer, repository, write? }`: the token's issuer and
155
163
  repository name, which it reads, and the scope it may write) or throws, and `limits` any of `Limits`
156
164
  (`{ maxCheckHashes, maxUploadBytes }`) over
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-cache-server",
3
- "version": "0.0.652",
3
+ "version": "0.0.653",
4
4
  "license": "MIT",
5
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
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "miniflare: tests/worker.test.ts runs src/worker.ts in workerd with R2, as Workers deploy it."
46
46
  ],
47
47
  "dependencies": {
48
- "@vzn/vx-devtools": "0.0.652"
48
+ "@vzn/vx-devtools": "0.0.653"
49
49
  },
50
50
  "repository": {
51
51
  "type": "git",
package/src/bun.ts CHANGED
@@ -11,7 +11,7 @@ import type { Blobs } from './store.js'
11
11
  const USAGE = `usage: vx-cache-server [--port 8080] [--host 0.0.0.0]
12
12
  ALLOW who may take part, space separated, each issuer:owner/repo:workflow
13
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
14
+ AUDIENCE the aud a CI token names: the origin clients reach this server at
15
15
  READ_ACCESS public: a request with no token reads a repository ALLOW names
16
16
  S3_BUCKET the bucket; S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID and
17
17
  S3_SECRET_ACCESS_KEY as Bun's S3 client reads them
@@ -140,7 +140,9 @@ function main(argv: string[]): void {
140
140
  // header, which the client picks. Workers know their own hostnames.
141
141
  const audience = env.AUDIENCE?.trim() ?? ''
142
142
  if (audience === '')
143
- fail(`set AUDIENCE: the aud a CI token names, the URL clients reach this server at\n${USAGE}`)
143
+ fail(
144
+ `set AUDIENCE: the aud a CI token names, the origin clients reach this server at\n${USAGE}`,
145
+ )
144
146
  const handle = createCacheServer({
145
147
  blobs: s3Blobs(client),
146
148
  authorize: oidcAuth({
package/src/server.ts CHANGED
@@ -57,6 +57,13 @@ export function limitsFrom(env: object): Partial<Limits> {
57
57
  /** A held artifact's bytes never change (2026-10-25). */
58
58
  const IMMUTABLE = 'private, max-age=31536000, immutable'
59
59
 
60
+ /**
61
+ * An answer from the first scope named is that key's, which never
62
+ * changes; private: its token chose the repository. One from a later
63
+ * scope changes once an earlier one holds the hash.
64
+ */
65
+ const cacheOf = (at: number): string => (at === 0 ? IMMUTABLE : 'private, no-cache')
66
+
60
67
  /**
61
68
  * A scope on the wire: a ref (`refs/…`), percent-encoded as
62
69
  * `encodeURIComponent` does, its folder's name (2026-11-03). One spelling
@@ -91,6 +98,17 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
91
98
  }
92
99
  return scope
93
100
  }
101
+ /**
102
+ * The scopes a download names in `vx-scope`, in the order to read them
103
+ * (`<scope>, <scope>`): up to `maxCheckHashes`, its lookups as a check's.
104
+ */
105
+ const scopesOf = (req: Request): string[] => {
106
+ const named = (req.headers.get(SCOPE_HEADER) ?? '').split(',').map((s) => s.trim())
107
+ if (named.length > limits.maxCheckHashes) {
108
+ throw new Refusal(400, `${SCOPE_HEADER}: up to ${limits.maxCheckHashes} scopes`)
109
+ }
110
+ return named.map(scopeOf)
111
+ }
94
112
  const nameOf = (access: Access, scope: string, hash: string): string =>
95
113
  blobName(access.issuer, access.repository, scope, hash)
96
114
  /** A read of the bucket: one it fails is its fault, `502`, never the server's `500`. */
@@ -137,32 +155,43 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
137
155
  return { hashes: hashes as string[], scopes: scopes.map(scopeOf) }
138
156
  }
139
157
 
158
+ /**
159
+ * An artifact under the first scope named that holds it, each asked in
160
+ * turn: a run on a branch reads its own scope, then main's, and a client
161
+ * asking each by its own request paid a round-trip for every scope
162
+ * before the one that held it (2026-11-06).
163
+ */
140
164
  async function artifact(req: Request, hash: string): Promise<Response> {
141
165
  if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
142
166
  const access = await authorize(req)
143
- const scope = scopeOf(req.headers.get(SCOPE_HEADER))
144
- const blob = await read(blobs.get(nameOf(access, scope, hash)))
145
- if (blob === null) return errorJson(404, 'no artifact under this key')
146
- return new Response(blob.body, {
147
- headers: {
148
- 'content-length': String(blob.size),
149
- 'content-type': 'application/octet-stream',
150
- // An artifact never changes under its key; private: its token chose the repository.
151
- 'cache-control': IMMUTABLE,
152
- },
153
- })
167
+ const scopes = scopesOf(req)
168
+ for (const [at, scope] of scopes.entries()) {
169
+ const blob = await read(blobs.get(nameOf(access, scope, hash)))
170
+ if (blob === null) continue
171
+ return new Response(blob.body, {
172
+ headers: {
173
+ 'content-length': String(blob.size),
174
+ 'content-type': 'application/octet-stream',
175
+ 'cache-control': cacheOf(at),
176
+ },
177
+ })
178
+ }
179
+ return errorJson(404, 'no artifact under this key')
154
180
  }
155
181
 
156
- /** Turbo's `HEAD /v8/artifacts/<hash>`: whether the scope named holds it, and its size, with no bytes. */
182
+ /** Turbo's `HEAD /v8/artifacts/<hash>`: whether a scope named holds it, and its size, with no bytes. */
157
183
  async function exists(req: Request, hash: string): Promise<Response> {
158
184
  if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
159
185
  const access = await authorize(req)
160
- const scope = scopeOf(req.headers.get(SCOPE_HEADER))
161
- const size = await read(blobs.size(nameOf(access, scope, hash)))
162
- if (size === null) return new Response(null, { status: 404 })
163
- return new Response(null, {
164
- headers: { 'content-length': String(size), 'cache-control': IMMUTABLE },
165
- })
186
+ const scopes = scopesOf(req)
187
+ for (const [at, scope] of scopes.entries()) {
188
+ const size = await read(blobs.size(nameOf(access, scope, hash)))
189
+ if (size === null) continue
190
+ return new Response(null, {
191
+ headers: { 'content-length': String(size), 'cache-control': cacheOf(at) },
192
+ })
193
+ }
194
+ return new Response(null, { status: 404 })
166
195
  }
167
196
 
168
197
  // Every lookup at once, each exact: a hash answers the first scope
@@ -199,7 +228,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
199
228
  if (length > limits.maxUploadBytes) {
200
229
  throw new Refusal(413, `an upload is at most ${limits.maxUploadBytes} bytes`)
201
230
  }
202
- if ((await read(blobs.size(name))) !== null) {
231
+ if ((await read(blobs.size(name, true))) !== null) {
203
232
  await req.body?.cancel()
204
233
  return new Response(null, { status: 204 })
205
234
  }
package/src/store.ts CHANGED
@@ -15,8 +15,12 @@ export interface Blobs {
15
15
  put(name: string, body: ReadableStream<Uint8Array>, size: number): Promise<void>
16
16
  /** The blob, or null when the bucket has none by that name. */
17
17
  get(name: string): Promise<Blob | null>
18
- /** Its size, read off its metadata, or null when there is none. */
19
- size(name: string): Promise<number | null>
18
+ /**
19
+ * Its size, read off its metadata, or null when there is none. `bucket`:
20
+ * the bucket's own answer, never one a backend kept (an upload's skip,
21
+ * which a stale "held" would drop).
22
+ */
23
+ size(name: string, bucket?: boolean): Promise<number | null>
20
24
  }
21
25
 
22
26
  /**
package/src/worker.ts CHANGED
@@ -41,7 +41,7 @@ export interface Env {
41
41
  readonly BLOBS?: R2Bucket
42
42
  /** Who may take part (`parseAllow`): `issuer:owner/repo:workflow` entries, space separated. */
43
43
  readonly ALLOW?: string
44
- /** The `aud` a CI token names; unset, the origin the Worker is reached at. */
44
+ /** The `aud` a CI token names, read as its origin; unset, the origin the Worker is reached at. */
45
45
  readonly AUDIENCE?: string
46
46
  /** `public`: a request with no token reads a repository ALLOW names. */
47
47
  readonly READ_ACCESS?: string
@@ -52,6 +52,28 @@ export interface Env {
52
52
  readonly EXPIRY_DAYS?: string | number
53
53
  }
54
54
 
55
+ /**
56
+ * R2's answers that ask to be sent again: 10001 "internal error, please
57
+ * try again" and 10058 "reduce your rate of simultaneous reads on the
58
+ * same object". Each failed a check, a download or an upload's skip 502
59
+ * (2026-11-01 to 11-06: 57 in the dashboard's Issues), so the client lost
60
+ * a hit or sent its bytes again.
61
+ */
62
+ const AGAIN = /\((10001|10058)\)$/
63
+ const R2_TRIES = 3
64
+
65
+ /** A read of R2, asked up to three times while R2 says to ask again, 50 then 100 ms apart. */
66
+ async function again<T>(ask: () => Promise<T>): Promise<T> {
67
+ for (let attempt = 1; ; attempt++) {
68
+ try {
69
+ return await ask()
70
+ } catch (err) {
71
+ if (attempt === R2_TRIES || !AGAIN.test(String((err as Error)?.message))) throw err
72
+ await new Promise((r) => setTimeout(r, 50 * attempt))
73
+ }
74
+ }
75
+ }
76
+
55
77
  /** An object's key in the data center's cache. */
56
78
  const keyOf = (name: string): Request => new Request(`https://vx-cache.blobs/${name}`)
57
79
  /** The key of the mark that R2 holds an object, and its size. */
@@ -67,8 +89,9 @@ const SIZE = 'vx-size'
67
89
  * With `expiryDays`, the bucket's lifecycle rule, whether R2 holds an
68
90
  * object is kept there too, once R2 takes, heads or serves it, until the
69
91
  * rule may drop it: counted from its upload, so a mark never outlives
70
- * R2's copy. Only "held" is kept: a
71
- * kept "missing" would hide an upload that lands later.
92
+ * R2's copy unless it is deleted by hand, which a download R2 misses
93
+ * clears. Only "held" is kept: a kept "missing" would hide an upload
94
+ * that lands later.
72
95
  */
73
96
  export function r2Blobs(
74
97
  bucket: R2Bucket,
@@ -111,22 +134,31 @@ export function r2Blobs(
111
134
  const hit = await (await cache).match(key)
112
135
  // No mark from this copy: it can outlive R2's.
113
136
  if (hit?.body) return { body: hit.body, size: Number(hit.headers.get('content-length')) }
114
- const o = await bucket.get(name)
115
- if (o === null) return null
137
+ const o = await again(() => bucket.get(name))
138
+ if (o === null) {
139
+ // R2 let it go before its expiry (a deletion by hand): the next
140
+ // check asks R2 again, and its miss sends the upload.
141
+ if (expiryDays !== undefined) ctx.waitUntil(cache.then((c) => c.delete(heldOf(name))))
142
+ return null
143
+ }
116
144
  mark(name, o.size, o.uploaded.getTime())
117
145
  // The copy is R2 read again, never a tee of the client's body: a tee
118
146
  // on Workers reads at its slower branch's pace, and the cache write
119
147
  // held a download for 19 minutes (main, 2026-10-28).
120
148
  ctx.waitUntil(
121
149
  (async () => {
122
- const again = await bucket.get(name)
123
- if (again === null) return
150
+ const copy = await again(() => bucket.get(name))
151
+ if (copy === null) return
124
152
  const headers = {
125
- 'content-length': String(again.size),
153
+ 'content-length': String(copy.size),
126
154
  'cache-control': 'public, max-age=31536000, immutable',
127
155
  }
128
- await (await cache).put(key, new Response(again.body, { headers }))
129
- })(),
156
+ await (await cache).put(key, new Response(copy.body, { headers }))
157
+ })().catch((err: unknown) =>
158
+ // The client has its bytes; only the copy failed. Thrown, it was
159
+ // an uncaught exception in the dashboard's Issues (2026-11-01).
160
+ console.warn(`vx-cache-server: the data center's copy of ${name}: ${String(err)}`),
161
+ ),
130
162
  )
131
163
  return { body: o.body, size: o.size }
132
164
  },
@@ -135,12 +167,16 @@ export function r2Blobs(
135
167
  // which outlives R2's (expiry, a wipe), so a writer told "held" there
136
168
  // skipped its upload and a reader in another data center found nothing
137
169
  // (2026-10-27: a DTE worker's save, then the run's read, a 404).
138
- size: async (name) => {
139
- if (expiryDays !== undefined) {
170
+ // A PUT's skip asks R2 alone: a mark outlives a deletion by hand, and
171
+ // the upload it answered was dropped until the mark's expiry. It costs
172
+ // no head a mark saved: a client checks first and uploads only what
173
+ // the check missed, which no mark holds.
174
+ size: async (name, fromBucket) => {
175
+ if (expiryDays !== undefined && fromBucket !== true) {
140
176
  const held = await (await cache).match(heldOf(name))
141
177
  if (held !== undefined) return Number(held.headers.get(SIZE))
142
178
  }
143
- const o = await bucket.head(name)
179
+ const o = await again(() => bucket.head(name))
144
180
  if (o === null) return null
145
181
  mark(name, o.size, o.uploaded.getTime())
146
182
  return o.size
package/wrangler.jsonc CHANGED
@@ -13,6 +13,10 @@
13
13
  // EXPIRY_DAYS: the bucket's lifecycle rule in days, so a repeat check
14
14
  // skips R2 (README.md § On Cloudflare Workers); never above the rule.
15
15
  "vars": { "ALLOW": "" },
16
+ // No cron: the Worker exports no scheduled(). Unset, wrangler leaves a
17
+ // dashboard's or an old deploy's cron in place, which failed daily
18
+ // (2026-10-26 to 11-06); an empty list clears it on every deploy.
19
+ "triggers": { "crons": [] },
16
20
  // Expiry is the bucket's lifecycle rules (README.md § Expiry).
17
21
  "r2_buckets": [{ "binding": "BLOBS", "bucket_name": "vx-cache" }],
18
22
  }