@vzn/vx-reapi 0.0.646 → 0.0.649

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from '@vzn/vx'
24
24
  import { ReapiRemoteCache } from './cache.js'
25
25
  import { reapiExecutor } from './executor.js'
26
- import { ReapiClient, type ReapiOptions, usesTls } from './wire.js'
26
+ import { MAX_TIMER_MS, ReapiClient, type ReapiOptions, usesTls } from './wire.js'
27
27
 
28
28
  // `ReapiRemoteCache` is public for a workspace that composes cache
29
29
  // layers by hand; the wire, the Merkle encoders and the executor are
@@ -50,8 +50,8 @@ export interface ReapiPluginOptions extends Partial<
50
50
  */
51
51
  queueTimeoutMs?: number
52
52
  /**
53
- * Endpoint, or omit to read `VX_REAPI_ENDPOINT`. With neither the plugin
54
- * DECLINES — a declared-but-unconfigured plugin costs nothing and must
53
+ * Endpoint (`process.env.REAPI_ENDPOINT` in `.vx/workspace.ts`). Unset
54
+ * or empty, the plugin DECLINES — a declared-but-unconfigured plugin costs nothing and must
55
55
  * never fail a run. TLS unless it starts `grpc://`, `http://`, `unix:` or
56
56
  * `unix-abstract:`, or `tls: false` is set, as Bazel reads a schemeless
57
57
  * `--remote_cache`.
@@ -61,8 +61,7 @@ export interface ReapiPluginOptions extends Partial<
61
61
  * Contribute the `executor` capability too, so tasks RUN on the REAPI
62
62
  * server rather than only caching there. Default false: remote execution
63
63
  * changes where a user's build runs, which is not something a plugin should
64
- * switch on merely by being configured for caching. `VX_REAPI_EXECUTE=1`
65
- * also enables it.
64
+ * switch on merely by being configured for caching.
66
65
  */
67
66
  execute?: boolean
68
67
  /** REAPI platform properties for remote execution (`container-image`, …). */
@@ -71,14 +70,12 @@ export interface ReapiPluginOptions extends Partial<
71
70
  capacity?: number
72
71
  /**
73
72
  * PEM file of the CA that signed the server's certificate, for a server
74
- * behind a private CA. Falls back to `VX_REAPI_TLS_CERTIFICATE`. Bazel's
75
- * `--tls_certificate`.
73
+ * behind a private CA. Bazel's `--tls_certificate`.
76
74
  */
77
75
  tlsCertificate?: string
78
76
  /**
79
77
  * PEM files of a client certificate and its key, for a server that asks
80
- * for mutual TLS (`VX_REAPI_TLS_CLIENT_CERTIFICATE` / `_KEY`). Bazel's
81
- * `--tls_client_certificate` / `--tls_client_key`.
78
+ * for mutual TLS. Bazel's `--tls_client_certificate` / `--tls_client_key`.
82
79
  */
83
80
  tlsClientCertificate?: string
84
81
  tlsClientKey?: string
@@ -95,69 +92,51 @@ export interface ReapiPluginOptions extends Partial<
95
92
  * Declines when no endpoint is configured, so it is safe to leave declared.
96
93
  */
97
94
  function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
98
- // `process.env`, not `Bun.env`, for core's reason (exec/sandbox-runtime.ts):
99
- // an embedder that replaces the env object leaves `Bun.env` on the old one.
100
- const from = options.endpoint !== undefined ? '`reapi({ endpoint })`' : 'VX_REAPI_ENDPOINT'
101
- const endpoint = (options.endpoint ?? process.env['VX_REAPI_ENDPOINT'])?.trim()
95
+ const from = '`reapi({ endpoint })`'
96
+ // A trailing slash is stripped, as every other plugin strips its URL's; a
97
+ // socket path is the file system's to read.
98
+ const given = options.endpoint?.trim()
99
+ const endpoint =
100
+ given?.startsWith('unix') === true ? given : given?.replace(/^(.*[^/:])\/+$/, '$1')
102
101
  if (endpoint === undefined || endpoint === '') return undefined
103
102
  assertEndpoint(endpoint, from)
104
103
  // A secrets file's trailing newline sent a name no blob is under, and
105
104
  // every lookup missed in silence.
106
- const instanceName =
107
- (options.instanceName ?? process.env['VX_REAPI_INSTANCE'])?.trim() || undefined
105
+ const instanceName = options.instanceName?.trim() || undefined
108
106
  // A server behind a private CA, or one that asks for a client
109
107
  // certificate, was unreachable: TLS used the system roots and no client
110
108
  // pair (F-41). Each is a PEM file, read here; one that cannot be read is
