@vzn/vx-migrate 0.0.646 → 0.0.649

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.
@@ -17,9 +17,12 @@ import {
17
17
  type RemoteOpenContext,
18
18
  type VxPlugin,
19
19
  refuseUnknownOptions,
20
+ UserError,
20
21
  type PluginOptionKinds,
21
22
  } from '@vzn/vx'
22
- import { deadline } from '../remote-deadline.js'
23
+ import { deadline, MAX_TIMER_MS } from '../remote-deadline.js'
24
+ import { checkRemoteUrl } from '../remote-url.js'
25
+ import { refusal } from '../remote-refusal.js'
23
26
  import { OutageBreaker } from '../remote-breaker.js'
24
27
  import { Cooldown, withRetry } from '../remote-retry.js'
25
28
  import {
@@ -31,13 +34,13 @@ import {
31
34
 
32
35
  export interface NxCacheOptions {
33
36
  /** Base URL of the cache server, or `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. */
34
- server?: string
37
+ url?: string
35
38
  /**
36
39
  * Bearer token, or `NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN`, then the OS
37
40
  * keychain. `''` (option or variable) is a server that runs open: no
38
41
  * token, nothing asked.
39
42
  */
40
- accessToken?: string
43
+ token?: string
41
44
  /** Per-request deadline (default 30 s). */
42
45
  timeoutMs?: number
43
46
  /** Resends of a request answered 429 / 5xx or never connected (default 1); 0 turns them off. */
@@ -45,9 +48,9 @@ export interface NxCacheOptions {
45
48
  }
46
49
 
47
50
  export interface NxCacheConfig {
48
- server: string
51
+ url: string
49
52
  /** Undefined: the layer's `open` asks the OS keychain; `''`: an open server. */
50
- accessToken?: string
53
+ token?: string
51
54
  timeoutMs: number
52
55
  retries: number
53
56
  }
@@ -57,36 +60,22 @@ export function resolveNxCacheConfig(
57
60
  options: NxCacheOptions,
58
61
  env: Record<string, string | undefined> = Bun.env,
59
62
  ): NxCacheConfig | undefined {
60
- const server = (options.server ?? env['NX_SELF_HOSTED_REMOTE_CACHE_SERVER'])?.replace(/\/+$/, '')
61
- if (!server) return undefined
62
- // The URL is printed in every refusal line, so a `user:pass@` in it would
63
- // leak to the log. Credentials go in the access token.
64
- if (URL.canParse(server)) {
65
- const u = new URL(server)
66
- if (u.username !== '' || u.password !== '')
67
- throw new Error(
68
- 'vx/nx-cache: server carries credentials (user:pass@); pass them as the accessToken instead',
69
- )
70
- }
71
- const accessToken = options.accessToken ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
72
- const fault = accessToken === undefined ? null : headerValueFault(accessToken)
63
+ // Named by its source: the env's was refused as a `url` option.
64
+ const from =
65
+ options.url !== undefined ? 'nxCache() option "url"' : 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER'
66
+ const url = (options.url ?? env['NX_SELF_HOSTED_REMOTE_CACHE_SERVER'])?.replace(/\/+$/, '')
67
+ if (!url) return undefined
68
+ checkRemoteUrl(url, from)
69
+ const token = options.token ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
70
+ const fault = token === undefined ? null : headerValueFault(token)
73
71
  if (fault !== null)
74
72
  throw new Error(
75
- `vx/nx-cache: the access token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
73
+ `nxCache(): the access token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
76
74
  )
77
75
  const retries = options.retries ?? 1
78
- // A NaN never reaches the bound, and the request would be resent forever.
79
- if (!Number.isInteger(retries) || retries < 0)
80
- throw new Error(`vx/nx-cache: retries must be a whole number ≥ 0, got ${retries}`)
81
- // A zero timeout aborted every request as it started.
82
- const ms = options.timeoutMs
83
- if (ms !== undefined && !(typeof ms === 'number' && Number.isFinite(ms) && ms > 0))
84
- throw new Error(
85
- `vx/nx-cache: timeoutMs must be a positive number of ms, got ${JSON.stringify(ms)}`,
86
- )
87
76
  return {
88
- server,
89
- ...(accessToken !== undefined ? { accessToken } : {}),
77
+ url,
78
+ ...(token !== undefined ? { token } : {}),
90
79
  timeoutMs: options.timeoutMs ?? 30_000,
91
80
  retries,
92
81
  }
@@ -98,7 +87,7 @@ export function resolveNxCacheConfig(
98
87
  * the prefetch pass calls `get`) is a GET whose body is cancelled before
99
88
  * it answers, so no probe holds a connection. It kept the response for a
100
89
  * `get` of the same hash, which no caller makes. Bodies stream: `get` returns the `fetch` Response and `put`
101
- * sends the Blob core hands it. `put` treats `409` as success:
90
+ * sends the Blob core hands it. `put` treats any 2xx and `409` as success:
102
91
  * the record is immutable and content-addressed, so "already there" is the
103
92
  * outcome wanted. An auth failure (401/403) throws ONCE — core
104
93
  * reports it — and then turns the layer off for the rest of the process
@@ -129,11 +118,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
129
118
  private readonly fetchImpl: typeof fetch = fetch,
130
119
  private readonly wait: (ms: number) => Promise<void> = Bun.sleep,
131
120
  ) {
132
- this.endpoint = `${config.server}/v1/cache`
133
- this.token = config.accessToken
121
+ this.endpoint = `${config.url}/v1/cache`
122
+ this.token = config.token
134
123
  this.keychainRemote = {
135
124
  key: this.endpoint,
136
- label: remoteLabel(config.server, undefined),
125
+ label: remoteLabel(config.url, undefined),
137
126
  env: 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN',
138
127
  }
139
128
  }
@@ -171,7 +160,7 @@ export class NxRemoteCache implements RemoteCacheLayer {
171
160
  () =>
172
161
  withRetry(
173
162
  () =>
174
- this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
163
+ this.fetchImpl(`${this.config.url}/v1/cache/${hash}`, {
175
164
  method,
176
165
  headers,
177
166
  ...(body === undefined ? {} : { body }),
@@ -187,23 +176,25 @@ export class NxRemoteCache implements RemoteCacheLayer {
187
176
  const first = !this.writesRefused
188
177
  this.writesRefused = true
189
178
  if (!first) return undefined
179
+ // The status and the server's own words, as turboCache() says them.
190
180
  throw new Error(
191
- 'HTTP 403: access forbidden (a read-only token cannot write); uploads off for this run, reads go on',
181
+ `${(await refusal(res)).message}: access forbidden (a read-only token cannot write); uploads off for this run, reads go on`,
192
182
  )
193
183
  }
194
184
  if (res.status === 401 || res.status === 403) {
195
185
  const first = !this.disabled
196
186
  this.disabled = true
197
187
  if (!first) return undefined
188
+ const said = (await refusal(res)).message
198
189
  const why =
199
190
  res.status === 401
200
191
  ? 'missing or invalid token'
201
192
  : 'access forbidden (the token may not read)'
202
193
  if (this.forget === undefined)
203
- throw new Error(`HTTP ${res.status}: ${why}; remote cache off for this run`)
194
+ throw new Error(`${said}: ${why}; remote cache off for this run`)
204
195
  await this.forget()
205
196
  throw new Error(
206
- `HTTP ${res.status}: ${why}; removed it from the OS keychain, so the next run asks again; remote cache off for this run`,
197
+ `${said}: ${why}; removed it from the OS keychain, so the next run asks again; remote cache off for this run`,
207
198
  )
208
199
  }
209
200
  return res
@@ -220,9 +211,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
220
211
  const res = await this.request('GET', hash)
221
212
  if (res === undefined) return null
222
213
  if (res.status !== 200) {
223
- await res.body?.cancel()
224
- if (res.status === 404) return null
225
- throw new Error(`HTTP ${res.status}`)
214
+ if (res.status === 404) {
215
+ await res.body?.cancel()
216
+ return null
217
+ }
218
+ throw await refusal(res)
226
219
  }
227
220
  return res
228
221
  }
@@ -236,19 +229,21 @@ export class NxRemoteCache implements RemoteCacheLayer {
236
229
  }
237
230
 
238
231
  // Nx's record carries no duration.
239
- async put(hash: string, body: Blob): Promise<void> {
240
- if (this.disabled || this.writesRefused) return
232
+ async put(hash: string, body: Blob): Promise<void | false> {
233
+ // Nothing sent, so no upload is counted.
234
+ if (this.disabled || this.writesRefused) return false
241
235
  const res = await this.request('PUT', hash, body)
242
- if (res === undefined) return
243
- if (res.status === 200 || res.status === 202 || res.status === 409) return
244
- throw new Error(`HTTP ${res.status}`)
236
+ if (res === undefined) return false
237
+ // The spec answers 200; a store behind it may say 201 or 204.
238
+ if (res.ok || res.status === 409) return
239
+ throw await refusal(res)
245
240
  }
246
241
  }
247
242
 
248
243
  /** Each option `NxCacheOptions` names, with its kind: derived from the type, so the two cannot drift. */
249
244
  const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
250
- server: 'string',
251
- accessToken: 'string',
245
+ url: 'string',
246
+ token: 'string',
252
247
  timeoutMs: 'number',
253
248
  retries: 'number',
254
249
  }
@@ -257,13 +252,28 @@ const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
257
252
  * Declare in `.vx/workspace.ts`; the local store stays the floor beneath it:
258
253
  *
259
254
  * ```ts
260
- * plugins: [nxCache({ server: 'https://cache.example.com', accessToken: process.env.CACHE_TOKEN })]
255
+ * plugins: [nxCache({ url: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
261
256
  * ```
262
257
  *
263
- * Declines without a server, so it is safe to leave declared.
258
+ * Declines without a url, so it is safe to leave declared.
264
259
  */
265
260
  export function nxCache(options: NxCacheOptions = {}): VxPlugin {
266
261
  refuseUnknownOptions('nxCache()', options, NX_CACHE_KEYS)
262
+ // At the call: in the `cache` hook a laptop with no server named passed
263
+ // what failed the first CI run. A NaN never reaches the bound, and the
264
+ // request would be resent forever; a zero timeout, or one past the longest
265
+ // timer, aborted every request as it started.
266
+ const { retries, timeoutMs: ms } = options
267
+ if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
268
+ throw new UserError(`nxCache() option "retries" must be a whole number ≥ 0, got ${retries}`)
269
+ if (ms !== undefined && !(ms > 0 && ms <= MAX_TIMER_MS))
270
+ throw new UserError(
271
+ `nxCache() option "timeoutMs" must be a positive number of ms up to ${MAX_TIMER_MS}, got ${ms}`,
272
+ )
273
+ // At the call too, as vxCache() checks its url: in the hook alone, a verb
274
+ // that opens no cache (`vx show`) took a url every run then failed on.
275
+ const url = options.url?.replace(/\/+$/, '')
276
+ if (url) checkRemoteUrl(url, 'nxCache() option "url"')
267
277
  return definePlugin(import.meta, {
268
278
  cache(): RemoteCacheLayer | undefined {
269
279
  const config = resolveNxCacheConfig(options)
package/src/nx-exec.cjs CHANGED
@@ -133,7 +133,7 @@ async function loadGraph(nx, project) {
133
133
  }
134
134
  // A cache written before the project existed (one added since Nx last
135
135
  // ran) is stale: \`nx run\` computes the graph and finds it, so compute.
136
- if (graph === null || graph.nodes[project] === undefined) {
136
+ if (graph === null || !Object.hasOwn(graph.nodes, project)) {
137
137
  graph = unwrapGraph(await pg.createProjectGraphAsync({ exitOnError: false }))
138
138
  }
139
139
  if (graph === null) throw new Error('nx produced a project graph with no nodes')
@@ -168,7 +168,7 @@ async function main(argv) {
168
168
  loadTaskEnv(nx, process.env, args.dotenv, undefined)
169
169
 
170
170
  const { graph, pg } = await loadGraph(nx, args.project)
171
- const node = graph.nodes[args.project]
171
+ const node = Object.hasOwn(graph.nodes, args.project) ? graph.nodes[args.project] : undefined
172
172
  if (node === undefined) {
173
173
  process.stderr.write(
174
174
  `nx-exec: no project ${JSON.stringify(args.project)} in the Nx project graph\n`,
package/src/package-fs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // What packages.ts needs of the file system and of globs, as core has
2
2
  // them: core's copies are not on the façade, and these left core with the
3
- // package-manager discovery (M, 2026-10-14).
3
+ // package-manager discovery (2026-10-14).
4
4
 
5
5
  import { readlinkSync, realpathSync } from 'node:fs'
6
6
  import path from 'node:path'
@@ -123,6 +123,11 @@ export async function gitTop(dir: string): Promise<string | null> {
123
123
  if (await file.exists()) {
124
124
  const gitdir = await file.text().catch(() => '')
125
125
  if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
126
+ // A submodule's own worktree points there too, and is a top-level:
127
+ // git writes `commondir` in a worktree's gitdir alone.
128
+ const to = gitdir.replace(/^gitdir:\s*/, '').trim()
129
+ if (await Bun.file(`${to.startsWith('/') ? '' : `${d}/`}${to}/commondir`).exists())
130
+ return d || '/'
126
131
  }
127
132
  if (d === '') return null
128
133
  }
package/src/packages.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // Package-manager workspaces: the members `package.json#workspaces` or
2
2
  // `pnpm-workspace.yaml` list, and the root a migration writes
3
- // `.vx/workspace.ts` at. Core reads neither (M, 2026-10-14): a project is a
3
+ // `.vx/workspace.ts` at. Core reads neither (2026-10-14): a project is a
4
4
  // directory holding a vx.config.
5
5
  import { constants, existsSync, type Dirent } from 'node:fs'
6
6
  import { access, readdir, realpath, stat } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
- import { PROJECT_CONFIG_FILENAMES, UserError } from '@vzn/vx'
8
+ import { PROJECT_CONFIG_FILENAMES, ui, UserError } from '@vzn/vx'
9
9
  import {
10
10
  BUN_GLOB_WILDCARDS,
11
11
  EXTGLOB,
@@ -75,7 +75,7 @@ const decoder = new TextDecoder()
75
75
  * Inside git the walk never goes above the git top-level ({@link gitTop}),
76
76
  * so a manifest above the checkout is never even read: a `package.json`
77
77
  * planted in a shared directory above it claimed it, and its workspace
78
- * config ran as you (owner, 2026-10-13: "find the root based on git").
78
+ * config ran as you (2026-10-13: "find the root based on git").
79
79
  *
80
80
  * Outside git the walk stops above `start` at a manifest another user
81
81
  * owns (root aside), the same case without a top-level to stop at
@@ -641,8 +641,9 @@ async function readManifest(root: string, dir: string, file: string): Promise<st
641
641
  () => false,
642
642
  )
643
643
  if (searchable) unreadable(err, file)
644
- process.stderr.write(
645
- `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it\n`,
644
+ ui.say(
645
+ 'warn',
646
+ `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it`,
646
647
  )
647
648
  return null
648
649
  }
@@ -768,8 +769,9 @@ export async function discoverPackages(
768
769
  shared.sort()
769
770
  const shown =
770
771
  shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
771
- process.stderr.write(
772
- `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}\n`,
772
+ ui.say(
773
+ 'warn',
774
+ `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}`,
773
775
  )
774
776
  }
775
777
 
@@ -1,11 +1,13 @@
1
1
  // What a mapping could not say, reported once by `vx lock`, the pass that
2
- // checks configs (a run checks nothing, M 2026-10-15). What this machine
2
+ // checks configs (a run checks nothing, 2026-10-15). What this machine
3
3
  // lacks to run the mapping (a bin, a graph export) is said on runs too. One line per DISTINCT gap, not one per task: n8n has a
4
4
  // `dev` and a `watch` in most of its 84 packages, and astro's `build`
5
5
  // carries the same `!vendor/**` output in every package — the per-task
6
6
  // form was a hundred identical lines before the first frame (2026-09-11).
7
7
  // Shared by `turbo()` and `nx()` since the second one existed.
8
8
 
9
+ import { taskNameProblem } from '@vzn/vx'
10
+
9
11
  /** What both mappers' projects have in common: a name and tasks with todos. */
10
12
  interface MappedProject {
11
13
  readonly name: string
@@ -39,25 +41,49 @@ export function collectGaps(
39
41
  return { notes, todos, live }
40
42
  }
41
43
 
42
- /** At `vx lock` every gap; on a run only the live notes. */
44
+ /**
45
+ * At `vx lock` every gap; on a run only the live notes. Each says `[vx]`
46
+ * first, as every plugin warning does, then the package's short name.
47
+ */
43
48
  export function warnGaps(
44
49
  warn: (line: string) => void,
45
50
  pluginName: string,
46
51
  gaps: Gaps,
47
52
  lock: boolean,
48
53
  ): void {
49
- for (const note of gaps.live) warn(`[${pluginName}] ${note}`)
54
+ const tag = `[vx] ${pluginName.replace(/^@vzn\//, '')}:`
55
+ for (const note of gaps.live) warn(`${tag} ${note}`)
50
56
  if (!lock) return
51
- for (const note of gaps.notes) warn(`[${pluginName}] ${note}`)
57
+ for (const note of gaps.notes) warn(`${tag} ${note}`)
52
58
  for (const [todo, ids] of gaps.todos) {
53
59
  if (ids.length === 1) {
54
- warn(`[${pluginName}] ${ids[0]}: ${todo}`)
60
+ warn(`${tag} ${ids[0]}: ${todo}`)
55
61
  continue
56
62
  }
57
63
  const names = [...new Set(ids.map((id) => id.slice(id.indexOf('#') + 1)))]
58
64
  const packages = new Set(ids.map((id) => id.slice(0, id.indexOf('#'))))
59
65
  warn(
60
- `[${pluginName}] ${ids.length} task(s) (${names.join(', ')} across ${packages.size} package(s)): ${todo}`,
66
+ `${tag} ${ids.length} tasks (${names.join(', ')} across ${packages.size} package${packages.size === 1 ? '' : 's'}): ${todo}`,
61
67
  )
62
68
  }
63
69
  }
70
+
71
+ /**
72
+ * A task whose name the schema refuses (`lint#fix`, `^up`) becomes a TODO,
73
+ * as the scripts migrator does (item 1033): written as a task, core
74
+ * refused the whole config on the next command.
75
+ */
76
+ export function refuseTaskNames(
77
+ projects: readonly { tasks: { name: string; todos: string[]; task: object | null }[] }[],
78
+ what: string,
79
+ ): void {
80
+ for (const p of projects)
81
+ for (const t of p.tasks) {
82
+ const why = t.task === null ? null : taskNameProblem(t.name)
83
+ if (why === null) continue
84
+ t.task = null
85
+ t.todos = [
86
+ `${what} ${JSON.stringify(t.name)} not migrated: its name ${why} — rename it, or add it by hand under another name`,
87
+ ]
88
+ }
89
+ }
@@ -1,6 +1,6 @@
1
1
  // The remote cache a Turbo or Nx repo already uses, as the repo shows it,
2
2
  // so the workspace file a migration writes keeps sharing it: native or
3
- // keep, from `vx init` or `bunx @vzn/vx-migrate` (M, 2026-10-14). Nx
3
+ // keep, from `vx init` or `bunx @vzn/vx-migrate` (2026-10-14). Nx
4
4
  // Cloud's wire is Nx's own, so it is named, never declared.
5
5
 
6
6
  import { readFileSync } from 'node:fs'
@@ -28,6 +28,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
28
28
  rc = (
29
29
  Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
30
30
  )?.remoteCache
31
+ // quiet: a local config file: none read is none declared
31
32
  } catch {
32
33
  continue
33
34
  }
@@ -42,6 +43,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
42
43
  let linked: unknown
43
44
  try {
44
45
  linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
46
+ // quiet: a local config file: none read is none linked
45
47
  } catch {}
46
48
  if (
47
49
  typeof linked === 'object' &&
@@ -63,6 +65,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
63
65
  let text: string
64
66
  try {
65
67
  text = readFileSync(path.join(root, f), 'utf8')
68
+ // quiet: a local config file: none read is none declared
66
69
  } catch {
67
70
  continue
68
71
  }
@@ -84,6 +87,7 @@ function usesNxCloud(root: string): boolean {
84
87
  } | null
85
88
  try {
86
89
  json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
90
+ // quiet: a local config file: none read is none declared
87
91
  } catch {
88
92
  return false
89
93
  }
@@ -11,9 +11,15 @@
11
11
  */
12
12
  export function deadline(timeoutMs: number): AbortSignal {
13
13
  const controller = new AbortController()
14
+ // A timer past 2^31-1 ms fires after 1 ms: a 30-day
15
+ // TURBO_REMOTE_CACHE_TIMEOUT aborted every request as it started. Turbo
16
+ // defines that value, so it is held here; an option is refused at the call.
14
17
  setTimeout(
15
18
  () => controller.abort(new Error(`no answer within ${timeoutMs} ms`)),
16
- timeoutMs,
19
+ Math.min(timeoutMs, MAX_TIMER_MS),
17
20
  ).unref()
18
21
  return controller.signal
19
22
  }
23
+
24
+ /** The longest delay a timer holds. */
25
+ export const MAX_TIMER_MS = 2 ** 31 - 1
@@ -0,0 +1,31 @@
1
+ /** How long the body's first chunk is waited for: a refusal is not held on a body that never comes. */
2
+ const BODY_WAIT_MS = 1_000
3
+
4
+ /**
5
+ * A remote's refusal, named by its status and its own words: "HTTP 500"
6
+ * alone said nothing, and a batch query Vercel refused with 400 "Too many
7
+ * hashes. Limit is 100." read as no answer and was never seen (main
8
+ * f71b377, 2026-10-23). One line of the body, bounded: it is remote text.
9
+ * Shared by `turboCache()` and `nxCache()`.
10
+ */
11
+ export async function refusal(res: Response, what = ''): Promise<Error> {
12
+ let said = ''
13
+ try {
14
+ const reader = res.body?.getReader()
15
+ if (reader !== undefined) {
16
+ let timer: ReturnType<typeof setTimeout> | undefined
17
+ const first = await Promise.race([
18
+ reader.read(),
19
+ new Promise<undefined>((r) => {
20
+ timer = setTimeout(() => r(undefined), BODY_WAIT_MS)
21
+ }),
22
+ ]).finally(() => clearTimeout(timer))
23
+ // quiet: read no further; a cancel that fails has nothing left to free
24
+ void reader.cancel().catch(() => undefined)
25
+ said = new TextDecoder().decode(first?.value?.subarray(0, 512)).replace(/\s+/g, ' ').trim()
26
+ }
27
+ } catch {
28
+ // quiet: the status alone still names the refusal
29
+ }
30
+ return new Error(`${what}HTTP ${res.status}${said === '' ? '' : `: ${said.slice(0, 200)}`}`)
31
+ }
@@ -24,7 +24,8 @@ export class Cooldown {
24
24
  * One request with Turbo's retry rule (`turborepo-api-client/src/retry.rs`):
25
25
  * a 5xx other than 501, or a request that never connected, is sent again
26
26
  * after 2 s, `retries` times. A 429 opens the remote's `cooldown` for its
27
- * `Retry-After` (capped at 10 s a wait, 2 s without one), and the request
27
+ * `Retry-After` (capped at 10 s a wait, 2 s without one or for a `0`
28
+ * after the first), and the request
28
29
  * is resent once it ends, as often as fits in 20 s from its first 429: a
29
30
  * limit longer than one capped wait lost every hit before (Growth's
30
31
  * parity run, 2026-10-16). A spent deadline is not retried, unlike
@@ -55,8 +56,12 @@ export async function withRetry(
55
56
  continue
56
57
  }
57
58
  if (res.status === 429) {
59
+ // `Retry-After: 0` resends at once, but only once in a row: each
60
+ // answered so sent again at once, hundreds a second for 20 s.
61
+ const asked = retryAfterMs(res)
62
+ const delay =
63
+ asked === undefined || (asked === 0 && firstLimit !== undefined) ? BACKOFF_MS : asked
58
64
  firstLimit ??= now()
59
- const delay = retryAfterMs(res) ?? BACKOFF_MS
60
65
  if (retries === 0 || now() + delay - firstLimit > RATE_LIMIT_BUDGET_MS) return res
61
66
  cooldown.extend(delay)
62
67
  await res.body?.cancel()
@@ -77,10 +82,10 @@ function retryable(status: number): boolean {
77
82
  return status >= 500 && status !== 501
78
83
  }
79
84
 
80
- /** A refused port or an unresolved host, as Bun's `fetch` codes them (measured on 1.4.2). */
85
+ /** A refused port or an unresolved host, as Bun's `fetch` codes them (1.4.4 took errno names). */
81
86
  function neverConnected(err: unknown): boolean {
82
87
  const code = (err as { code?: unknown } | null)?.code
83
- return code === 'ConnectionRefused' || code === 'ENOTFOUND'
88
+ return code === 'ECONNREFUSED' || code === 'ENOTFOUND'
84
89
  }
85
90
 
86
91
  /** `Retry-After` as seconds or an HTTP date, capped as Turbo caps it. */
@@ -0,0 +1,28 @@
1
+ import { UserError } from '@vzn/vx'
2
+
3
+ /**
4
+ * A remote cache's URL, refused as `from` names it (an option, a variable,
5
+ * a config file). Shared by `turboCache()` and `nxCache()`, at the call for
6
+ * the option and when the cache opens for every source.
7
+ *
8
+ * Plain http:// to any host is taken, where vxCache() takes it only to
9
+ * loopback: Turbo and Nx both send their token over it, and their
10
+ * self-hosted servers document such URLs (`TURBO_API=http://cache:3000`
11
+ * beside a CI service container; nx-cache-server's
12
+ * `NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://your-server:3000`). These
13
+ * plugins run such a repo unchanged.
14
+ */
15
+ export function checkRemoteUrl(url: string, from: string): void {
16
+ // With no scheme every request failed on its own, as a bare "Invalid URL".
17
+ if (!/^https?:\/\//.test(url))
18
+ throw new UserError(`${from} must be an http(s) URL, got ${JSON.stringify(url)}`)
19
+ // The URL is printed in every refusal line, so a `user:pass@` in it would
20
+ // leak to the log. Credentials go in the token.
21
+ if (URL.canParse(url)) {
22
+ const u = new URL(url)
23
+ if (u.username !== '' || u.password !== '')
24
+ throw new UserError(
25
+ `${from} carries credentials (user:pass@); pass them as the token instead`,
26
+ )
27
+ }
28
+ }