@vzn/vx-migrate 0.0.609 → 0.0.611

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 CHANGED
@@ -335,7 +335,7 @@ The Nx spec has no existence probe, so `has` (the `--dry` prediction; the prefet
335
335
  - A remote error degrades to a **miss** and a warning that names the request, the artifact and the server — `vx/turbo-cache: upload 32248a2a7c89e241 to https://cache.example.com/v8/artifacts failed: no answer within 60000 ms` — never the token. The run never fails because of the cache.
336
336
  - The same failure is said once per run: an unreachable server fails the probe, the download and the upload alike, and the run prints the first and, at its end, `vx/turbo-cache: 2 more requests failed the same way: Unable to connect. Is the computer able to access the url?` (core's `LayeredCache` does the counting, for every cache plugin).
337
337
  - A request answered `429` or `5xx` (not `501`), or one that never connected (a refused port, an unresolved host), is sent again after 2 s — a `429` after its `Retry-After`, capped at 10 s — as Turbo's client does. A spent deadline is not: it has already cost its wait.
338
- - Three outages in a row (no connection, no answer within the deadline, or a `5xx` once the resends are spent) open Turbo's outage breaker: no request is sent for 30 s, each lookup is a miss at once, then one request probes and its answer closes or reopens it. A hung server costs three deadlines, not one per task.
338
+ - Three outages in a row (no connection, no answer within the deadline, or a `5xx` once the resends are spent) open Turbo's outage breaker: no request is sent for 30 s, each lookup is a miss at once, then one request probes and its answer closes or reopens it. A hit is judged when its body ends, so a server that sends headers and then stalls the body counts the same way. A hung server costs three deadlines, not one per task.
339
339
  - A refused token (`401`/`403`) warns **once** and turns the layer off for the rest of the process (a `403` on an upload is a read-only token — Nx's spec, or turborepo-remote-cache's `READ_ONLY` and write-less JWTs: it turns off uploads alone, and reads go on) — including the requests already in flight when the refusal lands, which degrade in silence rather than repeating it (a six-project run printed five identical lines before 2026-09-20).
340
340
  - Policy (`--cache=remote:r`, …) is enforced by core's `LayeredCache`, which the plugins wrap — a read-only token pairs naturally with `remote:r`.
341
341
  - Each tool's own switches narrow that policy, never widen it, as they do for the tool: `TURBO_CACHE` (`--cache`'s syntax, an omitted source off) and `TURBO_REMOTE_CACHE_READ_ONLY` for `turboCache()`, `NX_SKIP_REMOTE_CACHE` / `NX_DISABLE_REMOTE_CACHE` (`true`) for `nxCache()`. A CI that keeps untrusted pull requests off the shared cache that way keeps vx off it too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.609",
3
+ "version": "0.0.611",
4
4
  "license": "MIT",
5
5
  "description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "@vzn/vx": "^0.0.609"
42
+ "@vzn/vx": "^0.0.611"
43
43
  },
44
44
  "repository": {
45
45
  "type": "git",
@@ -142,18 +142,20 @@ export class NxRemoteCache implements RemoteCacheLayer {
142
142
  // every hit read as a corrupt artifact (nx#33092).
143
143
  headers['Accept'] = 'application/octet-stream'
144
144
  }
145
- const res = await this.breaker.send(() =>
146
- withRetry(
147
- () =>
148
- this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
149
- method,
150
- headers,
151
- ...(body === undefined ? {} : { body }),
152
- signal: deadline(this.config.timeoutMs),
153
- }),
154
- this.config.retries,
155
- this.wait,
156
- ),
145
+ const res = await this.breaker.send(
146
+ () =>
147
+ withRetry(
148
+ () =>
149
+ this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
150
+ method,
151
+ headers,
152
+ ...(body === undefined ? {} : { body }),
153
+ signal: deadline(this.config.timeoutMs),
154
+ }),
155
+ this.config.retries,
156
+ this.wait,
157
+ ),
158
+ method === 'GET',
157
159
  )
158
160
  if (method === 'PUT' && res.status === 403) {
159
161
  const first = !this.writesRefused
@@ -184,8 +186,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
184
186
  if (this.disabled) return null
185
187
  const res = await this.request('GET', hash)
186
188
  if (res === undefined) return null
187
- if (res.status === 404) return null
188
- if (res.status !== 200) throw new Error(`HTTP ${res.status}`)
189
+ if (res.status !== 200) {
190
+ await res.body?.cancel()
191
+ if (res.status === 404) return null
192
+ throw new Error(`HTTP ${res.status}`)
193
+ }
189
194
  return res
190
195
  }
191
196
 
@@ -7,6 +7,13 @@
7
7
  * task its full deadline. A result that lands after the breaker moved on
8
8
  * (a request already in flight when it opened) changes nothing. Shared by
9
9
  * `turboCache()` and `nxCache()`.
10
+ *
11
+ * A hit's body (`settleOnBody`, a 200 with a body) is judged when the
12
+ * body ends, not when its headers land: a server that sent headers in
13
+ * time and then stalled the body was judged a success, reset the count,
14
+ * and cost every task its full deadline. The body's error is the outage;
15
+ * its end or a cancel is the success. A body nobody reads or cancels
16
+ * leaves its verdict open.
10
17
  */
