@vzn/vx-cache-server 0.0.651 → 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 +17 -9
- package/README.md +14 -6
- package/package.json +2 -2
- package/src/bun.ts +4 -2
- package/src/server.ts +48 -19
- package/src/store.ts +6 -2
- package/src/worker.ts +49 -13
- package/wrangler.jsonc +4 -0
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
125
|
-
|
|
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 }
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
|
71
|
-
* kept "missing" would hide an upload
|
|
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)
|
|
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
|
|
123
|
-
if (
|
|
150
|
+
const copy = await again(() => bucket.get(name))
|
|
151
|
+
if (copy === null) return
|
|
124
152
|
const headers = {
|
|
125
|
-
'content-length': String(
|
|
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(
|
|
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
|
-
|
|
139
|
-
|
|
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
|
}
|