@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,335 @@
1
+ // LayeredCache — composes the local cache with a remote HTTP cache.
2
+ //
3
+ // Read path: try local. On miss, try remote; on remote hit, ingest
4
+ // the artifact bytes into local so the next read is a local hit.
5
+ //
6
+ // Write path: write to local synchronously. Upload the local artifact
7
+ // to remote as a fire-and-forget background task; failures log a
8
+ // warning but never fail the user's run (the task already succeeded;
9
+ // the only loss is the remote cache entry).
10
+ //
11
+ // The local and remote layers share the SAME artifact format — the
12
+ // `<hash>.tar.zst` bytes ship across the wire verbatim. Metadata
13
+ // (taskId, command, durationMs) travels separately: the caller
14
+ // supplies it to `get()` via the `ctx` arg, and the remote layer
15
+ // surfaces `durationMs` from its response. No stage dirs, no
16
+ // meta.json, no tar.gz wrapping — the artifact is what it is.
17
+
18
+ import type { Cache } from './cache.js'
19
+ import type {
20
+ CacheEntry,
21
+ CacheGetContext,
22
+ CacheKeyInput,
23
+ CacheLayer,
24
+ CachePolicy,
25
+ CacheStats,
26
+ IngestMeta,
27
+ InvocationRecord,
28
+ OutputFileRow,
29
+ PruneOptions,
30
+ PruneResult,
31
+ RunRecord,
32
+ SaveArgs,
33
+ } from './cache.js'
34
+ import { FULL_CACHE_POLICY } from './cache.js'
35
+ import type { RemoteCache } from './remote-cache.js'
36
+
37
+ /**
38
+ * Cap on concurrent background PUTs. Keeps a burst of cache misses from
39
+ * opening one socket per task; excess uploads queue and drain FIFO.
40
+ */
41
+ const UPLOAD_CONCURRENCY = 4
42
+
43
+ export interface LayeredCacheOptions {
44
+ /** Called for remote-related errors that the layer suppresses. */
45
+ onRemoteError?: (err: Error) => void
46
+ /**
47
+ * The 4-axis read/write policy. The local slice (read/write) is
48
+ * already applied to the inner `Cache` by the caller; this layer reads
49
+ * `remoteRead` / `remoteWrite` to gate its OWN remote operations.
50
+ * Default: everything on.
51
+ */
52
+ policy?: CachePolicy
53
+ }
54
+
55
+ export class LayeredCache implements CacheLayer {
56
+ /**
57
+ * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
58
+ * through here, so a key probed concurrently by both resolves a
59
+ * SINGLE remote GET. Each promise resolves `true` iff the artifact
60
+ * was successfully ingested into local. Entries are retained for the
61
+ * run's lifetime: a settled `false` records "remote already had no
62
+ * such artifact (or it was corrupt)", which lets `get` skip a second
63
+ * lazy probe of the same dead hash. The map is bounded by the number
64
+ * of distinct task keys in a run — negligible.
65
+ */
66
+ private readonly inflight = new Map<string, Promise<boolean>>()
67
+
68
+ /**
69
+ * Hashes whose local artifact was materialized FROM the remote layer
70
+ * this run (by `prefetch` or `get`'s read-through). A later `get`
71
+ * finds them as a local hit, but the work was still saved by the
72
+ * remote cache — so we flip `source` to `'remote'` and the
73
+ * orchestrator reports `cache-hit-remote`. Without this, a prefetch
74
+ * followed by a `get` would mislabel a genuine remote hit as local.
75
+ */
76
+ private readonly remoteSourced = new Set<string>()
77
+
78
+ /**
79
+ * Background write-through uploads. `save()` returns after the local
80
+ * write + byte capture; the PUT runs here so a cache-miss task never
81
+ * holds its scheduler worker slot for the upload round-trip. Bounded
82
+ * at UPLOAD_CONCURRENCY; `run()` awaits `drainUploads()` before
83
+ * `cache.close()`.
84
+ */
85
+ private readonly uploadQueue: Array<() => Promise<void>> = []
86
+ private activeUploads = 0
87
+ private drainWaiters: Array<() => void> = []
88
+
89
+ private readonly policy: CachePolicy
90
+
91
+ constructor(
92
+ private readonly local: Cache,
93
+ private readonly remote: RemoteCache,
94
+ private readonly options: LayeredCacheOptions = {},
95
+ ) {
96
+ this.policy = options.policy ?? FULL_CACHE_POLICY
97
+ }
98
+
99
+ async key(input: CacheKeyInput): Promise<string> {
100
+ return await this.local.key(input)
101
+ }
102
+
103
+ async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
104
+ // No-op when remote reads are off — there's nothing to warm from.
105
+ if (!this.policy.remoteRead) return false
106
+ return await this.pullFromRemote(hash, ctx)
107
+ }
108
+
109
+ async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
110
+ const localHit = await this.local.get(hash, ctx)
111
+ if (localHit) {
112
+ // A prefetch this run may have materialized this entry FROM
113
+ // remote; the local row exists now, but the work was saved by
114
+ // the remote cache, so the provenance stays 'remote'.
115
+ return this.remoteSourced.has(hash) ? { ...localHit, source: 'remote' } : localHit
116
+ }
117
+
118
+ // Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
119
+ // real miss; never touch the remote layer.
120
+ if (!this.policy.remoteRead) return null
121
+
122
+ // A prefetch may already have probed this hash (resolved) or be
123
+ // mid-flight. Awaiting the shared promise guarantees AT MOST ONE
124
+ // remote GET per key: a settled `false` means remote had nothing
125
+ // (no second probe), and a settled/in-flight `true` means the
126
+ // artifact was/will-be ingested locally — re-read below.
127
+ const ingested = await this.pullFromRemote(hash, ctx)
128
+ if (!ingested) return null
129
+
130
+ // The artifact is now in local, but this *lookup* was a remote
131
+ // hit — flip the source so callers can distinguish "saved work
132
+ // via the remote cache" from "saved work via a prior local run".
133
+ const materialized = await this.local.get(hash, ctx)
134
+ return materialized ? { ...materialized, source: 'remote' } : null
135
+ }
136
+
137
+ // Existence probe: local first, then a remote HEAD — no body
138
+ // transfer, no ingest, so the plan path stays read-only even against
139
+ // a remote cache. Remote errors degrade to a miss (never throw).
140
+ async has(hash: string): Promise<'local' | 'remote' | null> {
141
+ if ((await this.local.has(hash)) === 'local') return 'local'
142
+ if (!this.policy.remoteRead) return null
143
+ try {
144
+ return (await this.remote.has(hash)) ? 'remote' : null
145
+ } catch (err) {
146
+ this.reportRemoteError(err)
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Single implementation of the remote read-through, shared by
153
+ * `prefetch` and `get`. Idempotent per hash via `inflight`: the first
154
+ * caller starts the GET + validate + ingest; concurrent and later
155
+ * callers await the same promise. Resolves `true` when the artifact
156
+ * ends up in local, `false` on a remote miss / error / corruption
157
+ * (degrades to a cache miss; the error is reported, never thrown).
158
+ */
159
+ private pullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
160
+ const existing = this.inflight.get(hash)
161
+ if (existing) return existing
162
+ const p = this.doPullFromRemote(hash, ctx)
163
+ this.inflight.set(hash, p)
164
+ return p
165
+ }
166
+
167
+ private async doPullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
168
+ let remoteResult
169
+ try {
170
+ remoteResult = await this.remote.get(hash)
171
+ } catch (err) {
172
+ this.reportRemoteError(err)
173
+ return false
174
+ }
175
+ if (!remoteResult) return false
176
+
177
+ // Ingest the remote bytes into local using the caller-supplied
178
+ // taskId/command plus the remote-reported durationMs. The remote
179
+ // layer carries durationMs as an HTTP header (x-artifact-duration);
180
+ // taskId + command come from the orchestrator's TaskNode in scope.
181
+ // Without `ctx`, we can't populate a meaningful entries row, so
182
+ // ingest with placeholders — caller-side typing nudges everyone
183
+ // toward passing ctx.
184
+ const meta: IngestMeta = {
185
+ taskId: ctx?.taskId ?? `${hash}#unknown`,
186
+ command: ctx?.command ?? '',
187
+ durationMs: remoteResult.durationMs ?? 0,
188
+ }
189
+ try {
190
+ await this.local.ingest(hash, new Uint8Array(remoteResult.body), meta)
191
+ } catch (err) {
192
+ // The bytes came off the network — a corrupt/truncated remote
193
+ // artifact must degrade to a cache miss (task re-executes), not
194
+ // crash the run. Local-layer reads outside this block still
195
+ // propagate: local corruption is a real fault, not a network one.
196
+ this.reportRemoteError(err)
197
+ return false
198
+ }
199
+ this.remoteSourced.add(hash)
200
+ return true
201
+ }
202
+
203
+ outputsPath(hash: string): string {
204
+ return this.local.outputsPath(hash)
205
+ }
206
+
207
+ async hashFile(filePath: string): Promise<string> {
208
+ return await this.local.hashFile(filePath)
209
+ }
210
+
211
+ async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
212
+ await this.local.restoreOutputs(hash, projectDir, workspaceRoot)
213
+ }
214
+
215
+ async save(args: SaveArgs): Promise<void> {
216
+ // Local write honors its own gate inside Cache.save (no-op when
217
+ // local writes are disabled).
218
+ await this.local.save(args)
219
+ if (!this.policy.remoteWrite) return
220
+ // Write-through upload, OFF the task's critical path. The bytes are
221
+ // captured NOW — read from the artifact the local layer just wrote
222
+ // (same format on both sides, no repacking), or, when local writes
223
+ // are disabled (`--cache=local:,remote:rw`), packed in memory while
224
+ // the output files are guaranteed still on disk. The PUT itself then
225
+ // runs in the bounded background pool so the task's worker slot is
226
+ // released immediately; `run()` awaits `drainUploads()` before
227
+ // closing the cache. Errors are logged, not propagated: the task
228
+ // already succeeded; we don't fail it on cache-server issues.
229
+ let bytes: Uint8Array
230
+ try {
231
+ bytes = this.local.localWritesEnabled
232
+ ? await Bun.file(this.local.outputsPath(args.hash)).bytes()
233
+ : await this.local.packArtifactBytes(args)
234
+ } catch (err) {
235
+ this.reportRemoteError(err)
236
+ return
237
+ }
238
+ const durationMs = args.entry.durationMs
239
+ this.enqueueUpload(async () => {
240
+ try {
241
+ await this.remote.put(args.hash, bytes, { durationMs })
242
+ } catch (err) {
243
+ this.reportRemoteError(err)
244
+ }
245
+ })
246
+ }
247
+
248
+ /**
249
+ * Resolves once every queued + in-flight background upload settles.
250
+ * `run()` calls this next to the prefetch drain, before
251
+ * `cache.close()` — an upload reading state after close would race.
252
+ */
253
+ async drainUploads(): Promise<void> {
254
+ if (this.activeUploads === 0 && this.uploadQueue.length === 0) return
255
+ await new Promise<void>((resolve) => this.drainWaiters.push(resolve))
256
+ }
257
+
258
+ private enqueueUpload(job: () => Promise<void>): void {
259
+ this.uploadQueue.push(job)
260
+ this.pumpUploads()
261
+ }
262
+
263
+ private pumpUploads(): void {
264
+ while (this.activeUploads < UPLOAD_CONCURRENCY && this.uploadQueue.length > 0) {
265
+ const job = this.uploadQueue.shift()!
266
+ this.activeUploads++
267
+ // Jobs never reject (each wraps its PUT in the never-fail guard).
268
+ void job().finally(() => {
269
+ this.activeUploads--
270
+ this.pumpUploads()
271
+ })
272
+ }
273
+ if (this.activeUploads === 0 && this.uploadQueue.length === 0 && this.drainWaiters.length > 0) {
274
+ const waiters = this.drainWaiters
275
+ this.drainWaiters = []
276
+ for (const w of waiters) w()
277
+ }
278
+ }
279
+
280
+ async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
281
+ await this.local.ingest(hash, compressed, meta)
282
+ }
283
+
284
+ loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
285
+ // Output-file fingerprints live in the local SQLite layer only —
286
+ // they describe the state on this machine's filesystem.
287
+ return this.local.loadOutputFilesBatch(hashes)
288
+ }
289
+
290
+ async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
291
+ return this.local.isOutputsCurrent(projectDir, expected)
292
+ }
293
+
294
+ recordRun(run: RunRecord): void {
295
+ this.local.recordRun(run)
296
+ }
297
+
298
+ recordRuns(runs: readonly RunRecord[]): void {
299
+ this.local.recordRuns(runs)
300
+ }
301
+
302
+ recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
303
+ this.local.recordRunBundle(bundle)
304
+ }
305
+
306
+ stats(): CacheStats {
307
+ return this.local.stats()
308
+ }
309
+
310
+ async prune(options: PruneOptions): Promise<PruneResult> {
311
+ return await this.local.prune(options)
312
+ }
313
+
314
+ close(): void {
315
+ this.local.close()
316
+ }
317
+
318
+ private reportRemoteError(err: unknown): void {
319
+ const e = err instanceof Error ? err : new Error(String(err))
320
+ // The remote cache is fully optional: NO remote failure — a 500, a
321
+ // network drop, a corrupt artifact, even a throwing onRemoteError
322
+ // callback — may ever fail the run. We report and degrade to a
323
+ // cache miss. The callback is guarded so a buggy reporter can't
324
+ // turn an optional-cache hiccup into a run failure.
325
+ try {
326
+ if (this.options.onRemoteError) {
327
+ this.options.onRemoteError(e)
328
+ } else {
329
+ process.stderr.write(`[vx] remote cache: ${e.message}\n`)
330
+ }
331
+ } catch {
332
+ // swallow — reporting must never escalate
333
+ }
334
+ }
335
+ }
@@ -0,0 +1,251 @@
1
+ // Remote cache HTTP client — speaks the Turborepo /v8/artifacts/ spec.
2
+ //
3
+ // Wire-level only. Knows nothing about local storage, tar formats, or
4
+ // how the orchestrator uses the cache; those concerns live in the
5
+ // LayeredCache. Three operations — GET (read), PUT (write), and HEAD
6
+ // (existence probe for the plan path).
7
+ //
8
+ // Reference servers we want to interop with at the HTTP layer:
9
+ // ducktors/turborepo-remote-cache, Fox32/openturbo-remote-cache,
10
+ // Vercel hosted cache.
11
+
12
+ import { createHmac, timingSafeEqual } from 'node:crypto'
13
+
14
+ export interface RemoteCacheConfig {
15
+ /** Base URL of the cache server, e.g. https://cache.example.com */
16
+ baseUrl: string
17
+ /** Bearer token sent as `Authorization: Bearer <token>`. */
18
+ token: string
19
+ /** Optional Turbo-style tenant identifiers; sent as ?teamId=&slug=. */
20
+ teamId?: string
21
+ slug?: string
22
+ /** Per-request timeout in ms. Default 60_000. */
23
+ timeoutMs?: number
24
+ /**
25
+ * HMAC artifact-signing key (Turbo's `remoteCache.signature` scheme).
26
+ * When set, every PUT carries an `x-artifact-tag` over the artifact
27
+ * and every GET response must carry a matching tag — a missing or
28
+ * mismatched tag is a `RemoteCacheError`, never a silent accept.
29
+ */
30
+ signatureKey?: string
31
+ /**
32
+ * Untrusted per-PR partition id, sent as `x-vx-cache-scope`. A compatible
33
+ * remote-cache server MAY partition untrusted writes by this id so one fork
34
+ * PR can't read or poison another's cache. Ignored by trusted writes and by
35
+ * third-party Turbo servers.
36
+ */
37
+ cacheScope?: string
38
+ }
39
+
40
+ export interface RemotePutMetadata {
41
+ /** Task duration in ms. Sent as x-artifact-duration. */
42
+ durationMs: number
43
+ }
44
+
45
+ export interface RemoteGetResult {
46
+ body: ArrayBuffer
47
+ durationMs: number | undefined
48
+ }
49
+
50
+ /**
51
+ * Hard cap on a downloaded artifact body (compressed bytes). A malicious or
52
+ * compromised remote (or a MITM answering a GET) must not be able to stream an
53
+ * unbounded body into `res.arrayBuffer()` and exhaust the victim's memory —
54
+ * the download aborts past this and degrades to a cache miss. Mirrors the
55
+ * server-side PUT cap.
56
+ */
57
+ export const MAX_REMOTE_ARTIFACT_BYTES = 512 * 1024 * 1024
58
+
59
+ /**
60
+ * Read a response body, aborting once cumulative bytes exceed `max`. Streams
61
+ * via the body reader so a lying (or absent) content-length can't defeat the
62
+ * cap. Falls back to `arrayBuffer()` only when the body isn't a stream.
63
+ *
64
+ * Exported for tests: the `max` param lets a unit test exercise the
65
+ * mid-stream abort with a tiny cap instead of a 512 MiB body.
66
+ */
67
+ export async function readBodyBounded(res: Response, max: number): Promise<ArrayBuffer> {
68
+ const reader = res.body?.getReader()
69
+ if (reader === undefined) return await res.arrayBuffer()
70
+ const chunks: Uint8Array[] = []
71
+ let total = 0
72
+ for (;;) {
73
+ const { done, value } = await reader.read()
74
+ if (done) break
75
+ if (value === undefined) continue
76
+ total += value.byteLength
77
+ if (total > max) {
78
+ await reader.cancel().catch(() => {})
79
+ throw new Error(`artifact exceeds ${max}-byte download cap`)
80
+ }
81
+ chunks.push(value)
82
+ }
83
+ const out = new Uint8Array(total)
84
+ let off = 0
85
+ for (const c of chunks) {
86
+ out.set(c, off)
87
+ off += c.byteLength
88
+ }
89
+ return out.buffer
90
+ }
91
+
92
+ export class RemoteCache {
93
+ constructor(private readonly config: RemoteCacheConfig) {}
94
+
95
+ async get(hash: string): Promise<RemoteGetResult | null> {
96
+ const res = await this.fetch('GET', this.artifactUrl(hash))
97
+ if (res.status === 404) return null
98
+ if (!res.ok) throw new RemoteCacheError(`GET ${hash} → ${res.status}`, res.status)
99
+ // Refuse an honestly-declared oversize body before reading a byte.
100
+ const declaredLen = Number(res.headers.get('content-length') ?? '')
101
+ if (Number.isFinite(declaredLen) && declaredLen > MAX_REMOTE_ARTIFACT_BYTES) {
102
+ throw new RemoteCacheError(
103
+ `GET ${hash} → artifact too large (${declaredLen} bytes > ${MAX_REMOTE_ARTIFACT_BYTES} cap)`,
104
+ res.status,
105
+ )
106
+ }
107
+ // A body that ends before the declared content-length (server died
108
+ // mid-transfer) throws from the reader, not from fetch() — wrap it here
109
+ // so callers see a typed RemoteCacheError and the layered cache can
110
+ // degrade it to a miss. The bounded read also aborts a body that lies
111
+ // about (or omits) its length once it crosses the cap.
112
+ let body: ArrayBuffer
113
+ try {
114
+ body = await readBodyBounded(res, MAX_REMOTE_ARTIFACT_BYTES)
115
+ } catch (err) {
116
+ throw new RemoteCacheError(
117
+ `GET ${hash} → body read failed: ${(err as Error).message}`,
118
+ res.status,
119
+ err,
120
+ )
121
+ }
122
+ if (this.config.signatureKey !== undefined) {
123
+ const received = res.headers.get('x-artifact-tag')
124
+ if (received === null) {
125
+ // A signing deployment must not silently accept unsigned
126
+ // artifacts — that would let an attacker strip the tag.
127
+ throw new RemoteCacheError(
128
+ `GET ${hash} → signature verification enabled but response carries no x-artifact-tag`,
129
+ res.status,
130
+ )
131
+ }
132
+ if (!this.tagMatches(hash, new Uint8Array(body), received)) {
133
+ throw new RemoteCacheError(`GET ${hash} → x-artifact-tag signature mismatch`, res.status)
134
+ }
135
+ }
136
+ return {
137
+ body,
138
+ durationMs: parseIntHeader(res.headers.get('x-artifact-duration')),
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Existence probe — Turbo wire `HEAD /v8/artifacts/:hash` (200 =
144
+ * exists, 404 = not). No body transfer; the plan path (`--dry` /
145
+ * `--graph`) predicts remote hits with this so it never downloads
146
+ * artifacts. Other failures throw a `RemoteCacheError` like `get`;
147
+ * the LayeredCache degrades them to a miss.
148
+ */
149
+ async has(hash: string): Promise<boolean> {
150
+ const res = await this.fetch('HEAD', this.artifactUrl(hash))
151
+ if (res.status === 404) return false
152
+ if (!res.ok) throw new RemoteCacheError(`HEAD ${hash} → ${res.status}`, res.status)
153
+ return true
154
+ }
155
+
156
+ async put(hash: string, body: ArrayBuffer | Uint8Array, meta: RemotePutMetadata): Promise<void> {
157
+ const headers: Record<string, string> = {
158
+ 'Content-Type': 'application/octet-stream',
159
+ 'Content-Length': String(body.byteLength),
160
+ 'x-artifact-duration': String(meta.durationMs),
161
+ }
162
+ if (this.config.signatureKey !== undefined) {
163
+ headers['x-artifact-tag'] = this.artifactTag(
164
+ hash,
165
+ body instanceof Uint8Array ? body : new Uint8Array(body),
166
+ )
167
+ }
168
+
169
+ const res = await this.fetch('PUT', this.artifactUrl(hash), { body, headers })
170
+ // Any 2xx is success — Turbo-compatible servers answer 200/201/202.
171
+ if (!res.ok) {
172
+ throw new RemoteCacheError(`PUT ${hash} → ${res.status}`, res.status)
173
+ }
174
+ }
175
+
176
+ // Turbo's construction (crates/turborepo-cache/src/signature_authentication.rs):
177
+ // base64(HMAC-SHA256(key, utf8(hash) || utf8(teamId ?? '') || body)).
178
+ // Byte-compatible with Turbo so signing servers/clients interop.
179
+ private artifactTag(hash: string, body: Uint8Array): string {
180
+ return createHmac('sha256', this.config.signatureKey!)
181
+ .update(hash)
182
+ .update(this.config.teamId ?? '')
183
+ .update(body)
184
+ .digest('base64')
185
+ }
186
+
187
+ private tagMatches(hash: string, body: Uint8Array, received: string): boolean {
188
+ const expected = Buffer.from(this.artifactTag(hash, body), 'base64')
189
+ const actual = Buffer.from(received, 'base64')
190
+ return expected.length === actual.length && timingSafeEqual(expected, actual)
191
+ }
192
+
193
+ private artifactUrl(hash: string): string {
194
+ return this.urlWithTenancy(`${this.config.baseUrl}/v8/artifacts/${encodeURIComponent(hash)}`)
195
+ }
196
+
197
+ private urlWithTenancy(base: string): string {
198
+ const params = new URLSearchParams()
199
+ if (this.config.teamId) params.set('teamId', this.config.teamId)
200
+ if (this.config.slug) params.set('slug', this.config.slug)
201
+ const q = params.toString()
202
+ return q ? `${base}?${q}` : base
203
+ }
204
+
205
+ private async fetch(
206
+ method: string,
207
+ url: string,
208
+ init?: { body?: ArrayBuffer | Uint8Array | string; headers?: Record<string, string> },
209
+ ): Promise<Response> {
210
+ const timeoutMs = this.config.timeoutMs ?? 60_000
211
+ try {
212
+ return await fetch(url, {
213
+ method,
214
+ signal: AbortSignal.timeout(timeoutMs),
215
+ headers: {
216
+ Authorization: `Bearer ${this.config.token}`,
217
+ // The untrusted per-PR partition (an optional server-side extension;
218
+ // a third-party Turbo server ignores it). Isolates one fork PR's
219
+ // writes/reads from another's within the untrusted tier.
220
+ ...(this.config.cacheScope !== undefined
221
+ ? { 'x-vx-cache-scope': this.config.cacheScope }
222
+ : {}),
223
+ ...init?.headers,
224
+ },
225
+ ...(init?.body !== undefined ? { body: init.body } : {}),
226
+ })
227
+ } catch (err) {
228
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
229
+ throw new RemoteCacheError(`${method} ${url} timed out after ${timeoutMs}ms`, 0, err)
230
+ }
231
+ throw new RemoteCacheError(`${method} ${url} failed: ${(err as Error).message}`, 0, err)
232
+ }
233
+ }
234
+ }
235
+
236
+ export class RemoteCacheError extends Error {
237
+ constructor(
238
+ message: string,
239
+ public readonly status: number,
240
+ public override readonly cause?: unknown,
241
+ ) {
242
+ super(message)
243
+ this.name = 'RemoteCacheError'
244
+ }
245
+ }
246
+
247
+ function parseIntHeader(v: string | null): number | undefined {
248
+ if (v === null) return undefined
249
+ const n = Number(v)
250
+ return Number.isFinite(n) ? n : undefined
251
+ }