@vzn/vx-cache 0.0.650 → 0.0.652
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/README.md +7 -6
- package/package.json +2 -2
- package/src/index.ts +73 -26
package/README.md
CHANGED
|
@@ -31,7 +31,8 @@ export default {
|
|
|
31
31
|
|
|
32
32
|
- `url` is the server's base URL (`https://cache.example.com`, without `/v1`), or `VX_CACHE_URL`.
|
|
33
33
|
It must be `https://`: every request carries the CI token, so `http://`
|
|
34
|
-
is taken only to this machine (`127.0.0.1`, `::1`, `localhost`).
|
|
34
|
+
is taken only to this machine (`127.0.0.1`, `::1`, `localhost`). It
|
|
35
|
+
takes no `?query` or `#fragment`: each route is appended to it. The
|
|
35
36
|
option is checked when the config loads, `VX_CACHE_URL` when a run
|
|
36
37
|
opens the cache.
|
|
37
38
|
- `issuer` (the CI issuer's URL, as its tokens name it) and `repository`
|
|
@@ -83,7 +84,7 @@ workflow would carry a token that writes nowhere the run reads. A job
|
|
|
83
84
|
uses a lent token over its own and never lends one on, nor any once
|
|
84
85
|
its remote is off for the run.
|
|
85
86
|
|
|
86
|
-
An upload answered with a 5xx or a 429 is sent again, three tries in
|
|
87
|
+
An upload answered with a 5xx or a 429, or whose connection reset, is sent again, three tries in
|
|
87
88
|
all: a PUT names its hash, so a second one stores the same bytes.
|
|
88
89
|
|
|
89
90
|
A refused token is said once and turns the remote off for the run; a
|
|
@@ -91,13 +92,13 @@ refused write (a job that reads only) turns off writes alone. Any other
|
|
|
91
92
|
refusal is a warning naming the server's own words, and the task reads
|
|
92
93
|
as a miss.
|
|
93
94
|
|
|
94
|
-
Every read names scopes, each a ref's
|
|
95
|
-
plain). A check names the run's refs in order (its branch, then the
|
|
95
|
+
Every read names scopes, each a ref, percent-encoded. A check names the run's refs in order (its branch, then the
|
|
96
96
|
branch it merges into, then the default branch; on CI `github()` or
|
|
97
97
|
`gitlab()` from `@vzn/vx-ci` names them, so declare it too: without it
|
|
98
98
|
a run reads git's branch and the default branch only), and the first ref
|
|
99
|
-
holding a hash answers it; its download names that ref. A
|
|
100
|
-
|
|
99
|
+
holding a hash answers it; its download names that ref. A ref longer
|
|
100
|
+
than a scope may be (512 characters, percent-encoded) is said and not
|
|
101
|
+
read; the others are. A run that reads no ref says so and stays local. The run asks the server's limits once
|
|
101
102
|
(`/v1/capabilities`) and checks in chunks of its `maxCheckHashes`
|
|
102
103
|
lookups, all at once; a run reading more refs than one check takes
|
|
103
104
|
(Workers' default is 6) spreads each hash's scopes over several. It
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-cache",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.652",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "vxCache(): a vx cache plugin for a server that speaks the vx-cache protocol, such as @vzn/vx-cache-server.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"jose: the tests sign the CI tokens the server checks."
|
|
33
33
|
],
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@vzn/vx": "^0.0.
|
|
35
|
+
"@vzn/vx": "^0.0.652"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,8 @@ function expiryOf(jwt: string): number {
|
|
|
68
68
|
const LEND_MARGIN_MS = 5 * 60_000
|
|
69
69
|
/** Tries of GitHub's OIDC endpoint before the remote turns off (`askOnce`). */
|
|
70
70
|
const ASK_ATTEMPTS = 3
|
|
71
|
+
/** A connection that dropped, not a server that answered: an upload is sent again. */
|
|
72
|
+
const RESET = new Set(['ECONNRESET', 'EPIPE', 'ECONNREFUSED'])
|
|
71
73
|
/** Uploads open at once; more wait. */
|
|
72
74
|
const IN_FLIGHT = 4
|
|
73
75
|
|
|
@@ -103,9 +105,15 @@ function proxyHeaders(
|
|
|
103
105
|
return out
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
/**
|
|
107
|
-
const
|
|
108
|
-
new Bun.CryptoHasher('sha256').update(
|
|
108
|
+
/** An issuer or a repository on the wire: its SHA-256's first 128 bits in hex, never the value (2026-10-25). */
|
|
109
|
+
const hashOf = (text: string): string =>
|
|
110
|
+
new Bun.CryptoHasher('sha256').update(text).digest('hex').slice(0, 32)
|
|
111
|
+
|
|
112
|
+
/** A ref's scope on the wire: the ref, percent-encoded, its folder's name on the server (2026-11-03). */
|
|
113
|
+
const scopeOf = (ref: string): string => encodeURIComponent(ref)
|
|
114
|
+
|
|
115
|
+
/** The longest scope a server takes (PROTOCOL.md § Keys), so a folder's name fits a bucket's key. */
|
|
116
|
+
const SCOPE_MAX = 512
|
|
109
117
|
|
|
110
118
|
/**
|
|
111
119
|
* A body's first `max` bytes as text, the rest cancelled unread, and
|
|
@@ -140,8 +148,12 @@ async function jsonUpTo(res: Response, max: number, what: string): Promise<unkno
|
|
|
140
148
|
|
|
141
149
|
/** Capabilities and a CI token are a few hundred bytes: room to spare. */
|
|
142
150
|
const SMALL_ANSWER_BYTES = 64 * 1024
|
|
143
|
-
/**
|
|
144
|
-
|
|
151
|
+
/**
|
|
152
|
+
* A check's answer per hash asked: a hash (up to 128), a size and a scope
|
|
153
|
+
* (up to 512, a long branch's) in JSON are under 700 bytes. At 512 an
|
|
154
|
+
* answer under the longest scopes was refused as too long.
|
|
155
|
+
*/
|
|
156
|
+
const CHECK_BYTES_PER_HASH = 1024
|
|
145
157
|
|
|
146
158
|
/**
|
|
147
159
|
* `s` with each C0/C1 control and DEL written as `\xNN`: a server's words
|
|
@@ -406,8 +418,8 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
406
418
|
) {
|
|
407
419
|
this.endpoint = config.url
|
|
408
420
|
this.named = {
|
|
409
|
-
'vx-issuer':
|
|
410
|
-
'vx-repository':
|
|
421
|
+
'vx-issuer': hashOf(config.issuer),
|
|
422
|
+
'vx-repository': hashOf(config.repository),
|
|
411
423
|
}
|
|
412
424
|
}
|
|
413
425
|
|
|
@@ -800,18 +812,28 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
800
812
|
private async upload(item: { hash: string; body: Blob }): Promise<void | false> {
|
|
801
813
|
// Queued before the remote or its writes were turned off.
|
|
802
814
|
if (this.disabled || this.writesRefused) return false
|
|
803
|
-
// Sent again after a 5xx or a
|
|
804
|
-
// the server failing to read GitHub's keys once
|
|
805
|
-
// (2026-10-29). A PUT
|
|
815
|
+
// Sent again after a 5xx, a 429 or a reset connection, up to three tries,
|
|
816
|
+
// as the token's ask: the server failing to read GitHub's keys once, and
|
|
817
|
+
// one ECONNRESET, each lost a DTE worker's entry (2026-10-29). A PUT
|
|
818
|
+
// names its hash, so a second one stores the same bytes. A deadline is
|
|
819
|
+
// not retried: the upload already had its time.
|
|
806
820
|
for (let attempt = 1; ; attempt++) {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
821
|
+
let res: Response | undefined
|
|
822
|
+
try {
|
|
823
|
+
res = await this.request(`/v1/artifacts/${item.hash}`, {
|
|
824
|
+
method: 'PUT',
|
|
825
|
+
body: item.body,
|
|
826
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
827
|
+
timeoutMs: this.config.uploadTimeoutMs,
|
|
828
|
+
write: true,
|
|
829
|
+
paced: true,
|
|
830
|
+
})
|
|
831
|
+
} catch (err) {
|
|
832
|
+
if (!RESET.has((err as { code?: unknown }).code as string) || attempt === ASK_ATTEMPTS)
|
|
833
|
+
throw err
|
|
834
|
+
await this.wait(1000 * attempt)
|
|
835
|
+
continue
|
|
836
|
+
}
|
|
815
837
|
if (res === undefined) return false
|
|
816
838
|
if (res.ok) return
|
|
817
839
|
if ((res.status !== 429 && res.status < 500) || attempt === ASK_ATTEMPTS)
|
|
@@ -837,15 +859,22 @@ function namedOf(options: VxCacheOptions): { issuer: string; repository: string
|
|
|
837
859
|
/** The longest delay a timer holds. */
|
|
838
860
|
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
839
861
|
|
|
862
|
+
/** `url` as a refusal may print it: a `user:pass@` would reach the CI log. */
|
|
863
|
+
function shown(url: string): string {
|
|
864
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1***@')
|
|
865
|
+
}
|
|
866
|
+
|
|
840
867
|
/**
|
|
841
868
|
* https, or http to this machine: every request carries the CI token, and
|
|
842
869
|
* http:// to another host sent it in clear.
|
|
843
870
|
*/
|
|
844
871
|
function secure(url: string): boolean {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
872
|
+
// Parsed as the request will be: a scheme in any case, no url it cannot carry.
|
|
873
|
+
const u = URL.parse(url)
|
|
874
|
+
if (u === null || u.host === '') return false
|
|
875
|
+
if (u.protocol === 'https:') return true
|
|
876
|
+
if (u.protocol !== 'http:') return false
|
|
877
|
+
return u.hostname === '127.0.0.1' || u.hostname === '[::1]' || u.hostname === 'localhost'
|
|
849
878
|
}
|
|
850
879
|
|
|
851
880
|
/** Options over `VX_CACHE_URL`; `undefined`: no server named. */
|
|
@@ -860,11 +889,19 @@ export function resolveVxCacheConfig(
|
|
|
860
889
|
const from = options.url === undefined ? 'VX_CACHE_URL' : 'url'
|
|
861
890
|
if (!secure(url))
|
|
862
891
|
throw new UserError(
|
|
863
|
-
`vxCache(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${url}`,
|
|
892
|
+
`vxCache(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
|
|
893
|
+
)
|
|
894
|
+
// Each route is appended to the url: past a `?` or `#` it was query or
|
|
895
|
+
// fragment, and every request asked the server's root.
|
|
896
|
+
if (/[?#]/.test(url))
|
|
897
|
+
throw new UserError(
|
|
898
|
+
`vxCache(): ${from} takes no ?query or #fragment: each route is appended to it, got ${shown(url)}`,
|
|
864
899
|
)
|
|
865
900
|
// Each route is the url's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
|
|
866
901
|
if (url.endsWith('/v1'))
|
|
867
|
-
throw new UserError(
|
|
902
|
+
throw new UserError(
|
|
903
|
+
`vxCache(): ${from} is the server's base, without /v1: ${shown(url.slice(0, -3))}`,
|
|
904
|
+
)
|
|
868
905
|
return {
|
|
869
906
|
url,
|
|
870
907
|
...named,
|
|
@@ -931,14 +968,24 @@ export function vxCache(options: VxCacheOptions): VxPlugin {
|
|
|
931
968
|
? '[vx] vx-cache: this job gets no OIDC token (give it `permissions: id-token: write`): it reads only'
|
|
932
969
|
: '[vx] vx-cache: no CI token for the cache server (name an id_tokens entry VX_CACHE_TOKEN): it reads only',
|
|
933
970
|
)
|
|
934
|
-
|
|
971
|
+
// A scope the server cannot name was a 400 on every check, and the
|
|
972
|
+
// run read nothing, its base branch's artifacts too.
|
|
973
|
+
const refs = [...new Set(ctx.refs)].filter((ref) => {
|
|
974
|
+
const length = scopeOf(ref).length
|
|
975
|
+
if (length <= SCOPE_MAX) return true
|
|
976
|
+
ctx.warn(
|
|
977
|
+
`[vx] vx-cache: ${ref} is ${length} characters percent-encoded, over the ${SCOPE_MAX} a cache folder's name takes: the run does not read it`,
|
|
978
|
+
)
|
|
979
|
+
return false
|
|
980
|
+
})
|
|
981
|
+
if (refs.length === 0) {
|
|
935
982
|
ctx.warn(
|
|
936
983
|
'[vx] vx-cache: the run reads no ref, so it names no key; remote cache off for this run',
|
|
937
984
|
)
|
|
938
985
|
return undefined
|
|
939
986
|
}
|
|
940
987
|
const layer = new VxCacheLayer(config)
|
|
941
|
-
layer.refs =
|
|
988
|
+
layer.refs = refs
|
|
942
989
|
layer.lent = ctx.lent
|
|
943
990
|
layer.warn = ctx.warn
|
|
944
991
|
return layer
|