111
109
  // a setting to fix, refused naming it.
112
- const pem = (
113
- option: string | undefined,
114
- name: string,
115
- fromEnv: string | undefined,
116
- env: string,
117
- ): string | undefined => {
118
- const file = option ?? fromEnv?.trim()
119
- if (file === undefined || file === '') return undefined
110
+ const pem = (file: string | undefined, name: string): string | undefined => {
111
+ if (file === undefined || file.trim() === '') return undefined
120
112
  try {
121
- return readFileSync(file, 'utf8')
113
+ return readFileSync(file.trim(), 'utf8')
122
114
  } catch (err) {
123
- const from = option !== undefined ? `\`reapi({ ${name} })\`` : env
124
115
  throw new UserError(
125
- `vx/reapi: ${from} names ${file}, which cannot be read (${(err as NodeJS.ErrnoException).code ?? String(err)})`,
116
+ `\`reapi({ ${name} })\` names ${file.trim()}, which cannot be read (${(err as NodeJS.ErrnoException).code ?? String(err)})`,
126
117
  )
127
118
  }
128
119
  }
129
- const tlsCaPem = pem(
130
- options.tlsCertificate,
131
- 'tlsCertificate',
132
- process.env['VX_REAPI_TLS_CERTIFICATE'],
133
- 'VX_REAPI_TLS_CERTIFICATE',
134
- )
135
- const tlsClientCertPem = pem(
136
- options.tlsClientCertificate,
137
- 'tlsClientCertificate',
138
- process.env['VX_REAPI_TLS_CLIENT_CERTIFICATE'],
139
- 'VX_REAPI_TLS_CLIENT_CERTIFICATE',
140
- )
141
- const tlsClientKeyPem = pem(
142
- options.tlsClientKey,
143
- 'tlsClientKey',
144
- process.env['VX_REAPI_TLS_CLIENT_KEY'],
145
- 'VX_REAPI_TLS_CLIENT_KEY',
146
- )
120
+ const tlsCaPem = pem(options.tlsCertificate, 'tlsCertificate')
121
+ const tlsClientCertPem = pem(options.tlsClientCertificate, 'tlsClientCertificate')
122
+ const tlsClientKeyPem = pem(options.tlsClientKey, 'tlsClientKey')
147
123
  // grpc-js refuses either one alone, with a message that names neither
148
124
  // setting.
149
125
  if ((tlsClientCertPem === undefined) !== (tlsClientKeyPem === undefined)) {
150
126
  throw new UserError(
151
- 'vx/reapi: a client certificate and its key go together — set both tlsClientCertificate and tlsClientKey (VX_REAPI_TLS_CLIENT_CERTIFICATE / VX_REAPI_TLS_CLIENT_KEY), or neither',
127
+ 'reapi(): a client certificate and its key go together — set both tlsClientCertificate and tlsClientKey, or neither',
152
128
  )
153
129
  }
154
130
  if (usesTls({ endpoint, tls: options.tls, tlsCaPem, tlsClientCertPem }) && ipTarget(endpoint)) {
155
131
  throw new UserError(
156
- `vx/reapi: ${from} is ${JSON.stringify(endpoint)}, TLS to an IP address, which Bun cannot open (it refuses an IP as the TLS server name) — name the server by host name, or use grpc:// for plaintext`,
132
+ `${from} is ${JSON.stringify(endpoint)}, TLS to an IP address, which Bun cannot open (it refuses an IP as the TLS server name) — name the server by host name, or use grpc:// for plaintext`,
157
133
  )
158
134
  }
135
+ // Never the option as written: a blank one spread here was sent as the
136
+ // instance name.
137
+ const { instanceName: _written, ...rest } = options
159
138
  return {
160
- ...options,
139
+ ...rest,
161
140
  endpoint,
162
141
  ...(instanceName === undefined ? {} : { instanceName }),
163
142
  ...(tlsCaPem === undefined ? {} : { tlsCaPem }),
@@ -181,7 +160,7 @@ function assertEndpoint(endpoint: string, from: string): void {
181
160
  const port = m?.[2] === undefined ? undefined : Number(m[2])
182
161
  if (m === null || (port !== undefined && (port < 1 || port > 65_535))) {
183
162
  throw new UserError(
184
- `vx/reapi: ${from} is ${JSON.stringify(endpoint)}, which is not host[:port] (e.g. grpcs://cache.example.com:443)`,
163
+ `${from} is ${JSON.stringify(endpoint)}, which is not host[:port] (e.g. grpcs://cache.example.com:443)`,
185
164
  )
186
165
  }
187
166
  }
@@ -222,15 +201,49 @@ const REAPI_PLUGIN_KEYS: PluginOptionKinds<ReapiPluginOptions> = {
222
201
  chunkBytes: 'number',
223
202
  }
224
203
 
204
+ /**
205
+ * The number options, checked at the call: the client and the executor
206
+ * refused a bad one only once an endpoint was set, so a laptop passed what
207
+ * failed the first CI run, and a NaN read as `null`.
208
+ */
209
+ function refuseBadNumbers(options: ReapiPluginOptions): void {
210
+ const refuse = (name: string, v: unknown, want: string): never => {
211
+ throw new UserError(`reapi() option "${name}" must be ${want}, got ${String(v)}`)
212
+ }
213
+ for (const name of [
214
+ 'callTimeoutMs',
215
+ 'metaTimeoutMs',
216
+ 'executeTimeoutMs',
217
+ 'queueTimeoutMs',
218
+ ] as const) {
219
+ const v = options[name]
220
+ if (v !== undefined && !(v > 0 && v <= MAX_TIMER_MS))
221
+ refuse(name, v, `a positive number of ms up to ${MAX_TIMER_MS}`)
222
+ }
223
+ for (const name of ['chunkBytes', 'capacity'] as const) {
224
+ const v = options[name]
225
+ if (v !== undefined && !(Number.isInteger(v) && v > 0))
226
+ refuse(name, v, 'a whole number above 0')
227
+ }
228
+ }
229
+
225
230
  export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
226
231
  refuseUnknownOptions('reapi()', options, REAPI_PLUGIN_KEYS)
232
+ refuseBadNumbers(options)
233
+ // An unset variable as a value was sent as an empty one on every action.
234
+ for (const key of ['platform', 'headers'] as const)
235
+ for (const [name, value] of Object.entries(options[key] ?? {}))
236
+ if (typeof value !== 'string')
237
+ throw new UserError(
238
+ `reapi() option "${key}.${name}" must be a string, got ${key === 'headers' && value !== undefined ? typeof value : String(value)}`,
239
+ )
227
240
  let executorClient: ReapiClient | undefined
228
241
  let remoteCache: ReapiRemoteCache | undefined
229
242
  return definePlugin(import.meta, {
230
243
  async executor(ctx): Promise<TaskExecutor | undefined> {
231
244
  // Before the connection: with execute off, an unreadable PEM is the
232
245
  // cache hook's to refuse, never a failure "in executor".
233
- if (options.execute !== true && process.env['VX_REAPI_EXECUTE'] !== '1') return undefined
246
+ if (options.execute !== true) return undefined
234
247
  const conn = connection(options)
235
248
  if (conn === undefined) return undefined
236
249
  executorClient = new ReapiClient({ ...conn, onWarn: (m) => ctx.warn(m) })
@@ -241,8 +254,12 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
241
254
  // plugin's setting nor anything to do about it. Core turns a throwing
242
255
  // factory into a UserError and ABORTS — deliberately, an executor is
243
256
  // load-bearing — so this is the message a user acts on.
257
+ // Both asks: a failed second one escaped as the raw status, naming
258
+ // neither the server nor the setting.
259
+ let caps: Awaited<ReturnType<ReapiClient['capabilities']>>
244
260
  try {
245
261
  await executorClient.negotiate()
262
+ caps = await executorClient.capabilities()
246
263
  } catch (err) {
247
264
  executorClient.close()
248
265
  executorClient = undefined
@@ -262,10 +279,9 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
262
279
  // A cache-only deployment (bazel-remote) advertises no execution
263
280
  // capability. Offering it work would hang the run on a server that will
264
281
  // never answer, so DECLINE loudly and let the local executor take over.
265
- const caps = await executorClient.capabilities()
266
282
  if (!caps.execEnabled) {
267
283
  ctx.warn(
268
- `vx/reapi: ${conn.endpoint} does not advertise remote execution (cache only) — tasks will run locally`,
284
+ `[vx] vx-reapi: ${conn.endpoint} does not advertise remote execution (cache only) — tasks will run locally`,
269
285
  )
270
286
  executorClient.close()
271
287
  executorClient = undefined
package/src/merkle.ts CHANGED
@@ -115,13 +115,22 @@ function canonicaliseTree(graft: TreeGraft): { root: Digest; blobs: Blob[] } {
115
115
  if (sent !== undefined) byOldDigest.set(sent, child)
116
116
  })
117
117
  const blobs: Blob[] = []
118
+ // Once per old digest: a subtree the Tree names under many names was
119
+ // rebuilt per name, 2^depth times.
120
+ const rebuilt = new Map<string, Digest>()
118
121
  const rebuild = (dir: Directory): Digest => {
119
122
  const directories: DirectoryNode[] = dir.directories.map((d) => {
120
123
  const child = byOldDigest.get(d.digest.hash)
121
124
  // A child we cannot resolve keeps its original digest — the blob may
122
125
  // exist server-side under the worker's encoding even if we cannot
123
126
  // re-derive it. Better a possibly-dangling reference than a wrong one.
124
- return child === undefined ? d : { name: d.name, digest: rebuild(child) }
127
+ if (child === undefined) return d
128
+ let digest = rebuilt.get(d.digest.hash)
129
+ if (digest === undefined) {
130
+ digest = rebuild(child)
131
+ rebuilt.set(d.digest.hash, digest)
132
+ }
133
+ return { name: d.name, digest }
125
134
  })
126
135
  const canonical: Directory = { files: dir.files, directories, symlinks: dir.symlinks }
127
136
  const data = encodeDirectory(canonical)
@@ -200,7 +209,9 @@ export async function buildInputTree(args: {
200
209
  // anyway so a warning naming several paths reads the same run to run.
201
210
  // Read READ_CONCURRENCY at once, inserted in sorted order below: one at a
202
211
  // time, 2 000 small inputs cost 304 ms of file-system round trips (F-36).
203
- const sorted = [...args.paths].sort()
212
+ // Deduped: an upstream's output on disk that is also a declared input
213
+ // is named twice, and was read and counted twice.
214
+ const sorted = [...new Set(args.paths)].sort()
204
215
  const reads = await mapBounded(sorted, READ_CONCURRENCY, async (rel) => {
205
216
  const abs = path.join(args.workspaceRoot, rel)
206
217
  // lstat, not stat: a symlinked input must be REPRESENTED as a symlink.
@@ -244,7 +255,11 @@ export async function buildInputTree(args: {
244
255
  node.symlinks.set(parts[parts.length - 1]!, { name: parts[parts.length - 1]!, target })
245
256
  continue
246
257
  }
247
- if (got.kind === 'other') continue
258
+ if (got.kind === 'other') {
259
+ // A keyed file that is now a directory (or a socket) is absent too.
260
+ if (args.expected?.has(rel) === true) moved.push(rel)
261
+ continue
262
+ }
248
263
  const data = got.data
249
264
  const want = args.expected?.get(rel)
250
265
  if (want !== undefined && !carriesOid(data, want)) moved.push(rel)
@@ -331,6 +346,33 @@ export async function buildInputTree(args: {
331
346
  return { root: rootDigest, blobs, fileCount, shadowed, moved }
332
347
  }
333
348
 
349
+ /**
350
+ * REAPI's sort: names by UTF-8 bytes, which servers compare (Go's string
351
+ * order). JS's default sort is by UTF-16 units, which puts an astral name
352
+ * (an emoji) before one in U+E000-U+FFFF; a server refused the Directory,
353
+ * and another client hashed the same tree to another digest.
354
+ */
355
+ function byUtf8(a: string, b: string): number {
356
+ const n = Math.min(a.length, b.length)
357
+ for (let i = 0; i < n; i++) {
358
+ let x = a.charCodeAt(i)
359
+ let y = b.charCodeAt(i)
360
+ if (x === y) continue
361
+ // Surrogates (D800-DFFF) are code points past FFFF: move them after E000-FFFF.
362
+ if (x >= 0xd800 && y >= 0xd800) {
363
+ x += x >= 0xe000 ? -0x800 : 0x2000
364
+ y += y >= 0xe000 ? -0x800 : 0x2000
365
+ }
366
+ return x - y
367
+ }
368
+ return a.length - b.length
369
+ }
370
+
371
+ /** `xs` sorted by `byUtf8`; the default sort when no unit can differ. */
372
+ function utf8Sorted(xs: string[]): string[] {
373
+ return xs.some((x) => /[\ud800-\uffff]/.test(x)) ? xs.sort(byUtf8) : xs.sort()
374
+ }
375
+
334
376
  /**
335
377
  * Depth-first, children before parents: a Directory's digest covers its
336
378
  * children's digests, so they must be finalised first. REAPI requires `files`
@@ -340,7 +382,7 @@ export async function buildInputTree(args: {
340
382
  */
341
383
  function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
342
384
  const directories: DirectoryNode[] = []
343
- const dirNames = [...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])].sort()
385
+ const dirNames = utf8Sorted([...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])])
344
386
  for (const name of dirNames) {
345
387
  const sub = node.dirs.get(name)
346
388
  directories.push({
@@ -351,8 +393,8 @@ function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
351
393
  digest: node.rawDirs.get(name) ?? serialise(sub!, blobs, seen),
352
394
  })
353
395
  }
354
- const files = [...node.files.keys()].sort().map((n) => node.files.get(n)!)
355
- const symlinks = [...node.symlinks.keys()].sort().map((n) => node.symlinks.get(n)!)
396
+ const files = utf8Sorted([...node.files.keys()]).map((n) => node.files.get(n)!)
397
+ const symlinks = utf8Sorted([...node.symlinks.keys()]).map((n) => node.symlinks.get(n)!)
356
398
  const dir: Directory = { files, directories, symlinks }
357
399
  const data = encodeDirectory(dir)
358
400
  const digest = sha256(data)
@@ -517,7 +559,7 @@ const HASH_ALGO: Partial<Record<DigestFunctionName, string>> = {
517
559
  */
518
560
  export function digestWith(fn: DigestFunctionName, data: Uint8Array): Digest {
519
561
  const algo = HASH_ALGO[fn]
520
- if (algo === undefined) throw new Error(`@vzn/vx-reapi: unsupported digest function ${fn}`)
562
+ if (algo === undefined) throw new Error(`unsupported digest function ${fn}`)
521
563
  return { hash: createHash(algo).update(data).digest('hex'), size_bytes: data.length }
522
564
  }
523
565
 
@@ -533,6 +575,7 @@ export function canDigest(fn: DigestFunctionName): boolean {
533
575
  try {
534
576
  createHash(algo)
535
577
  return true
578
+ // quiet: a hash this runtime lacks is answered by false
536
579
  } catch {
537
580
  return false
538
581
  }
@@ -584,15 +627,13 @@ interface CommandSpec {
584
627
  * over these bytes and canonical encoders write ascending field numbers.
585
628
  */
586
629
  export function encodeCommand(c: CommandSpec): Uint8Array {
587
- const env = [...c.environmentVariables].sort((a, b) =>
588
- a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
589
- )
590
- const platform = [...c.platform].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
630
+ const env = [...c.environmentVariables].sort((a, b) => byUtf8(a.name, b.name))
631
+ const platform = [...c.platform].sort((a, b) => byUtf8(a.name, b.name))
591
632
  return concat([
592
633
  ...c.arguments.map((a) => repStrField(1, a)),
593
634
  ...env.map((e) => lenField(2, concat([strField(1, e.name), strField(2, e.value)]))),
594
- ...[...(c.legacyOutputFiles ?? [])].sort().map((p) => repStrField(3, p)),
595
- ...[...(c.legacyOutputDirectories ?? [])].sort().map((p) => repStrField(4, p)),
635
+ ...utf8Sorted([...(c.legacyOutputFiles ?? [])]).map((p) => repStrField(3, p)),
636
+ ...utf8Sorted([...(c.legacyOutputDirectories ?? [])]).map((p) => repStrField(4, p)),
596
637
  ...(platform.length > 0
597
638
  ? [
598
639
  lenField(
@@ -604,8 +645,8 @@ export function encodeCommand(c: CommandSpec): Uint8Array {
604
645
  ]
605
646
  : []),
606
647
  ...(c.workingDirectory === '' ? [] : [strField(6, c.workingDirectory)]),
607
- ...[...c.outputPaths].sort().map((p) => repStrField(7, p)),
608
- ...[...(c.outputNodeProperties ?? [])].sort().map((n) => repStrField(8, n)),
648
+ ...utf8Sorted([...c.outputPaths]).map((p) => repStrField(7, p)),
649
+ ...utf8Sorted([...(c.outputNodeProperties ?? [])]).map((n) => repStrField(8, n)),
609
650
  ...(c.outputDirectoryFormat === undefined || c.outputDirectoryFormat === 0
610
651
  ? []
611
652
  : [intField(9, c.outputDirectoryFormat)]),
@@ -627,9 +668,7 @@ export function encodeAction(a: {
627
668
  /** `platform` (field 10) — REAPI v2.2 moved it here from `Command`. */
628
669
  platform?: ReadonlyArray<{ name: string; value: string }>
629
670
  }): Uint8Array {
630
- const platform = [...(a.platform ?? [])].sort((x, y) =>
631
- x.name < y.name ? -1 : x.name > y.name ? 1 : 0,
632
- )
671
+ const platform = [...(a.platform ?? [])].sort((x, y) => byUtf8(x.name, y.name))
633
672
  return concat([
634
673
  lenField(1, encodeDigest(a.commandDigest)),
635
674
  lenField(2, encodeDigest(a.inputRootDigest)),