@vzn/vx-migrate 0.0.595 → 0.0.597

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/src/paths.ts CHANGED
@@ -33,6 +33,9 @@ export function relPosix(from: string, to: string): string {
33
33
  export function packageScripts(meta: ProjectMeta): Record<string, unknown> {
34
34
  const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
35
35
  const scripts: Record<string, unknown> = Object.create(null)
36
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return scripts
36
+ if (!isRecord(raw)) return scripts
37
37
  return Object.assign(scripts, raw)
38
38
  }
39
+
40
+ export const isRecord = (v: unknown): v is Record<string, unknown> =>
41
+ typeof v === 'object' && v !== null && !Array.isArray(v)
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Turbo's outage breaker (`turborepo-cache/src/outage_breaker.rs`): three
3
+ * outages in a row — no connection, no answer in time, or a 5xx once the
4
+ * retries are spent — open it for 30 s, and while open no request is sent.
5
+ * After the cooldown one request probes; its success closes the breaker,
6
+ * its outage opens it again. Without it a server that hung cost every
7
+ * task its full deadline. A result that lands after the breaker moved on
8
+ * (a request already in flight when it opened) changes nothing. Shared by
9
+ * `turboCache()` and `nxCache()`.
10
+ */
11
+ export class OutageBreaker {
12
+ private failures = 0
13
+ private openUntil: number | undefined
14
+ private probing = false
15
+ private generation = 0
16
+ constructor(private readonly now: () => number = Date.now) {}
17
+
18
+ async send(request: () => Promise<Response>): Promise<Response> {
19
+ let probe = false
20
+ if (this.openUntil !== undefined) {
21
+ if (this.now() < this.openUntil || this.probing)
22
+ throw new Error(
23
+ `unavailable after ${FAILURE_THRESHOLD} failures in a row; not asked again for ${COOLDOWN_MS / 1000} s`,
24
+ )
25
+ this.probing = probe = true
26
+ }
27
+ const generation = this.generation
28
+ let res: Response
29
+ try {
30
+ res = await request()
31
+ } catch (err) {
32
+ this.finish(generation, probe, true)
33
+ throw err
34
+ }
35
+ this.finish(generation, probe, res.status >= 500)
36
+ return res
37
+ }
38
+
39
+ private finish(generation: number, probe: boolean, outage: boolean): void {
40
+ if (generation !== this.generation) return
41
+ if (outage) {
42
+ this.failures++
43
+ if (probe || this.failures >= FAILURE_THRESHOLD) {
44
+ this.openUntil = this.now() + COOLDOWN_MS
45
+ this.probing = false
46
+ this.generation++
47
+ }
48
+ return
49
+ }
50
+ this.failures = 0
51
+ if (probe) {
52
+ this.openUntil = undefined
53
+ this.probing = false
54
+ this.generation++
55
+ }
56
+ }
57
+ }
58
+
59
+ const FAILURE_THRESHOLD = 3
60
+ const COOLDOWN_MS = 30_000
@@ -2,10 +2,18 @@
2
2
  * A request's deadline, named. The runtime's abort says "The operation
3
3
  * timed out." and nothing else; core's degrade line already names the
4
4
  * request, the artifact and the server, so the cause names the deadline
5
- * that was spent. Shared by `turboCache()` and `nxCache()`.
5
+ * that was spent. The name rides the signal's reason, not a catch around
6
+ * `fetch`: the deadline covers the body too, and a body read past it
7
+ * (core's ingest, the signed download's temp) rejects with the reason
8
+ * (measured on Bun 1.4.2), where a catch around `fetch` never saw it.
9
+ * Unref'd, as `AbortSignal.timeout`'s timer is. Shared by `turboCache()`
10
+ * and `nxCache()`.
6
11
  */
7
- export function deadlineNamed(err: unknown, timeoutMs: number): unknown {
8
- return (err as { name?: unknown } | null)?.name === 'TimeoutError'
9
- ? new Error(`no answer within ${timeoutMs} ms`)
10
- : err
12
+ export function deadline(timeoutMs: number): AbortSignal {
13
+ const controller = new AbortController()
14
+ setTimeout(
15
+ () => controller.abort(new Error(`no answer within ${timeoutMs} ms`)),
16
+ timeoutMs,
17
+ ).unref()
18
+ return controller.signal
11
19
  }
@@ -413,6 +413,8 @@ export function excludeWorkspaceOutputs(
413
413
  readonly dir: string
414
414
  readonly tasks: GeneratedTask[]
415
415
  }[],
416
+ /** The globs an opaque entry stands for (`vx-migrate`'s `...globalInputs` spread). */
417
+ expand: (entry: unknown) => readonly string[] = () => [],
416
418
  ): void {
417
419
  const positive = (v: unknown): string[] =>
418
420
  Array.isArray(v)
@@ -444,7 +446,7 @@ export function excludeWorkspaceOutputs(
444
446
  ?.inputs
445
447
  if (inputs === undefined || !Array.isArray(inputs.workspaceFiles)) continue
446
448
  const files = inputs.workspaceFiles as string[]
447
- const reads = positive(files)
449
+ const reads = positive(files.flatMap((f) => (typeof f === 'string' ? [f] : expand(f))))
448
450
  if (reads.length === 0) continue
449
451
  for (const i of candidates(reads)) {
450
452
  const w = writers[i]!
@@ -0,0 +1,36 @@
1
+ // The env names a package's tracked source spells: what stands in, in a
2
+ // written config, for a `*` env entry Turbo or Vite Task matched against
3
+ // the environment a run sees.
4
+
5
+ import path from 'node:path'
6
+ import { relPosix } from './paths.js'
7
+
8
+ /** Source and env-example files a framework build reads its variables from. */
9
+ const SPELLS_ENV =
10
+ /\.(c|m)?(j|t)sx?$|\.(vue|svelte|astro|html)$|(^|\/)\.env\.(example|sample|template)$/
11
+
12
+ /**
13
+ * The upper-case names the tracked source under `dirs` spells (`NEXT_PUBLIC_API`
14
+ * in `process.env.NEXT_PUBLIC_API` or an `.env.example`), sorted. Without
15
+ * git, none: the note still names the framework's prefix.
16
+ */
17
+ export async function spelledNames(
18
+ root: string,
19
+ dirs: readonly string[],
20
+ tracked: readonly string[] | null,
21
+ ): Promise<string[]> {
22
+ if (tracked === null) return []
23
+ const prefixes = dirs.map((dir) => {
24
+ const rel = relPosix(root, dir)
25
+ return rel === '' || rel === '.' ? '' : `${rel}/`
26
+ })
27
+ const names = new Set<string>()
28
+ for (const f of tracked) {
29
+ if (!SPELLS_ENV.test(f) || !prefixes.some((p) => f.startsWith(p))) continue
30
+ const text = await Bun.file(path.join(root, f))
31
+ .text()
32
+ .catch(() => '')
33
+ for (const m of text.matchAll(/\b[A-Z][A-Z0-9_]*_[A-Z0-9_]+\b/g)) names.add(m[0])
34
+ }
35
+ return [...names].sort()
36
+ }
@@ -10,6 +10,7 @@
10
10
  import { lstat, readFile } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
13
+ import { relPosix } from './paths.js'
13
14
 
14
15
  /** The files git tracks under `root`, root-relative; null outside a repo or without git. */
15
16
  export async function trackedFiles(root: string): Promise<string[] | null> {
@@ -220,7 +221,7 @@ export function spareTrackedOutputs(
220
221
  /** Drop the `!` entries readers carry for a committed file no output names any more. */
221
222
  const unhide = (owner: (typeof projects)[number] | null, rooted: string) => {
222
223
  for (const q of projects) {
223
- const qrel = path.relative(root, q.dir).split(path.sep).join('/')
224
+ const qrel = relPosix(root, q.dir)
224
225
  for (const r of q.tasks) {
225
226
  const inputs = (r.task?.['cache'] as { inputs?: Outputs } | undefined)?.inputs
226
227
  if (inputs === undefined) continue
@@ -238,7 +239,7 @@ export function spareTrackedOutputs(
238
239
  }
239
240
  }
240
241
  for (const p of projects) {
241
- const rel = path.relative(root, p.dir).split(path.sep).join('/')
242
+ const rel = relPosix(root, p.dir)
242
243
  const own = rel === '' ? tracked : filesUnder(tracked, rel).map((f) => f.slice(rel.length + 1))
243
244
  for (const t of p.tasks) {
244
245
  const outputs = (t.task?.['cache'] as { outputs?: Outputs } | undefined)?.outputs
@@ -28,7 +28,7 @@ import {
28
28
  wildcardOutput,
29
29
  wildcardTodo,
30
30
  } from '../shared-outputs.js'
31
- import { packageScripts, relPosix } from '../paths.js'
31
+ import { isRecord, packageScripts, relPosix } from '../paths.js'
32
32
  import { MAX_SPARED, type TrackedKinds } from '../tracked-outputs.js'
33
33
  import {
34
34
  DOTENV_PROBE,
@@ -65,6 +65,8 @@ interface TurboJson {
65
65
  envMode?: unknown
66
66
  }
67
67
 
68
+ const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
69
+
68
70
  /** A name core takes in `cache.inputs.env` and `exec.env.passThrough`. */
69
71
  const keyable = (name: string): boolean =>
70
72
  name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
@@ -437,9 +439,6 @@ async function readTurboJson(file: string, root: string): Promise<TurboJson> {
437
439
  return parsed as TurboJson
438
440
  }
439
441
 
440
- const isRecord = (v: unknown): v is Record<string, unknown> =>
441
- typeof v === 'object' && v !== null && !Array.isArray(v)
442
-
443
442
  const TOP_LISTS = [
444
443
  'globalDependencies',
445
444
  'globalEnv',
@@ -1164,14 +1163,19 @@ export async function mapTurboWorkspace(
1164
1163
  // One with edges of its own (with-shell-commands' `tooling-config#build`
1165
1164
  // → `prebuild`) was a group, which keys nothing: Turbo's node still
1166
1165
  // hashes the package's files, so it is key-only with its edges. A node
1167
- // that starts persistent sidecars stays a group.
1166
+ // that starts persistent sidecars stays a group. One a `^name` reaches
1167
+ // whose own edges lack `^name` (opencode's `build: { dependsOn: [] }`)
1168
+ // is key-only too: Turbo's `^build` stops at it, and core's walked past
1169
+ // it to the builds below, running and keying ones Turbo never waits on.
1168
1170
  const keyOnly = new Map<string, Set<string>>()
1169
1171
  for (const meta of metas) {
1170
1172
  const scripts = packageScripts(meta)
1171
1173
  const { defined, defFor } = definitions(meta)
1174
+ const depended = metas.some((m) => m !== meta && declares(m, meta.name))
1172
1175
  for (const name of defined) {
1173
1176
  const def = defFor(name)
1174
- if (!caretSelf.has(name) || !withScript.has(name) || transit.has(name)) continue
1177
+ const stops = depended && caretNames.has(name) && !(def?.dependsOn ?? []).includes(`^${name}`)
1178
+ if (!(caretSelf.has(name) || stops) || !withScript.has(name) || transit.has(name)) continue
1175
1179
  if (scripts[name] !== undefined || commandOverride(def) !== undefined) continue
1176
1180
  if (sidecarGroups.has(`${meta.name}#${name}`)) continue
1177
1181
  if (def?.cache === false || def?.persistent === true) continue
@@ -1291,7 +1295,13 @@ export async function mapTurboWorkspace(
1291
1295
  // Core gives a project with no `build` this very node (a group behind
1292
1296
  // `^build`, keyed on the project's files), so writing it is noise:
1293
1297
  // solid's three script-less packages each got a `build` running `true`.
1294
- if (noop && name === 'build' && (defFor(name)!.dependsOn ?? []).every((d) => d === '^build'))
1298
+ const noopEdges = defFor(name)!.dependsOn ?? []
1299
+ if (
1300
+ noop &&
1301
+ name === 'build' &&
1302
+ noopEdges.includes('^build') &&
1303
+ noopEdges.every((d) => d === '^build')
1304
+ )
1295
1305
  continue
1296
1306
  if (
1297
1307
  override === undefined &&
@@ -1488,7 +1498,14 @@ export async function mapTurboWorkspace(
1488
1498
  )
1489
1499
  literalEnvGapsOnce(projects, notes)
1490
1500
  resolveSharedWorkspaceOutputs(root, projects)
1491
- excludeWorkspaceOutputs(root, projects)
1501
+ // A written config spreads the preset (`...globalInputs`), which the
1502
+ // take-back pass must read as its globs: unread, opencode's configs
1503
+ // read `packages/plugin/**` with no `!packages/plugin/dist/**` and
1504
+ // core refused to load them.
1505
+ const opaque = new Map<string, readonly string[]>()
1506
+ for (const e of opts.splice('inputs', globals.inputs))
1507
+ if (typeof e !== 'string') opaque.set(JSON.stringify(e), globals.inputs)
1508
+ excludeWorkspaceOutputs(root, projects, (e) => opaque.get(JSON.stringify(e)) ?? [])
1492
1509
  pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
1493
1510
  pruneOrphanPersistentNotes(projects, opts.persistentTodo)
1494
1511
  return { projects, notes, globals }
@@ -1882,10 +1899,12 @@ function buildTask(
1882
1899
  spelled,
1883
1900
  )
1884
1901
 
1902
+ // A name that is no shell identifier never reached a task (`sh` drops it)
1903
+ // and core refuses it in passThrough.
1885
1904
  const passThrough = uniq(
1886
1905
  [...global('env'), ...global('pass'), ...envNames, ...passNames],
1887
1906
  hidden('env', 'pass'),
1888
- )
1907
+ ).filter((n) => typeof n !== 'string' || SHELL_NAME.test(n))
1889
1908
 
1890
1909
  const exec: Record<string, unknown> = { command }
1891
1910
  if (passThrough.length > 0) exec.env = { passThrough }
@@ -24,7 +24,8 @@ import {
24
24
  refuseUnknownOptions,
25
25
  type PluginOptionKinds,
26
26
  } from '@vzn/vx'
27
- import { deadlineNamed } from '../remote-deadline.js'
27
+ import { deadline } from '../remote-deadline.js'
28
+ import { OutageBreaker } from '../remote-breaker.js'
28
29
  import { withRetry } from '../remote-retry.js'
29
30
  import { headerValueFault } from '../remote-token.js'
30
31
 
@@ -55,6 +56,12 @@ export interface TurboCacheOptions {
55
56
  uploadTimeoutMs?: number
56
57
  /** Resends of a request answered 429 / 5xx or never connected (default 1, Turbo's); 0 turns them off. */
57
58
  retries?: number
59
+ /**
60
+ * Send Turbo's preflight (`OPTIONS`) before each artifact request and
61
+ * follow its `Location`, or `TURBO_PREFLIGHT` / turbo.json's
62
+ * `remoteCache.preflight`; default off.
63
+ */
64
+ preflight?: boolean
58
65
  }
59
66
 
60
67
  /** turbo.json's `remoteCache` block: the source below Turbo's environment, as in Turbo. */
@@ -66,6 +73,7 @@ export interface TurboJsonRemoteCache {
66
73
  enabled?: unknown
67
74
  timeout?: unknown
68
75
  uploadTimeout?: unknown
76
+ preflight?: unknown
69
77
  }
70
78
 
71
79
  /** `.turbo/config.json`'s fields, under any of the spellings Turbo accepts. */
@@ -85,6 +93,7 @@ export interface TurboCacheConfig {
85
93
  timeoutMs: number
86
94
  uploadTimeoutMs: number
87
95
  retries: number
96
+ preflight: boolean
88
97
  }
89
98
 
90
99
  /** Turbo's signature message prefix (`crates/turborepo-cache/src/signature_authentication.rs`). */
@@ -250,18 +259,14 @@ export function resolveTurboCacheConfig(
250
259
  fromFile(file.teamId)
251
260
  const teamSlug =
252
261
  options.teamSlug ?? env['TURBO_TEAM'] ?? fromFile(local.teamSlug) ?? fromFile(file.teamSlug)
262
+ const preflight =
263
+ options.preflight ??
264
+ truthy(env['TURBO_PREFLIGHT'], 'TURBO_PREFLIGHT') ??
265
+ file.preflight === true
253
266
  // Turbo signs only under `remoteCache.signature: true`; the env key alone
254
267
  // signs nothing there, and a short one here refused the whole cache.
255
268
  // `TURBO_SIGNATURE` (1/true, 0/false) sits above turbo.json, as in Turbo.
256
- const signEnv = env['TURBO_SIGNATURE']
257
- let signing = file.signature === true
258
- if (signEnv !== undefined && signEnv !== '') {
259
- if (!/^(1|0|true|false)$/.test(signEnv))
260
- throw new Error(
261
- `vx/turbo-cache: TURBO_SIGNATURE should be 1 or 0, got ${JSON.stringify(signEnv)}`,
262
- )
263
- signing = signEnv === '1' || signEnv === 'true'
264
- }
269
+ const signing = truthy(env['TURBO_SIGNATURE'], 'TURBO_SIGNATURE') ?? file.signature === true
265
270
  const signatureKey =
266
271
  options.signatureKey ?? (signing ? env['TURBO_REMOTE_CACHE_SIGNATURE_KEY'] : undefined)
267
272
  if (signatureKey !== undefined) {
@@ -303,9 +308,18 @@ export function resolveTurboCacheConfig(
303
308
  seconds(file.uploadTimeout, 'remoteCache.uploadTimeout') ??
304
309
  60_000,
305
310
  retries,
311
+ preflight,
306
312
  }
307
313
  }
308
314
 
315
+ /** Turbo's `truth_env_var`: 1/true, 0/false; unset or empty is `undefined`. */
316
+ function truthy(v: string | undefined, name: string): boolean | undefined {
317
+ if (v === undefined || v === '') return undefined
318
+ if (!/^(1|0|true|false)$/.test(v))
319
+ throw new Error(`vx/turbo-cache: ${name} should be 1 or 0, got ${JSON.stringify(v)}`)
320
+ return v === '1' || v === 'true'
321
+ }
322
+
309
323
  /**
310
324
  * Turbo's timeout, whole seconds, as ms; `undefined` when unset. A value
311
325
  * Turbo would refuse is refused here rather than read as some other wait.
@@ -365,6 +379,7 @@ const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
365
379
  */
366
380
  export class TurboRemoteCache implements RemoteCacheLayer {
367
381
  private disabled = false
382
+ private readonly breaker = new OutageBreaker()
368
383
  /**
369
384
  * A `403` on an upload is a read-only token or server
370
385
  * (turborepo-remote-cache's `READ_ONLY` and its JWTs without the write
@@ -396,14 +411,50 @@ export class TurboRemoteCache implements RemoteCacheLayer {
396
411
  return u.toString()
397
412
  }
398
413
 
399
- private headers(extra: Record<string, string> = {}): Record<string, string> {
414
+ private headers(extra: Record<string, string> = {}, auth = true): Record<string, string> {
400
415
  return {
401
- Authorization: `Bearer ${this.config.token}`,
416
+ ...(auth ? { Authorization: `Bearer ${this.config.token}` } : {}),
402
417
  'x-artifact-client-interactive': process.stdout.isTTY ? '1' : '0',
403
418
  ...extra,
404
419
  }
405
420
  }
406
421
 
422
+ /**
423
+ * Turbo's preflight: an `OPTIONS` to the request's URL names the method
424
+ * and headers to come; the answer's `Location` (relative to `apiUrl`) is
425
+ * where the request goes, as given — a signed storage URL signs its own
426
+ * query, so nothing is appended — and the token goes along only when
427
+ * `Access-Control-Allow-Headers` admits `Authorization` (or `*`), so it
428
+ * never reaches a third-party store. The answer's status is not read, as
429
+ * in Turbo.
430
+ */
431
+ private async preflight(
432
+ url: string,
433
+ method: string,
434
+ headers: Record<string, string>,
435
+ signal: () => AbortSignal | undefined,
436
+ ): Promise<{ url: string; auth: boolean }> {
437
+ const s = signal()
438
+ const res = await this.fetchImpl(url, {
439
+ method: 'OPTIONS',
440
+ headers: {
441
+ Authorization: `Bearer ${this.config.token}`,
442
+ 'Access-Control-Request-Method': method === 'HEAD' ? 'GET' : method,
443
+ 'Access-Control-Request-Headers': ['Authorization', ...Object.keys(headers)].join(', '),
444
+ },
445
+ ...(s === undefined ? {} : { signal: s }),
446
+ })
447
+ await res.body?.cancel()
448
+ const location = res.headers.get('location')
449
+ const allowed = res.headers.get('access-control-allow-headers') ?? ''
450
+ return {
451
+ url: location === null ? res.url || url : new URL(location, this.config.apiUrl).toString(),
452
+ auth:
453
+ allowed.trim() === '*' ||
454
+ allowed.split(',').some((h) => h.trim().toLowerCase() === 'authorization'),
455
+ }
456
+ }
457
+
407
458
  /**
408
459
  * `undefined` = the token was refused and the refusal is ALREADY
409
460
  * reported, so the caller degrades to its miss value in silence. Only the
@@ -416,20 +467,29 @@ export class TurboRemoteCache implements RemoteCacheLayer {
416
467
  init: { body?: Blob | string; headers?: Record<string, string>; timeoutMs?: number } = {},
417
468
  ): Promise<Response | undefined> {
418
469
  const timeoutMs = init.timeoutMs ?? this.config.timeoutMs
419
- const res = await withRetry(
420
- () =>
421
- this.fetchImpl(this.url(pathname), {
422
- method,
423
- headers: this.headers(init.headers),
424
- ...(init.body === undefined ? {} : { body: init.body }),
425
- // 0 is Turbo's "no deadline".
426
- ...(timeoutMs === 0 ? {} : { signal: AbortSignal.timeout(timeoutMs) }),
427
- }),
428
- this.config.retries,
429
- this.wait,
430
- ).catch((err: unknown) => {
431
- throw deadlineNamed(err, timeoutMs)
432
- })
470
+ // 0 is Turbo's "no deadline".
471
+ const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
472
+ // The batch query is never preflighted, as in Turbo.
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
+ ),
492
+ )
433
493
  if (method === 'PUT' && res.status === 403) {
434
494
  const first = !this.writesRefused
435
495
  this.writesRefused = true
@@ -551,7 +611,9 @@ export class TurboRemoteCache implements RemoteCacheLayer {
551
611
  timeoutMs: this.config.uploadTimeoutMs,
552
612
  })
553
613
  if (res === undefined) return
554
- if (res.status !== 200 && res.status !== 202) throw new Error(`HTTP ${res.status}`)
614
+ // Turbo's client takes any 2xx (`error_for_status`); a server's 201 or
615
+ // 204 stored the artifact and was counted a failed upload.
616
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
555
617
  }
556
618
  }
557
619
 
@@ -636,6 +698,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
636
698
  timeoutMs: 'number',
637
699
  uploadTimeoutMs: 'number',
638
700
  retries: 'number',
701
+ preflight: 'boolean',
639
702
  }
640
703
 
641
704
  /**