@vzn/vx-cache-server 0.0.649 → 0.0.651
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 +14 -8
- package/README.md +21 -10
- package/package.json +2 -2
- package/src/protocol.ts +1 -1
- package/src/server.ts +41 -22
- package/src/store.ts +8 -13
- package/src/worker.ts +92 -16
- package/wrangler.jsonc +2 -0
package/PROTOCOL.md
CHANGED
|
@@ -24,9 +24,9 @@ are refused; there is no events route.
|
|
|
24
24
|
|
|
25
25
|
The server never reads a hash or an artifact's bytes. It only stores
|
|
26
26
|
them and compares hashes for equality, so a client may send them
|
|
27
|
-
encrypted (a keyed hash for names, a cipher for bytes).
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
encrypted (a keyed hash for names, a cipher for bytes). A scope is its
|
|
28
|
+
ref, percent-encoded (§ Keys), and the server encodes the token's ref
|
|
29
|
+
the same way to find where a write goes.
|
|
30
30
|
|
|
31
31
|
## Repository
|
|
32
32
|
|
|
@@ -61,9 +61,13 @@ alone decides what it reads and where it writes, by the server's
|
|
|
61
61
|
become, else its `ref`; on GitLab its `ref_path`.
|
|
62
62
|
- A `pull_request_target`, `issue_comment` or `workflow_run` run writes
|
|
63
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 `
|
|
65
|
-
`
|
|
66
|
-
|
|
64
|
+
pull request's code. Nor does a GitLab run whose `source_project_path`
|
|
65
|
+
or `job_project_path` names another project, whatever its
|
|
66
|
+
`pipeline_source`: a fork's merge request, and a child pipeline of one,
|
|
67
|
+
runs in the parent project with the fork's branch as its `ref_path`. A
|
|
68
|
+
`merge_request_event` pipeline must name its source project (GitLab 19.5+).
|
|
69
|
+
- A ref over 512 characters percent-encoded writes nothing (a write is
|
|
70
|
+
`403`): no read could name its scope (§ Keys).
|
|
67
71
|
|
|
68
72
|
A bad token is `401`. A server with `ALLOW` empty does not start; a
|
|
69
73
|
Worker, which cannot refuse to, refuses every request: `401`,
|
|
@@ -75,8 +79,10 @@ so a client keeps the remote for its next request.
|
|
|
75
79
|
## Keys
|
|
76
80
|
|
|
77
81
|
An artifact lives in one scope, and a key names both: `{ scope, hash }`.
|
|
78
|
-
`scope` is a ref
|
|
79
|
-
(`refs/heads/main`),
|
|
82
|
+
`scope` is a ref, percent-encoded as JavaScript's `encodeURIComponent`
|
|
83
|
+
does (`refs/heads/main` is `refs%2Fheads%2Fmain`), at most 512
|
|
84
|
+
characters. Any other spelling (a plain ref, another encoding of it, a
|
|
85
|
+
name not under `refs/`) is `400`, so one ref is one scope.
|
|
80
86
|
`GET` and `HEAD` name one scope in `vx-scope`; a check names
|
|
81
87
|
its scopes in its body.
|
|
82
88
|
`hash` is 1 to 128 hex digits. A read is exact: the server answers the scope named and no
|
package/README.md
CHANGED
|
@@ -109,18 +109,29 @@ subrequests a request, and `MAX_UPLOAD_BYTES` your plan's request body.
|
|
|
109
109
|
The server lowers neither. A download is read through the data center's cache: an
|
|
110
110
|
artifact never changes, so after R2 serves it once, that data center
|
|
111
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
artifact (a check, a `HEAD`, an upload's skip) is asked of R2, never
|
|
113
|
+
of that copy: it can outlive R2's, and a writer told "held" there would
|
|
114
|
+
skip an upload another data center then cannot read. An upload drops
|
|
115
|
+
the copy in the data center it reaches: R2 had let those bytes go.
|
|
116
|
+
|
|
117
|
+
Set `EXPIRY_DAYS` to the bucket's lifecycle rule (below) and R2's
|
|
118
|
+
"held" is kept in the data center's cache too: an upload, R2's
|
|
119
|
+
answer to a lookup, or a download R2 serves marks the artifact held,
|
|
120
|
+
in the background, until the rule may drop it, counted from its upload. A repeat check or `HEAD` in that data center then
|
|
121
|
+
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
|
|
123
|
+
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.
|
|
116
126
|
|
|
117
127
|
## Expiry
|
|
118
128
|
|
|
119
129
|
The bucket's own lifecycle rule expires artifacts, by age since they
|
|
120
130
|
were written; the server runs no sweep. An artifact's object name is
|
|
121
|
-
`<issuer>/<repository>/<
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
`<issuer>/<repository>/<ref>/<hash>`, each a readable folder
|
|
132
|
+
(`https%3A%2F%2Ftoken.actions.githubusercontent.com/acme%2Fapp/refs%2Fheads%2Fmain/<hash>`),
|
|
133
|
+
so deleting one branch's prefix prunes that branch alone. One age rule
|
|
134
|
+
covers the bucket, for example 1 day:
|
|
124
135
|
|
|
125
136
|
```sh
|
|
126
137
|
bunx wrangler r2 bucket lifecycle add vx-cache all '' --expire-days 1
|
|
@@ -145,9 +156,9 @@ over a bucket of your own (`src/index.ts`):
|
|
|
145
156
|
(`{ maxCheckHashes, maxUploadBytes }`) over
|
|
146
157
|
`DEFAULT_LIMITS`.
|
|
147
158
|
- `blobName(issuer, repository, scope, hash)` is an artifact's object name,
|
|
148
|
-
`<issuer>/<repository>/<scope>/<hash>`, issuer and repository each
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
`<issuer>/<repository>/<scope>/<hash>`, issuer and repository each
|
|
160
|
+
percent-encoded and `scope` the encoded ref as a client sends it
|
|
161
|
+
(PROTOCOL.md § Keys), so a slash in any part never starts a folder.
|
|
151
162
|
- `ALLOW` and the CI token check are [`@vzn/vx-devtools`](https://github.com/vznjs/vx/tree/main/packages/vx-devtools)'s,
|
|
152
163
|
the check every vx server shares.
|
|
153
164
|
- `SCOPE_HEADER` (`vx-scope`) and `LIMIT_HEADERS` are the header names
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-cache-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.651",
|
|
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.
|
|
48
|
+
"@vzn/vx-devtools": "0.0.651"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|
package/src/protocol.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// what a request carries. Every string a client sends is opaque to the
|
|
3
3
|
// server, which only compares it, so a client can send it encrypted.
|
|
4
4
|
|
|
5
|
-
/** The scope (a ref
|
|
5
|
+
/** The scope (a ref, percent-encoded) a download reads: `GET /v1/artifacts/<hash>`. */
|
|
6
6
|
export const SCOPE_HEADER = 'vx-scope'
|
|
7
7
|
|
|
8
8
|
/**
|
package/src/server.ts
CHANGED
|
@@ -3,14 +3,7 @@
|
|
|
3
3
|
// an artifact is one object, named by its issuer, repository name, scope and
|
|
4
4
|
// hash, and a read names the exact scope it wants (2026-10-25).
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
type Access,
|
|
8
|
-
type Authorize,
|
|
9
|
-
errorJson,
|
|
10
|
-
hashPart,
|
|
11
|
-
notAllowed,
|
|
12
|
-
Refusal,
|
|
13
|
-
} from '@vzn/vx-devtools'
|
|
6
|
+
import { type Access, type Authorize, errorJson, notAllowed, Refusal } from '@vzn/vx-devtools'
|
|
14
7
|
import { isCount, isHash, LIMIT_HEADERS, SCOPE_HEADER } from './protocol.js'
|
|
15
8
|
import { blobName, type Blobs } from './store.js'
|
|
16
9
|
|
|
@@ -64,8 +57,22 @@ export function limitsFrom(env: object): Partial<Limits> {
|
|
|
64
57
|
/** A held artifact's bytes never change (2026-10-25). */
|
|
65
58
|
const IMMUTABLE = 'private, max-age=31536000, immutable'
|
|
66
59
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
60
|
+
/**
|
|
61
|
+
* A scope on the wire: a ref (`refs/…`), percent-encoded as
|
|
62
|
+
* `encodeURIComponent` does, its folder's name (2026-11-03). One spelling
|
|
63
|
+
* per ref, so no two scopes name one folder; at most 512 characters, so
|
|
64
|
+
* a name stays under a bucket's 1024-byte key.
|
|
65
|
+
*/
|
|
66
|
+
const SCOPE_MAX = 512
|
|
67
|
+
const isScope = (scope: string): boolean => {
|
|
68
|
+
if (scope.length > SCOPE_MAX || !scope.startsWith('refs%2F')) return false
|
|
69
|
+
try {
|
|
70
|
+
return encodeURIComponent(decodeURIComponent(scope)) === scope
|
|
71
|
+
} catch {
|
|
72
|
+
// quiet: a broken escape (`%E0`) is a bad scope, which the caller refuses `400`.
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
69
76
|
|
|
70
77
|
export function createCacheServer(opts: ServerOptions): (req: Request) => Promise<Response> {
|
|
71
78
|
const { blobs, authorize } = opts
|
|
@@ -74,14 +81,17 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
74
81
|
Object.entries(LIMIT_HEADERS).map(([k, h]) => [h, String(limits[k as keyof Limits])]),
|
|
75
82
|
)
|
|
76
83
|
|
|
77
|
-
/** The scope a client names: a ref
|
|
84
|
+
/** The scope a client names: a ref, percent-encoded, read as it is. */
|
|
78
85
|
const scopeOf = (scope: unknown): string => {
|
|
79
|
-
if (typeof scope !== 'string' || !
|
|
80
|
-
throw new Refusal(
|
|
86
|
+
if (typeof scope !== 'string' || !isScope(scope)) {
|
|
87
|
+
throw new Refusal(
|
|
88
|
+
400,
|
|
89
|
+
`scope: a ref, percent-encoded, up to ${SCOPE_MAX} characters (PROTOCOL.md § Keys)`,
|
|
90
|
+
)
|
|
81
91
|
}
|
|
82
92
|
return scope
|
|
83
93
|
}
|
|
84
|
-
const nameOf = (access: Access, scope: string, hash: string):
|
|
94
|
+
const nameOf = (access: Access, scope: string, hash: string): string =>
|
|
85
95
|
blobName(access.issuer, access.repository, scope, hash)
|
|
86
96
|
/** A read of the bucket: one it fails is its fault, `502`, never the server's `500`. */
|
|
87
97
|
const read = async <T>(ask: Promise<T>): Promise<T> => {
|
|
@@ -93,9 +103,17 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
/** The token's own scope, the one place it writes. */
|
|
96
|
-
const writeScope = (access: Access):
|
|
106
|
+
const writeScope = (access: Access): string => {
|
|
97
107
|
if (access.write === undefined) throw new Refusal(403, 'this request reads only')
|
|
98
|
-
|
|
108
|
+
const scope = encodeURIComponent(access.write)
|
|
109
|
+
// Stored, it was a folder no read can name: every read's scope is ≤ SCOPE_MAX.
|
|
110
|
+
if (scope.length > SCOPE_MAX) {
|
|
111
|
+
throw new Refusal(
|
|
112
|
+
403,
|
|
113
|
+
`this job's ref is ${scope.length} characters percent-encoded, over the ${SCOPE_MAX} a scope takes: this request reads only`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
return scope
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
/**
|
|
@@ -103,14 +121,15 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
103
121
|
* the order to read them; up to `max` lookups, hashes times scopes.
|
|
104
122
|
*/
|
|
105
123
|
async function queryOf(req: Request, max: number) {
|
|
106
|
-
|
|
124
|
+
// Each lookup a hash (≤ 128) or a scope (≤ SCOPE_MAX), quoted and comma'd.
|
|
125
|
+
const body = await readJson(req, max * (SCOPE_MAX + 140) + 100)
|
|
107
126
|
const hashes = field(body, 'hashes')
|
|
108
127
|
const scopes = field(body, 'scopes')
|
|
109
128
|
if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === 'string' && isHash(h))) {
|
|
110
129
|
throw new Refusal(400, 'hashes: a list of 1 to 128 hex digits each')
|
|
111
130
|
}
|
|
112
131
|
if (!Array.isArray(scopes) || scopes.length === 0) {
|
|
113
|
-
throw new Refusal(400,
|
|
132
|
+
throw new Refusal(400, 'scopes: a list of refs, percent-encoded, in the order to read them')
|
|
114
133
|
}
|
|
115
134
|
if (hashes.length * scopes.length > max) {
|
|
116
135
|
throw new Refusal(400, `hashes × scopes: up to ${max} lookups`)
|
|
@@ -122,7 +141,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
122
141
|
if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
|
|
123
142
|
const access = await authorize(req)
|
|
124
143
|
const scope = scopeOf(req.headers.get(SCOPE_HEADER))
|
|
125
|
-
const blob = await read(blobs.get(
|
|
144
|
+
const blob = await read(blobs.get(nameOf(access, scope, hash)))
|
|
126
145
|
if (blob === null) return errorJson(404, 'no artifact under this key')
|
|
127
146
|
return new Response(blob.body, {
|
|
128
147
|
headers: {
|
|
@@ -139,7 +158,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
139
158
|
if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
|
|
140
159
|
const access = await authorize(req)
|
|
141
160
|
const scope = scopeOf(req.headers.get(SCOPE_HEADER))
|
|
142
|
-
const size = await read(blobs.size(
|
|
161
|
+
const size = await read(blobs.size(nameOf(access, scope, hash)))
|
|
143
162
|
if (size === null) return new Response(null, { status: 404 })
|
|
144
163
|
return new Response(null, {
|
|
145
164
|
headers: { 'content-length': String(size), 'cache-control': IMMUTABLE },
|
|
@@ -153,7 +172,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
153
172
|
const { hashes, scopes } = await queryOf(req, limits.maxCheckHashes)
|
|
154
173
|
const sizes = await Promise.all(
|
|
155
174
|
hashes.map((hash) =>
|
|
156
|
-
Promise.all(scopes.map(
|
|
175
|
+
Promise.all(scopes.map((s) => read(blobs.size(nameOf(access, s, hash))))),
|
|
157
176
|
),
|
|
158
177
|
)
|
|
159
178
|
const held = Object.fromEntries(
|
|
@@ -175,7 +194,7 @@ export function createCacheServer(opts: ServerOptions): (req: Request) => Promis
|
|
|
175
194
|
async function put(req: Request, hash: string): Promise<Response> {
|
|
176
195
|
if (!isHash(hash)) throw new Refusal(400, 'a hash is 1 to 128 hex digits')
|
|
177
196
|
const access = await authorize(req)
|
|
178
|
-
const name =
|
|
197
|
+
const name = nameOf(access, writeScope(access), hash)
|
|
179
198
|
const length = contentLength(req)
|
|
180
199
|
if (length > limits.maxUploadBytes) {
|
|
181
200
|
throw new Refusal(413, `an upload is at most ${limits.maxUploadBytes} bytes`)
|
package/src/store.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// Where a server keeps what it is sent: one bucket (S3 or R2), no
|
|
2
2
|
// database (2026-10-25). An artifact is one object whose name is its
|
|
3
|
-
// issuer, repository name,
|
|
3
|
+
// issuer, repository name, ref and hash and whose size is the bucket's
|
|
4
4
|
// own, so any number of nodes share a bucket and nothing else.
|
|
5
5
|
|
|
6
|
-
import { hashPart } from '@vzn/vx-devtools'
|
|
7
|
-
|
|
8
6
|
/** A blob's bytes and its size, as the bucket holds them. */
|
|
9
7
|
export interface Blob {
|
|
10
8
|
readonly body: ReadableStream<Uint8Array>
|
|
@@ -22,14 +20,11 @@ export interface Blobs {
|
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
/**
|
|
25
|
-
* An artifact's object name, `<issuer>/<
|
|
26
|
-
* (2026-
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* An artifact's object name, `<issuer>/<repository>/<ref>/<hash>`, each
|
|
24
|
+
* a readable folder (2026-11-03: pruning one branch is deleting its
|
|
25
|
+
* prefix). Issuer and repository are percent-encoded here and `scope` is
|
|
26
|
+
* the ref as the wire sends it, already encoded, so a slash or any other
|
|
27
|
+
* byte never starts another folder.
|
|
29
28
|
*/
|
|
30
|
-
export const blobName =
|
|
31
|
-
issuer
|
|
32
|
-
id: string,
|
|
33
|
-
scope: string,
|
|
34
|
-
hash: string,
|
|
35
|
-
): Promise<string> => `${await hashPart(issuer)}/${await hashPart(id)}/${scope}/${hash}`
|
|
29
|
+
export const blobName = (issuer: string, repository: string, scope: string, hash: string): string =>
|
|
30
|
+
`${encodeURIComponent(issuer)}/${encodeURIComponent(repository)}/${scope}/${hash}`
|
package/src/worker.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
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
|
|
2
|
+
// `wrangler.jsonc` deploys it; ALLOW, AUDIENCE, EXPIRY_DAYS and the limits are vars in
|
|
3
3
|
// it (README.md § On Cloudflare Workers).
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type Authorize,
|
|
7
|
+
cachedFetchJson,
|
|
8
|
+
errorJson,
|
|
9
|
+
type KeysCache,
|
|
10
|
+
oidcAuth,
|
|
11
|
+
parseAllow,
|
|
12
|
+
Refusal,
|
|
13
|
+
} from '@vzn/vx-devtools'
|
|
6
14
|
import { createCacheServer, DEFAULT_LIMITS, type Limits, limitsFrom } from './server.js'
|
|
7
15
|
import type { Blobs } from './store.js'
|
|
8
16
|
|
|
9
17
|
/** The parts of the Workers runtime this file uses, so it needs no types package. */
|
|
10
18
|
export interface R2Bucket {
|
|
11
19
|
put(name: string, body: ReadableStream<Uint8Array>): Promise<unknown>
|
|
12
|
-
get(
|
|
13
|
-
|
|
20
|
+
get(
|
|
21
|
+
name: string,
|
|
22
|
+
): Promise<{ body: ReadableStream<Uint8Array>; size: number; uploaded: Date } | null>
|
|
23
|
+
head(name: string): Promise<{ size: number; uploaded: Date } | null>
|
|
14
24
|
}
|
|
15
25
|
/** The data center's cache (Workers Cache API): free, and per data center. */
|
|
16
26
|
interface WorkersCache {
|
|
@@ -18,7 +28,7 @@ interface WorkersCache {
|
|
|
18
28
|
put(key: Request, res: Response): Promise<void>
|
|
19
29
|
delete(key: Request): Promise<boolean>
|
|
20
30
|
}
|
|
21
|
-
declare const caches: { open(name: string): Promise<WorkersCache
|
|
31
|
+
declare const caches: { open(name: string): Promise<WorkersCache>; default: KeysCache }
|
|
22
32
|
export interface Context {
|
|
23
33
|
waitUntil(p: Promise<unknown>): void
|
|
24
34
|
}
|
|
@@ -38,21 +48,45 @@ export interface Env {
|
|
|
38
48
|
/** A string, or a number as wrangler.jsonc may write it. */
|
|
39
49
|
readonly MAX_CHECK_HASHES?: string | number
|
|
40
50
|
readonly MAX_UPLOAD_BYTES?: string | number
|
|
51
|
+
/** The bucket's lifecycle rule in days; set, "held" is kept that long from each upload. */
|
|
52
|
+
readonly EXPIRY_DAYS?: string | number
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
/** An object's key in the data center's cache. */
|
|
44
56
|
const keyOf = (name: string): Request => new Request(`https://vx-cache.blobs/${name}`)
|
|
57
|
+
/** The key of the mark that R2 holds an object, and its size. */
|
|
58
|
+
const heldOf = (name: string): Request => new Request(`https://vx-cache.held/${name}`)
|
|
59
|
+
const SIZE = 'vx-size'
|
|
45
60
|
|
|
46
61
|
/**
|
|
47
62
|
* R2, read through the data center's cache: an artifact never changes
|
|
48
63
|
* under its name while R2 holds it, so a copy needs no expiry of its own;
|
|
49
64
|
* an upload drops the copy, of bytes R2 has since let go. The server has
|
|
50
65
|
* checked the token and named the object before a read reaches here.
|
|
66
|
+
*
|
|
67
|
+
* With `expiryDays`, the bucket's lifecycle rule, whether R2 holds an
|
|
68
|
+
* object is kept there too, once R2 takes, heads or serves it, until the
|
|
69
|
+
* 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.
|
|
51
72
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
export function r2Blobs(
|
|
74
|
+
bucket: R2Bucket,
|
|
75
|
+
cache: Promise<WorkersCache>,
|
|
76
|
+
ctx: Context,
|
|
77
|
+
expiryDays?: number,
|
|
78
|
+
): Blobs {
|
|
79
|
+
const mark = (name: string, size: number, uploaded: number): void => {
|
|
80
|
+
if (expiryDays === undefined) return
|
|
81
|
+
const left = Math.floor((uploaded + expiryDays * 86_400_000 - Date.now()) / 1000)
|
|
82
|
+
if (left <= 0) return
|
|
83
|
+
const headers = { [SIZE]: String(size), 'cache-control': `max-age=${left}` }
|
|
84
|
+
ctx.waitUntil(cache.then((c) => c.put(heldOf(name), new Response(null, { headers }))))
|
|
85
|
+
}
|
|
54
86
|
return {
|
|
55
87
|
async put(name, body, size) {
|
|
88
|
+
// Before R2's own upload time, so the mark ends no later than R2's copy.
|
|
89
|
+
const started = Date.now()
|
|
56
90
|
// R2 takes a stream of known length; this one errors at any other.
|
|
57
91
|
const fixed = new FixedLengthStream(size)
|
|
58
92
|
const piped = body.pipeTo(fixed.writable)
|
|
@@ -70,13 +104,16 @@ export function r2Blobs(bucket: R2Bucket, cache: Promise<WorkersCache>, ctx: Con
|
|
|
70
104
|
// R2 takes a name only once it holds none: a copy here is of bytes
|
|
71
105
|
// R2 expired, a corrupt one too, and outlived the new ones a year.
|
|
72
106
|
await (await cache).delete(keyOf(name))
|
|
107
|
+
mark(name, size, started)
|
|
73
108
|
},
|
|
74
109
|
get: async (name) => {
|
|
75
110
|
const key = keyOf(name)
|
|
76
111
|
const hit = await (await cache).match(key)
|
|
112
|
+
// No mark from this copy: it can outlive R2's.
|
|
77
113
|
if (hit?.body) return { body: hit.body, size: Number(hit.headers.get('content-length')) }
|
|
78
114
|
const o = await bucket.get(name)
|
|
79
115
|
if (o === null) return null
|
|
116
|
+
mark(name, o.size, o.uploaded.getTime())
|
|
80
117
|
// The copy is R2 read again, never a tee of the client's body: a tee
|
|
81
118
|
// on Workers reads at its slower branch's pace, and the cache write
|
|
82
119
|
// held a download for 19 minutes (main, 2026-10-28).
|
|
@@ -93,12 +130,21 @@ export function r2Blobs(bucket: R2Bucket, cache: Promise<WorkersCache>, ctx: Con
|
|
|
93
130
|
)
|
|
94
131
|
return { body: o.body, size: o.size }
|
|
95
132
|
},
|
|
96
|
-
// R2
|
|
97
|
-
//
|
|
98
|
-
// a writer told "held" there
|
|
99
|
-
//
|
|
100
|
-
// run's read, a 404).
|
|
101
|
-
size: async (name) =>
|
|
133
|
+
// R2 answers whether it holds an object (a check, a HEAD and a PUT's
|
|
134
|
+
// skip), or a mark it made: never the data center's copy of the bytes,
|
|
135
|
+
// which outlives R2's (expiry, a wipe), so a writer told "held" there
|
|
136
|
+
// skipped its upload and a reader in another data center found nothing
|
|
137
|
+
// (2026-10-27: a DTE worker's save, then the run's read, a 404).
|
|
138
|
+
size: async (name) => {
|
|
139
|
+
if (expiryDays !== undefined) {
|
|
140
|
+
const held = await (await cache).match(heldOf(name))
|
|
141
|
+
if (held !== undefined) return Number(held.headers.get(SIZE))
|
|
142
|
+
}
|
|
143
|
+
const o = await bucket.head(name)
|
|
144
|
+
if (o === null) return null
|
|
145
|
+
mark(name, o.size, o.uploaded.getTime())
|
|
146
|
+
return o.size
|
|
147
|
+
},
|
|
102
148
|
}
|
|
103
149
|
}
|
|
104
150
|
|
|
@@ -121,8 +167,34 @@ function workerLimits(env: Env): Limits {
|
|
|
121
167
|
return { ...DEFAULT_LIMITS, maxCheckHashes: CHECK_LOOKUPS, ...limitsFrom(env) }
|
|
122
168
|
}
|
|
123
169
|
|
|
170
|
+
/** EXPIRY_DAYS: unset, nothing is kept of R2's answer. */
|
|
171
|
+
function expiryOf(env: Env): number | undefined {
|
|
172
|
+
const v = env.EXPIRY_DAYS
|
|
173
|
+
if (v === undefined || v === '') return undefined
|
|
174
|
+
const n =
|
|
175
|
+
typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : Number.NaN
|
|
176
|
+
if (!(Number.isSafeInteger(n) && n > 0)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`EXPIRY_DAYS: the bucket's lifecycle rule, whole days above 0, not ${JSON.stringify(v)}`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
return n
|
|
182
|
+
}
|
|
183
|
+
|
|
124
184
|
/** What a request shares per isolate and settings. */
|
|
125
|
-
let made:
|
|
185
|
+
let made:
|
|
186
|
+
| { key: string; authorize: Authorize; limits: Limits; expiryDays: number | undefined }
|
|
187
|
+
| undefined
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The issuer's keys through the data center's cache: a fresh isolate read
|
|
191
|
+
* GitHub on its first token, and one slow answer failed it 502 (2026-10-29).
|
|
192
|
+
* `caches.default` is read at each call, as the bucket is per request.
|
|
193
|
+
*/
|
|
194
|
+
const edgeKeys = cachedFetchJson({
|
|
195
|
+
match: (url) => caches.default.match(url),
|
|
196
|
+
put: (url, res) => caches.default.put(url, res),
|
|
197
|
+
})
|
|
126
198
|
|
|
127
199
|
export default {
|
|
128
200
|
async fetch(req: Request, env: Env, ctx: Context): Promise<Response> {
|
|
@@ -136,6 +208,7 @@ export default {
|
|
|
136
208
|
env.READ_ACCESS,
|
|
137
209
|
env.MAX_CHECK_HASHES,
|
|
138
210
|
env.MAX_UPLOAD_BYTES,
|
|
211
|
+
env.EXPIRY_DAYS,
|
|
139
212
|
].join('\0')
|
|
140
213
|
if (made?.key !== key) {
|
|
141
214
|
let allow
|
|
@@ -157,11 +230,14 @@ export default {
|
|
|
157
230
|
return errorJson(500, `ALLOW: ${(err as Error).message}`)
|
|
158
231
|
}
|
|
159
232
|
let authorize
|
|
233
|
+
let expiryDays
|
|
160
234
|
try {
|
|
161
235
|
limits = workerLimits(env)
|
|
236
|
+
expiryDays = expiryOf(env)
|
|
162
237
|
authorize = oidcAuth({
|
|
163
238
|
allow,
|
|
164
239
|
readAccess: env.READ_ACCESS,
|
|
240
|
+
fetchJson: edgeKeys,
|
|
165
241
|
// jose matches aud exactly: a pasted newline refused every
|
|
166
242
|
// token as the wrong audience.
|
|
167
243
|
...(env.AUDIENCE ? { audience: env.AUDIENCE.trim() } : {}),
|
|
@@ -169,11 +245,11 @@ export default {
|
|
|
169
245
|
} catch (err) {
|
|
170
246
|
return errorJson(500, (err as Error).message)
|
|
171
247
|
}
|
|
172
|
-
made = { key, authorize, limits }
|
|
248
|
+
made = { key, authorize, limits, expiryDays }
|
|
173
249
|
}
|
|
174
250
|
// The bucket and cache are this request's: a Worker's I/O objects do not outlive it.
|
|
175
251
|
return createCacheServer({
|
|
176
|
-
blobs: r2Blobs(env.BLOBS, caches.open('vx-cache'), ctx),
|
|
252
|
+
blobs: r2Blobs(env.BLOBS, caches.open('vx-cache'), ctx, made.expiryDays),
|
|
177
253
|
authorize: made.authorize,
|
|
178
254
|
limits: made.limits,
|
|
179
255
|
})(req)
|
package/wrangler.jsonc
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// refused with "set ALLOW". AUDIENCE: the aud a
|
|
11
11
|
// CI token names, unset the Worker's own origin. MAX_CHECK_HASHES
|
|
12
12
|
// and MAX_UPLOAD_BYTES set the limits, a string or a number (PROTOCOL.md).
|
|
13
|
+
// EXPIRY_DAYS: the bucket's lifecycle rule in days, so a repeat check
|
|
14
|
+
// skips R2 (README.md § On Cloudflare Workers); never above the rule.
|
|
13
15
|
"vars": { "ALLOW": "" },
|
|
14
16
|
// Expiry is the bucket's lifecycle rules (README.md § Expiry).
|
|
15
17
|
"r2_buckets": [{ "binding": "BLOBS", "bucket_name": "vx-cache" }],
|