@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.
- package/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- 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
|
+
}
|