@vzn/vx-cache 0.0.650 → 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 +5 -5
  2. package/package.json +2 -2
  3. package/src/index.ts +55 -20
package/README.md CHANGED
@@ -83,7 +83,7 @@ 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 is sent again, three tries in
86
+ An upload answered with a 5xx or a 429, or whose connection reset, is sent again, three tries in
87
87
  all: a PUT names its hash, so a second one stores the same bytes.
88
88
 
89
89
  A refused token is said once and turns the remote off for the run; a
@@ -91,13 +91,13 @@ refused write (a job that reads only) turns off writes alone. Any other
91
91
  refusal is a warning naming the server's own words, and the task reads
92
92
  as a miss.
93
93
 
94
- Every read names scopes, each a ref's hash (no ref goes on the wire
95
- 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
96
95
  branch it merges into, then the default branch; on CI `github()` or
97
96
  `gitlab()` from `@vzn/vx-ci` names them, so declare it too: without it
98
97
  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 run that reads
100
- 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
101
101
  (`/v1/capabilities`) and checks in chunks of its `maxCheckHashes`
102
102
  lookups, all at once; a run reading more refs than one check takes
103
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.650",
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.650"
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,18 +808,28 @@ 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
- // Sent again after a 5xx or a 429, up to three tries, as the token's ask:
804
- // the server failing to read GitHub's keys once lost a DTE worker's entry
805
- // (2026-10-29). A PUT names its hash, so a second one stores the same bytes.
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.
806
816
  for (let attempt = 1; ; attempt++) {
807
- const res = await this.request(`/v1/artifacts/${item.hash}`, {
808
- method: 'PUT',
809
- body: item.body,
810
- headers: { 'content-type': 'application/octet-stream' },
811
- timeoutMs: this.config.uploadTimeoutMs,
812
- write: true,
813
- paced: true,
814
- })
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
+ }
815
833
  if (res === undefined) return false
816
834
  if (res.ok) return
817
835
  if ((res.status !== 429 && res.status < 500) || attempt === ASK_ATTEMPTS)
@@ -837,6 +855,11 @@ function namedOf(options: VxCacheOptions): { issuer: string; repository: string
837
855
  /** The longest delay a timer holds. */
838
856
  const MAX_TIMER_MS = 2 ** 31 - 1
839
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
+
840
863
  /**
841
864
  * https, or http to this machine: every request carries the CI token, and
842
865
  * http:// to another host sent it in clear.
@@ -860,11 +883,13 @@ export function resolveVxCacheConfig(
860
883
  const from = options.url === undefined ? 'VX_CACHE_URL' : 'url'
861
884
  if (!secure(url))
862
885
  throw new UserError(
863
- `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)}`,
864
887
  )
865
888
  // Each route is the url's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
866
889
  if (url.endsWith('/v1'))
867
- 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
+ )
868
893
  return {
869
894
  url,
870
895
  ...named,
@@ -931,14 +956,24 @@ export function vxCache(options: VxCacheOptions): VxPlugin {
931
956
  ? '[vx] vx-cache: this job gets no OIDC token (give it `permissions: id-token: write`): it reads only'
932
957
  : '[vx] vx-cache: no CI token for the cache server (name an id_tokens entry VX_CACHE_TOKEN): it reads only',
933
958
  )
934
- 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) {
935
970
  ctx.warn(
936
971
  '[vx] vx-cache: the run reads no ref, so it names no key; remote cache off for this run',
937
972
  )
938
973
  return undefined
939
974
  }
940
975
  const layer = new VxCacheLayer(config)
941
- layer.refs = [...new Set(ctx.refs)]
976
+ layer.refs = refs
942
977
  layer.lent = ctx.lent
943
978
  layer.warn = ctx.warn
944
979
  return layer