@vzn/vx-cache 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.
Files changed (3) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -2
  3. package/src/index.ts +63 -19
package/README.md CHANGED
@@ -83,18 +83,21 @@ workflow would carry a token that writes nowhere the run reads. A job
83
83
  uses a lent token over its own and never lends one on, nor any once
84
84
  its remote is off for the run.
85
85
 
86
+ An upload answered with a 5xx or a 429, or whose connection reset, is sent again, three tries in
87
+ all: a PUT names its hash, so a second one stores the same bytes.
88
+
86
89
  A refused token is said once and turns the remote off for the run; a
87
90
  refused write (a job that reads only) turns off writes alone. Any other
88
91
  refusal is a warning naming the server's own words, and the task reads
89
92
  as a miss.
90
93
 
91
- Every read names scopes, each a ref's hash (no ref goes on the wire
92
- plain). A check names the run's refs in order (its branch, then the
94
+ Every read names scopes, each a ref, percent-encoded. A check names the run's refs in order (its branch, then the
93
95
  branch it merges into, then the default branch; on CI `github()` or
94
96
  `gitlab()` from `@vzn/vx-ci` names them, so declare it too: without it
95
97
  a run reads git's branch and the default branch only), and the first ref
96
- holding a hash answers it; its download names that ref. A run that reads
97
- no ref says so and stays local. The run asks the server's limits once
98
+ holding a hash answers it; its download names that ref. A ref longer
99
+ than a scope may be (512 characters, percent-encoded) is said and not
100
+ read; the others are. A run that reads no ref says so and stays local. The run asks the server's limits once
98
101
  (`/v1/capabilities`) and checks in chunks of its `maxCheckHashes`
99
102
  lookups, all at once; a run reading more refs than one check takes
100
103
  (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.649",
3
+ "version": "0.0.651",
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.649"
35
+ "@vzn/vx": "^0.0.651"
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
- /** A ref's scope, an issuer or a repository on the wire: its SHA-256's first 128 bits in hex, never the value (2026-10-25). */
107
- const scopeOf = (ref: string): string =>
108
- new Bun.CryptoHasher('sha256').update(ref).digest('hex').slice(0, 32)
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
@@ -406,8 +414,8 @@ export class VxCacheLayer implements RemoteCacheLayer {
406
414
  ) {
407
415
  this.endpoint = config.url
408
416
  this.named = {
409
- 'vx-issuer': scopeOf(config.issuer),
410
- 'vx-repository': scopeOf(config.repository),
417
+ 'vx-issuer': hashOf(config.issuer),
418
+ 'vx-repository': hashOf(config.repository),
411
419
  }
412
420
  }
413
421
 
@@ -800,16 +808,35 @@ export class VxCacheLayer implements RemoteCacheLayer {
800
808
  private async upload(item: { hash: string; body: Blob }): Promise<void | false> {
801
809
  // Queued before the remote or its writes were turned off.
802
810
  if (this.disabled || this.writesRefused) return false
803
- const res = await this.request(`/v1/artifacts/${item.hash}`, {
804
- method: 'PUT',
805
- body: item.body,
806
- headers: { 'content-type': 'application/octet-stream' },
807
- timeoutMs: this.config.uploadTimeoutMs,
808
- write: true,
809
- paced: true,
810
- })
811
- if (res === undefined) return false
812
- if (!res.ok) throw await refusal(res)
811
+ // Sent again after a 5xx, a 429 or a reset connection, up to three tries,
812
+ // as the token's ask: the server failing to read GitHub's keys once, and
813
+ // one ECONNRESET, each lost a DTE worker's entry (2026-10-29). A PUT
814
+ // names its hash, so a second one stores the same bytes. A deadline is
815
+ // not retried: the upload already had its time.
816
+ for (let attempt = 1; ; attempt++) {
817
+ let res: Response | undefined
818
+ try {
819
+ res = await this.request(`/v1/artifacts/${item.hash}`, {
820
+ method: 'PUT',
821
+ body: item.body,
822
+ headers: { 'content-type': 'application/octet-stream' },
823
+ timeoutMs: this.config.uploadTimeoutMs,
824
+ write: true,
825
+ paced: true,
826
+ })
827
+ } catch (err) {
828
+ if (!RESET.has((err as { code?: unknown }).code as string) || attempt === ASK_ATTEMPTS)
829
+ throw err
830
+ await this.wait(1000 * attempt)
831
+ continue
832
+ }
833
+ if (res === undefined) return false
834
+ if (res.ok) return
835
+ if ((res.status !== 429 && res.status < 500) || attempt === ASK_ATTEMPTS)
836
+ throw await refusal(res)
837
+ await res.body?.cancel()
838
+ await this.wait(1000 * attempt)
839
+ }
813
840
  }
814
841
  }
815
842
 
@@ -828,6 +855,11 @@ function namedOf(options: VxCacheOptions): { issuer: string; repository: string
828
855
  /** The longest delay a timer holds. */
829
856
  const MAX_TIMER_MS = 2 ** 31 - 1
830
857
 
858
+ /** `url` as a refusal may print it: a `user:pass@` would reach the CI log. */
859
+ function shown(url: string): string {
860
+ return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1***@')
861
+ }
862
+
831
863
  /**
832
864
  * https, or http to this machine: every request carries the CI token, and
833
865
  * http:// to another host sent it in clear.
@@ -851,11 +883,13 @@ export function resolveVxCacheConfig(
851
883
  const from = options.url === undefined ? 'VX_CACHE_URL' : 'url'
852
884
  if (!secure(url))
853
885
  throw new UserError(
854
- `vxCache(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${url}`,
886
+ `vxCache(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
855
887
  )
856
888
  // Each route is the url's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
857
889
  if (url.endsWith('/v1'))
858
- throw new UserError(`vxCache(): ${from} is the server's base, without /v1: ${url.slice(0, -3)}`)
890
+ throw new UserError(
891
+ `vxCache(): ${from} is the server's base, without /v1: ${shown(url.slice(0, -3))}`,
892
+ )
859
893
  return {
860
894
  url,
861
895
  ...named,
@@ -922,14 +956,24 @@ export function vxCache(options: VxCacheOptions): VxPlugin {
922
956
  ? '[vx] vx-cache: this job gets no OIDC token (give it `permissions: id-token: write`): it reads only'
923
957
  : '[vx] vx-cache: no CI token for the cache server (name an id_tokens entry VX_CACHE_TOKEN): it reads only',
924
958
  )
925
- if (ctx.refs.length === 0) {
959
+ // A scope the server cannot name was a 400 on every check, and the
960
+ // run read nothing, its base branch's artifacts too.
961
+ const refs = [...new Set(ctx.refs)].filter((ref) => {
962
+ const length = scopeOf(ref).length
963
+ if (length <= SCOPE_MAX) return true
964
+ ctx.warn(
965
+ `[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`,
966
+ )
967
+ return false
968
+ })
969
+ if (refs.length === 0) {
926
970
  ctx.warn(
927
971
  '[vx] vx-cache: the run reads no ref, so it names no key; remote cache off for this run',
928
972
  )
929
973
  return undefined
930
974
  }
931
975
  const layer = new VxCacheLayer(config)
932
- layer.refs = [...new Set(ctx.refs)]
976
+ layer.refs = refs
933
977
  layer.lent = ctx.lent
934
978
  layer.warn = ctx.warn
935
979
  return layer