@vzn/vx-cache-server 0.0.0 → 0.0.649
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 +20 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +151 -0
- package/README.md +152 -1
- package/index.ts +4 -0
- package/package.json +53 -4
- package/src/bun.ts +173 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +23 -0
- package/src/server.ts +294 -0
- package/src/store.ts +35 -0
- package/src/worker.ts +181 -0
- package/wrangler.jsonc +16 -0
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,8 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-cache-server",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Placeholder; real releases are published by CI.",
|
|
3
|
+
"version": "0.0.649",
|
|
5
4
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
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.649"
|
|
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'
|
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|