@vzn/vx-reapi 0.0.0 → 0.0.485

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 ADDED
@@ -0,0 +1,277 @@
1
+ // @vzn/vx-reapi — a vx `cache` plugin backed by any server speaking Bazel's
2
+ // Remote Execution API (NativeLink, BuildBuddy, Buildbarn, bazel-remote).
3
+ //
4
+ // Phase 1 is the remote CACHE only: artifacts live in the CAS, addressed
5
+ // through an ActionCache entry derived from the vx cache key. Remote
6
+ // EXECUTION (the `executor` capability) is a later phase — see
7
+ // docs/design/plugin-executor-reapi-2026-08.md.
8
+ //
9
+ // Imports core only through the public `@vzn/vx` specifier, like every other
10
+ // plugin, so nothing here depends on core's internal layout.
11
+
12
+ import { readFileSync } from 'node:fs'
13
+ import {
14
+ definePlugin,
15
+ LayeredCache,
16
+ type CacheLayer,
17
+ type TaskExecutor,
18
+ type VxPlugin,
19
+ UserError,
20
+ refuseUnknownOptions,
21
+ type PluginOptionKinds,
22
+ } from '@vzn/vx'
23
+ import { ReapiRemoteCache } from './cache.js'
24
+ import { reapiExecutor } from './executor.js'
25
+ import { ReapiClient, type ReapiOptions } from './wire.js'
26
+
27
+ // `ReapiRemoteCache` is public for a workspace that composes cache
28
+ // layers by hand; the wire, the Merkle encoders and the executor are
29
+ // internal (1.0 freezes what this file exports).
30
+ export { ReapiRemoteCache } from './cache.js'
31
+ export { type ReapiOptions } from './wire.js'
32
+
33
+ // The connection's wire form, not the plugin's: the plugin reads PEM FILES
34
+ // (`tlsCertificate`, …, checked as a pair) and warns through its own
35
+ // context, so `reapi({ onWarn })` was dropped without a word and
36
+ // `reapi({ tlsClientCertPem })` skipped the pair check. `ReapiRemoteCache`,
37
+ // composed by hand, takes them.
38
+ export interface ReapiPluginOptions extends Partial<
39
+ Omit<ReapiOptions, 'tlsCaPem' | 'tlsClientCertPem' | 'tlsClientKeyPem' | 'onWarn'>
40
+ > {
41
+ /**
42
+ * Client-side bound on one action, from the EXECUTING transition. See
43
+ * `ReapiExecutorOptions.executeTimeoutMs`; `exec.timeout` wins per task.
44
+ */
45
+ executeTimeoutMs?: number
46
+ /**
47
+ * Bound on the time an action waits QUEUED before a worker starts it. See
48
+ * `ReapiExecutorOptions.queueTimeoutMs`; past it the task runs here.
49
+ */
50
+ queueTimeoutMs?: number
51
+ /**
52
+ * Endpoint, or omit to read `VX_REAPI_ENDPOINT`. With neither the plugin
53
+ * DECLINES — a declared-but-unconfigured plugin costs nothing and must
54
+ * never fail a run.
55
+ */
56
+ endpoint?: string
57
+ /**
58
+ * Contribute the `executor` capability too, so tasks RUN on the REAPI
59
+ * server rather than only caching there. Default false: remote execution
60
+ * changes where a user's build runs, which is not something a plugin should
61
+ * switch on merely by being configured for caching. `VX_REAPI_EXECUTE=1`
62
+ * also enables it.
63
+ */
64
+ execute?: boolean
65
+ /** REAPI platform properties for remote execution (`container-image`, …). */
66
+ platform?: Record<string, string>
67
+ /** Concurrent remote tasks; becomes the scheduler's pool for this executor. */
68
+ capacity?: number
69
+ /**
70
+ * PEM file of the CA that signed the server's certificate, for a server
71
+ * behind a private CA. Falls back to `VX_REAPI_TLS_CERTIFICATE`. Bazel's
72
+ * `--tls_certificate`.
73
+ */
74
+ tlsCertificate?: string
75
+ /**
76
+ * PEM files of a client certificate and its key, for a server that asks
77
+ * for mutual TLS (`VX_REAPI_TLS_CLIENT_CERTIFICATE` / `_KEY`). Bazel's
78
+ * `--tls_client_certificate` / `--tls_client_key`.
79
+ */
80
+ tlsClientCertificate?: string
81
+ tlsClientKey?: string
82
+ }
83
+
84
+ /**
85
+ * Declare in `vx.workspace.ts`; the local store is the floor beneath it and is
86
+ * read first, so the remote is asked only on a local miss:
87
+ *
88
+ * ```ts
89
+ * plugins: [reapi({ endpoint: 'grpcs://grpc.example.com:443' })]
90
+ * ```
91
+ *
92
+ * Declines when no endpoint is configured, so it is safe to leave declared.
93
+ */
94
+ function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
95
+ // `process.env`, not `Bun.env`, for core's reason (exec/sandbox-runtime.ts):
96
+ // an embedder that replaces the env object leaves `Bun.env` on the old one.
97
+ const from = options.endpoint !== undefined ? '`reapi({ endpoint })`' : 'VX_REAPI_ENDPOINT'
98
+ const endpoint = (options.endpoint ?? process.env['VX_REAPI_ENDPOINT'])?.trim()
99
+ if (endpoint === undefined || endpoint === '') return undefined
100
+ assertEndpoint(endpoint, from)
101
+ const instanceName = options.instanceName ?? process.env['VX_REAPI_INSTANCE']
102
+ // A server behind a private CA, or one that asks for a client
103
+ // certificate, was unreachable: TLS used the system roots and no client
104
+ // pair (F-41). Each is a PEM file, read here; one that cannot be read is
105
+ // a setting to fix, refused naming it.
106
+ const pem = (
107
+ option: string | undefined,
108
+ name: string,
109
+ fromEnv: string | undefined,
110
+ env: string,
111
+ ): string | undefined => {
112
+ const file = option ?? fromEnv?.trim()
113
+ if (file === undefined || file === '') return undefined
114
+ try {
115
+ return readFileSync(file, 'utf8')
116
+ } catch (err) {
117
+ const from = option !== undefined ? `\`reapi({ ${name} })\`` : env
118
+ throw new UserError(
119
+ `vx/reapi: ${from} names ${file}, which cannot be read (${(err as NodeJS.ErrnoException).code ?? String(err)})`,
120
+ )
121
+ }
122
+ }
123
+ const tlsCaPem = pem(
124
+ options.tlsCertificate,
125
+ 'tlsCertificate',
126
+ process.env['VX_REAPI_TLS_CERTIFICATE'],
127
+ 'VX_REAPI_TLS_CERTIFICATE',
128
+ )
129
+ const tlsClientCertPem = pem(
130
+ options.tlsClientCertificate,
131
+ 'tlsClientCertificate',
132
+ process.env['VX_REAPI_TLS_CLIENT_CERTIFICATE'],
133
+ 'VX_REAPI_TLS_CLIENT_CERTIFICATE',
134
+ )
135
+ const tlsClientKeyPem = pem(
136
+ options.tlsClientKey,
137
+ 'tlsClientKey',
138
+ process.env['VX_REAPI_TLS_CLIENT_KEY'],
139
+ 'VX_REAPI_TLS_CLIENT_KEY',
140
+ )
141
+ // grpc-js refuses either one alone, with a message that names neither
142
+ // setting.
143
+ if ((tlsClientCertPem === undefined) !== (tlsClientKeyPem === undefined)) {
144
+ throw new UserError(
145
+ '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',
146
+ )
147
+ }
148
+ return {
149
+ ...options,
150
+ endpoint,
151
+ ...(instanceName === undefined ? {} : { instanceName }),
152
+ ...(tlsCaPem === undefined ? {} : { tlsCaPem }),
153
+ ...(tlsClientCertPem === undefined ? {} : { tlsClientCertPem }),
154
+ ...(tlsClientKeyPem === undefined ? {} : { tlsClientKeyPem }),
155
+ }
156
+ }
157
+
158
+ /**
159
+ * A malformed endpoint failed each request on its own, far from the setting:
160
+ * `http://` failed the run with grpc's `Could not parse target name ""`, and
161
+ * `host:notaport` or a lone space degraded every request to a miss (F-14).
162
+ * Refused once, naming where it came from.
163
+ */
164
+ function assertEndpoint(endpoint: string, from: string): void {
165
+ // A grpc-js resolver target (`unix:/run/cas.sock`, `dns:///host:443`) is
166
+ // grpc's to parse.
167
+ if (/^(unix|unix-abstract|dns|ipv4|ipv6):/.test(endpoint)) return
168
+ const target = endpoint.replace(/^(https?|grpcs?):\/\//, '')
169
+ const m = /^(\[[^\]]+\]|[^:/\s]+)(?::(\d+))?$/.exec(target)
170
+ const port = m?.[2] === undefined ? undefined : Number(m[2])
171
+ if (m === null || (port !== undefined && (port < 1 || port > 65_535))) {
172
+ throw new UserError(
173
+ `vx/reapi: ${from} is ${JSON.stringify(endpoint)}, which is not host[:port] (e.g. cache.example.com:443 or grpcs://cache.example.com)`,
174
+ )
175
+ }
176
+ }
177
+
178
+ /** Each option `ReapiPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
179
+ const REAPI_PLUGIN_KEYS: PluginOptionKinds<ReapiPluginOptions> = {
180
+ executeTimeoutMs: 'number',
181
+ queueTimeoutMs: 'number',
182
+ endpoint: 'string',
183
+ execute: 'boolean',
184
+ platform: 'object',
185
+ capacity: 'number',
186
+ tlsCertificate: 'string',
187
+ tlsClientCertificate: 'string',
188
+ tlsClientKey: 'string',
189
+ instanceName: 'string',
190
+ headers: 'object',
191
+ tls: 'boolean',
192
+ toolName: 'string',
193
+ toolVersion: 'string',
194
+ correlatedInvocationsId: 'string',
195
+ callTimeoutMs: 'number',
196
+ metaTimeoutMs: 'number',
197
+ chunkBytes: 'number',
198
+ }
199
+
200
+ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
201
+ refuseUnknownOptions('reapi()', options, REAPI_PLUGIN_KEYS)
202
+ let executorClient: ReapiClient | undefined
203
+ let remoteCache: ReapiRemoteCache | undefined
204
+ return definePlugin(import.meta, {
205
+ async executor(ctx): Promise<TaskExecutor | undefined> {
206
+ const wanted = options.execute === true || process.env['VX_REAPI_EXECUTE'] === '1'
207
+ const conn = connection(options)
208
+ if (!wanted || conn === undefined) return undefined
209
+ executorClient = new ReapiClient({ ...conn, onWarn: (m) => ctx.warn(m) })
210
+ // Negotiate once: turns zstd transfer compression on when the server
211
+ // advertises it. The digest function stays SHA256 (see wire.negotiate).
212
+ // An unreachable or wrong endpoint surfaces here, and the raw gRPC
213
+ // string ("14 UNAVAILABLE … Resolution note:") names neither the
214
+ // plugin's setting nor anything to do about it. Core turns a throwing
215
+ // factory into a UserError and ABORTS — deliberately, an executor is
216
+ // load-bearing — so this is the message a user acts on.
217
+ try {
218
+ await executorClient.negotiate()
219
+ } catch (err) {
220
+ executorClient.close()
221
+ executorClient = undefined
222
+ const why = err instanceof Error ? err.message : String(err)
223
+ throw new Error(
224
+ `cannot reach the REAPI server at ${conn.endpoint} for remote execution: ${why} — check the endpoint, that the server is running, and that this host can reach it, or drop \`execute\` to use it as a cache only`,
225
+ )
226
+ }
227
+ // A cache-only deployment (bazel-remote) advertises no execution
228
+ // capability. Offering it work would hang the run on a server that will
229
+ // never answer, so DECLINE loudly and let the local executor take over.
230
+ const caps = await executorClient.capabilities()
231
+ if (!caps.execEnabled) {
232
+ ctx.warn(
233
+ `vx/reapi: ${conn.endpoint} does not advertise remote execution (cache only) — tasks will run locally`,
234
+ )
235
+ executorClient.close()
236
+ executorClient = undefined
237
+ return undefined
238
+ }
239
+ return reapiExecutor(executorClient, {
240
+ ...(options.platform === undefined ? {} : { platform: options.platform }),
241
+ ...(options.capacity === undefined ? {} : { capacity: options.capacity }),
242
+ ...(options.executeTimeoutMs === undefined
243
+ ? {}
244
+ : { executeTimeoutMs: options.executeTimeoutMs }),
245
+ ...(options.queueTimeoutMs === undefined ? {} : { queueTimeoutMs: options.queueTimeoutMs }),
246
+ warn: (m) => ctx.warn(m),
247
+ })
248
+ },
249
+ teardown(): void {
250
+ executorClient?.close()
251
+ executorClient = undefined
252
+ // The cache layer holds a client too, and `RemoteCacheLayer` has no
253
+ // close hook for core to call — `LayeredCache.close()` closes only the
254
+ // LOCAL handle. Closing it here is lifecycle hygiene rather than a
255
+ // measured fix: @grpc/grpc-js pools subchannels per target, so 20
256
+ // unclosed clients were MEASURED to share one connection. It matters
257
+ // for `vx watch`, which installs and tears down plugins once per
258
+ // re-run, and it keeps the resource owned by whoever created it.
259
+ remoteCache?.close()
260
+ remoteCache = undefined
261
+ },
262
+ cache(ctx): CacheLayer | undefined {
263
+ const conn = connection(options)
264
+ if (conn === undefined) return undefined
265
+ const remote = new ReapiRemoteCache({ ...conn, onWarn: (m) => ctx.warn(m) })
266
+ remoteCache = remote
267
+ // Compose over the local handle the host opened: reads try local, then
268
+ // remote (hydrating local on a remote hit); writes go local immediately
269
+ // and the remote upload drains in the background. All of that is core's
270
+ // LayeredCache — this plugin supplies only the wire.
271
+ return new LayeredCache(ctx.localCache, remote, {
272
+ policy: ctx.policy,
273
+ onRemoteError: (err) => ctx.warn(`vx/reapi: ${err.message}`),
274
+ })
275
+ },
276
+ })
277
+ }