11
18
  export class OutageBreaker {
12
19
  private failures = 0
@@ -15,7 +22,7 @@ export class OutageBreaker {
15
22
  private generation = 0
16
23
  constructor(private readonly now: () => number = Date.now) {}
17
24
 
18
- async send(request: () => Promise<Response>): Promise<Response> {
25
+ async send(request: () => Promise<Response>, settleOnBody = false): Promise<Response> {
19
26
  let probe = false
20
27
  if (this.openUntil !== undefined) {
21
28
  if (this.now() < this.openUntil || this.probing)
@@ -32,8 +39,32 @@ export class OutageBreaker {
32
39
  this.finish(generation, probe, true)
33
40
  throw err
34
41
  }
35
- this.finish(generation, probe, res.status >= 500)
36
- return res
42
+ if (!settleOnBody || res.status !== 200 || res.body === null) {
43
+ this.finish(generation, probe, res.status >= 500)
44
+ return res
45
+ }
46
+ const reader = res.body.getReader()
47
+ const settle = (outage: boolean) => this.finish(generation, probe, outage)
48
+ return new Response(
49
+ new ReadableStream<Uint8Array>({
50
+ async pull(controller) {
51
+ const chunk = await reader.read().catch((err: unknown) => {
52
+ settle(true)
53
+ controller.error(err)
54
+ })
55
+ if (chunk === undefined) return
56
+ if (chunk.done) {
57
+ settle(false)
58
+ controller.close()
59
+ } else controller.enqueue(chunk.value)
60
+ },
61
+ async cancel(reason) {
62
+ settle(false)
63
+ await reader.cancel(reason)
64
+ },
65
+ }),
66
+ { status: res.status, statusText: res.statusText, headers: res.headers },
67
+ )
37
68
  }
38
69
 
39
70
  private finish(generation: number, probe: boolean, outage: boolean): void {
@@ -471,24 +471,26 @@ export class TurboRemoteCache implements RemoteCacheLayer {
471
471
  const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
472
472
  // The batch query is never preflighted, as in Turbo.
473
473
  const preflight = this.config.preflight && method !== 'POST'
474
- const res = await this.breaker.send(() =>
475
- withRetry(
476
- async () => {
477
- const extra = init.headers ?? {}
478
- const to = preflight
479
- ? await this.preflight(this.url(pathname), method, extra, signal)
480
- : { url: this.url(pathname), auth: true }
481
- const s = signal()
482
- return this.fetchImpl(to.url, {
483
- method,
484
- headers: this.headers(extra, to.auth),
485
- ...(init.body === undefined ? {} : { body: init.body }),
486
- ...(s === undefined ? {} : { signal: s }),
487
- })
488
- },
489
- this.config.retries,
490
- this.wait,
491
- ),
474
+ const res = await this.breaker.send(
475
+ () =>
476
+ withRetry(
477
+ async () => {
478
+ const extra = init.headers ?? {}
479
+ const to = preflight
480
+ ? await this.preflight(this.url(pathname), method, extra, signal)
481
+ : { url: this.url(pathname), auth: true }
482
+ const s = signal()
483
+ return this.fetchImpl(to.url, {
484
+ method,
485
+ headers: this.headers(extra, to.auth),
486
+ ...(init.body === undefined ? {} : { body: init.body }),
487
+ ...(s === undefined ? {} : { signal: s }),
488
+ })
489
+ },
490
+ this.config.retries,
491
+ this.wait,
492
+ ),
493
+ method === 'GET',
492
494
  )
493
495
  if (method === 'PUT' && res.status === 403) {
494
496
  const first = !this.writesRefused
@@ -549,8 +551,11 @@ export class TurboRemoteCache implements RemoteCacheLayer {
549
551
  headers: { Accept: 'application/octet-stream' },
550
552
  })
551
553
  if (res === undefined) return null
552
- if (res.status === 404) return null
553
- if (res.status !== 200) throw new Error(`HTTP ${res.status}`)
554
+ if (res.status !== 200) {
555
+ await res.body?.cancel()
556
+ if (res.status === 404) return null
557
+ throw new Error(`HTTP ${res.status}`)
558
+ }
554
559
  const duration = Number(res.headers.get('x-artifact-duration'))
555
560
  const durationMs = Number.isFinite(duration) && duration > 0 ? duration : undefined
556
561
  if (this.key === undefined) return { body: res, durationMs }
@@ -574,7 +579,10 @@ export class TurboRemoteCache implements RemoteCacheLayer {
574
579
  try {
575
580
  const max = this.maxSignedBody
576
581
  const past = () => new Error(`the signed artifact runs past ${max} bytes — treated as a miss`)
577
- if (Number(res.headers.get('content-length') ?? 0) > max) throw past()
582
+ if (Number(res.headers.get('content-length') ?? 0) > max) {
583
+ await res.body?.cancel()
584
+ throw past()
585
+ }
578
586
  let n = 0
579
587
  const counted = res.body?.pipeThrough(
580
588
  new TransformStream<Uint8Array, Uint8Array>({