@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/README.md +45 -20
- package/package.json +2 -2
- package/src/adopt.ts +1 -1
- package/src/adoption-plugin.ts +2 -3
- package/src/migrate-nx.ts +3 -2
- package/src/migrate-turbo.ts +9 -81
- package/src/migrate-vite-task.ts +40 -0
- package/src/migrate.ts +69 -14
- package/src/nx/export-graph.ts +21 -3
- package/src/nx/index.ts +6 -3
- package/src/nx/lerna.ts +60 -0
- package/src/nx/nx-inputs.ts +17 -6
- package/src/nx/nx-map.ts +17 -6
- package/src/nx/nx-outputs.ts +62 -12
- package/src/nx/nx-upstream.ts +12 -2
- package/src/nx-cache/index.ts +17 -15
- package/src/paths.ts +4 -1
- package/src/remote-breaker.ts +60 -0
- package/src/remote-deadline.ts +13 -5
- package/src/shared-outputs.ts +3 -1
- package/src/spelled-env.ts +36 -0
- package/src/tracked-outputs.ts +3 -2
- package/src/turbo/turbo-map.ts +28 -9
- package/src/turbo-cache/index.ts +90 -27
- package/src/vite-task/vite-task-map.ts +558 -0
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 (
|
|
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
|
package/src/remote-deadline.ts
CHANGED
|
@@ -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.
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|
package/src/shared-outputs.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tracked-outputs.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
refuseUnknownOptions,
|
|
25
25
|
type PluginOptionKinds,
|
|
26
26
|
} from '@vzn/vx'
|
|
27
|
-
import {
|
|
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
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
/**
|