@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.
Files changed (3) hide show
  1. package/README.md +7 -6
  2. package/package.json +2 -2
  3. 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`). The
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 hash (no ref goes on the wire
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 run that reads
100
- no ref says so and stays local. The run asks the server's limits once
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.650",
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.650"
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
- /** 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
@@ -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
- /** A check's answer per hash asked: a hash, a size and a scope in JSON are under 150 bytes. */
144
- const CHECK_BYTES_PER_HASH = 512
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': scopeOf(config.issuer),
410
- 'vx-repository': scopeOf(config.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 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.
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
- 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
- })
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
- if (/^https:\/\/\S/.test(url)) return true
846
- if (!/^http:\/\/\S/.test(url)) return false
847
- const host = URL.parse(url)?.hostname
848
- return host === '127.0.0.1' || host === '[::1]' || host === 'localhost'
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(`vxCache(): ${from} is the server's base, without /v1: ${url.slice(0, -3)}`)
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
- if (ctx.refs.length === 0) {
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 = [...new Set(ctx.refs)]
988
+ layer.refs = refs
942
989
  layer.lent = ctx.lent
943
990
  layer.warn = ctx.warn
944
991
  return layer