@vzn/vx 0.0.12 → 0.0.14

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.
@@ -1,251 +0,0 @@
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
- }
@@ -1,60 +0,0 @@
1
- import {
2
- Cache,
3
- type CacheLayer,
4
- type CachePolicy,
5
- FULL_CACHE_POLICY,
6
- LayeredCache,
7
- RemoteCache,
8
- } from '../cache/index.js'
9
- import type { Logger } from './logger.js'
10
- import { resolveCacheScope } from './run-context.js'
11
-
12
- /**
13
- * Wrap the local cache in a `LayeredCache` when a remote cache is configured
14
- * via `VX_REMOTE_CACHE_URL` + a token. This is the escape hatch for a
15
- * THIRD-PARTY, Turbo-compatible cache server. A `cache` plugin can provide a
16
- * remote cache INTERNALLY instead, in which case this env path isn't
17
- * needed.
18
- *
19
- * **Trust tier follows the token you present** — the server derives
20
- * trusted/untrusted from the bearer, so the client just presents whichever
21
- * token it has: `VX_REMOTE_CACHE_TOKEN` for a trusted context, or
22
- * `VX_REMOTE_CACHE_PR_TOKEN` for a fork PR (reads the trusted cache, writes
23
- * only the untrusted scope). A fork-PR CI job simply doesn't hold the trusted
24
- * secret, so "which token you have" IS the tier — no separate trust flag.
25
- *
26
- * Optional env: `VX_REMOTE_CACHE_TEAM_ID`, `VX_REMOTE_CACHE_SLUG`
27
- * (Turbo tenancy query params), `VX_REMOTE_CACHE_TIMEOUT_MS`,
28
- * `VX_REMOTE_CACHE_SIGNATURE_KEY` (HMAC artifact signing).
29
- */
30
- export function wrapWithRemoteCache(
31
- local: Cache,
32
- log: Logger,
33
- policy: CachePolicy = FULL_CACHE_POLICY,
34
- ): CacheLayer {
35
- const url = process.env.VX_REMOTE_CACHE_URL
36
- const token = process.env.VX_REMOTE_CACHE_TOKEN ?? process.env.VX_REMOTE_CACHE_PR_TOKEN
37
- if (!url || !token) return local
38
-
39
- const config: ConstructorParameters<typeof RemoteCache>[0] = { baseUrl: url, token }
40
- const teamId = process.env.VX_REMOTE_CACHE_TEAM_ID
41
- if (teamId) config.teamId = teamId
42
- const slug = process.env.VX_REMOTE_CACHE_SLUG
43
- if (slug) config.slug = slug
44
- const timeoutMs = process.env.VX_REMOTE_CACHE_TIMEOUT_MS
45
- if (timeoutMs) {
46
- const n = Number(timeoutMs)
47
- if (Number.isFinite(n) && n > 0) config.timeoutMs = n
48
- }
49
- const signatureKey = process.env.VX_REMOTE_CACHE_SIGNATURE_KEY
50
- if (signatureKey) config.signatureKey = signatureKey
51
- // Untrusted per-PR isolation: a fork PR's writes land in its own sub-scope.
52
- const cacheScope = resolveCacheScope(process.env)
53
- if (cacheScope) config.cacheScope = cacheScope
54
-
55
- log.status(`remote cache: ${url}`)
56
- return new LayeredCache(local, new RemoteCache(config), {
57
- onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
58
- policy,
59
- })
60
- }