zygo-sdk 0.1.3

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.js ADDED
@@ -0,0 +1,1112 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Zygo — warm sandboxes for function-shaped code, from Node.
4
+ *
5
+ * import { connect } from 'zygo-sdk';
6
+ *
7
+ * const client = connect(); // `zygo api` on loopback
8
+ * const resize = client.fn('resize'); // a function from sandbox.toml
9
+ * const out = await resize({ url: '…' }); // ~2 ms, a fresh process
10
+ *
11
+ * A warm function costs about a millisecond and gets a clean process per
12
+ * request. A one-shot sandbox costs tens of milliseconds and needs nothing
13
+ * declared in advance:
14
+ *
15
+ * const r = await client.run('node:22-slim', ['node', '-e', 'console.log(6*7)']);
16
+ *
17
+ * A refused request — {@link Busy}, or {@link Unavailable} while the host is
18
+ * still building something — can be retried for you: `connect(url, { retries:
19
+ * 3 })` waits the server's `Retry-After` and sends it again. Off by default.
20
+ *
21
+ * This package talks to `zygo api` over HTTP — on a unix socket when the API
22
+ * is on this machine, which is the usual case and needs no token. It has no
23
+ * dependencies, and `node:http` handles both transports and connection reuse,
24
+ * so there is nothing here that a runtime does not already ship.
25
+ *
26
+ * What it is *not* is a second implementation of Zygo. Every boundary a
27
+ * sandbox has is built by the Zygo binary and enforced by the kernel; nothing
28
+ * here can widen one, and an API started without `--allow-deploy` will not let
29
+ * this package create a sandbox at all.
30
+ */
31
+
32
+ import { randomBytes } from 'node:crypto';
33
+ import http from 'node:http';
34
+ import https from 'node:https';
35
+
36
+ import { DEFAULT_URL, parse, resolve } from './endpoint.js';
37
+ import {
38
+ AuthError,
39
+ Busy,
40
+ Cancelled,
41
+ HandlerError,
42
+ NotFound,
43
+ SpecError,
44
+ Stuck,
45
+ Timeout,
46
+ TransportError,
47
+ Unavailable,
48
+ ZygoError,
49
+ fromResponse,
50
+ } from './errors.js';
51
+
52
+ export {
53
+ AuthError,
54
+ Busy,
55
+ Cancelled,
56
+ DEFAULT_URL,
57
+ HandlerError,
58
+ NotFound,
59
+ SpecError,
60
+ Stuck,
61
+ Timeout,
62
+ TransportError,
63
+ Unavailable,
64
+ ZygoError,
65
+ parse as parseEndpoint,
66
+ };
67
+
68
+ /**
69
+ * Largest answer read into memory. The API's own request limit is the same
70
+ * order, and an answer past it is a bug rather than a large result.
71
+ */
72
+ const MAX_BODY = 64 * 1024 * 1024;
73
+
74
+ /**
75
+ * A connection to a Zygo API.
76
+ *
77
+ * `retries` is how many times a *refused* request is sent again before its
78
+ * error reaches you: a {@link Busy} (the pool was full) or an
79
+ * {@link Unavailable} (the host is still building or warming something). Both
80
+ * mean the request never ran, which is what makes sending it again safe.
81
+ * Nothing else is retried — a handler that threw will throw again, and a
82
+ * request the deadline killed did run. Off by default. Each wait is the longer
83
+ * of the server's `Retry-After` and `backoff` seconds doubled per attempt.
84
+ */
85
+ export class Client {
86
+ /**
87
+ * @param {string} [url]
88
+ * @param {{token?: string|null, timeout?: number, retries?: number, backoff?: number,
89
+ * tenant?: string|null, agent?: import('http').Agent}} [options]
90
+ */
91
+ constructor(url, options = {}) {
92
+ this.endpoint = resolve(url);
93
+ // The environment by default, the same variable the server reads, so a
94
+ // shell that can start the API can also talk to it.
95
+ this.token = options.token !== undefined ? options.token : process.env.ZYGO_API_TOKEN ?? null;
96
+ // Bounds one HTTP exchange. Generous on purpose: the request's real limit
97
+ // is the function's own `timeout` and the supervisor enforces it, so a
98
+ // client that gives up first only loses the answer.
99
+ this.timeout = options.timeout ?? 300_000;
100
+ this.retries = Math.max(0, Math.floor(options.retries ?? 0));
101
+ this.backoff = Math.max(0, options.backoff ?? 1);
102
+ /** Sent as `X-Zygo-Tenant`; set by {@link Client#forTenant}. */
103
+ this._tenantId = options.tenant ?? null;
104
+
105
+ // Keep-alive is what keeps this client's overhead off the warm path: a
106
+ // fresh connection per call would cost more than a warm request does. The
107
+ // agent also pools, so concurrent callers become concurrent sandbox
108
+ // requests rather than a queue behind one socket.
109
+ const transport = this.endpoint.tls ? https : http;
110
+ this._transport = transport;
111
+ // `agent` is how `forTenant` shares this pool rather than opening a
112
+ // second one. Undocumented on purpose: it is an internal seam, not a
113
+ // knob, and passing a foreign agent here is a way to lose keep-alive.
114
+ this._agent = options.agent ?? new transport.Agent({ keepAlive: true, maxSockets: 64 });
115
+ }
116
+
117
+ /** Close every pooled connection. Calling it twice is harmless. */
118
+ close() {
119
+ this._agent.destroy();
120
+ }
121
+
122
+ // ---- the API ------------------------------------------------------
123
+
124
+ /**
125
+ * The server's version, and the version of the HTTP surface itself.
126
+ * `api` is what to check against: it is bumped only when a route changes
127
+ * incompatibly, so it stays put across releases that change what happens
128
+ * behind them.
129
+ */
130
+ version() {
131
+ return this.#request('GET', '/version');
132
+ }
133
+
134
+ /**
135
+ * `GET /healthz`, which needs no token.
136
+ *
137
+ * `status` is `ok`, `degraded` — a pool below its `min_warm`, so requests
138
+ * work but the first of them pay a cold start — or `stopping`, which is the
139
+ * only one that is not a 200.
140
+ */
141
+ health() {
142
+ return this.#request('GET', '/healthz', { authenticated: false });
143
+ }
144
+
145
+ /**
146
+ * Stop admitting, let what is running finish, then exit.
147
+ *
148
+ * Answers before the process leaves: `inFlight` is what was still running
149
+ * when the grace ran out, so `0` is a clean drain. `SIGTERM` does the same.
150
+ * Operator-only, and needs deploy rights.
151
+ */
152
+ async drain(grace = 30) {
153
+ const body = await this.#request('POST', `/drain?grace_ms=${Math.round(grace * 1000)}`);
154
+ return { drained: Boolean(body.drained), inFlight: Number(body.in_flight ?? 0) };
155
+ }
156
+
157
+ /** Every warm function the supervisor holds. */
158
+ async functions() {
159
+ const body = await this.#request('GET', '/fn');
160
+ return (body.functions ?? []).map(parseFunction);
161
+ }
162
+
163
+ /** One function's counters. */
164
+ async stats(name) {
165
+ return parseFunction(await this.#request('GET', `/fn/${esc(name)}/stats`));
166
+ }
167
+
168
+ /**
169
+ * Bring a registered function up now, without calling it. For the moment
170
+ * after a deploy, so the first real request does not pay for the warm-up.
171
+ */
172
+ warm(name) {
173
+ return this.#request('POST', `/fn/${esc(name)}/warm`);
174
+ }
175
+
176
+ /**
177
+ * Call a warm function and return what its handler returned.
178
+ *
179
+ * Throws {@link HandlerError} when the handler threw, {@link Timeout} when
180
+ * the deadline killed the request, and {@link Busy} when the function is at
181
+ * its concurrency limit — the last of which means the request never ran.
182
+ *
183
+ * `key` is a name *you* choose for this request, so that something else can
184
+ * stop it with {@link cancel} before it answers — the server's own id only
185
+ * arrives with the answer, which is too late to cancel the call it belongs
186
+ * to. `signal` does the same thing through an `AbortController`: aborting it
187
+ * sends the cancel for you.
188
+ *
189
+ * @param {string} name
190
+ * @param {unknown} [event]
191
+ * @param {{timeout?: number, key?: string, signal?: AbortSignal}} [options]
192
+ * `timeout` in seconds.
193
+ */
194
+ async call(name, event = null, options = {}) {
195
+ const key = options.key || (options.signal ? requestKey() : undefined);
196
+ const headers = timeoutHeader(options.timeout);
197
+ if (key) headers['x-zygo-request-key'] = key;
198
+ const stop = this.#onAbort(options.signal, key);
199
+ try {
200
+ const path = `/fn/${esc(name)}${workspaceQuery(options.workspace, options.out)}`;
201
+ const body = await this.#request('POST', path, { body: event, headers });
202
+ return parseResult(body);
203
+ } finally {
204
+ stop();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Call a function and yield its output as it is produced.
210
+ *
211
+ * An async iterator. Each item is either a piece of the request's output —
212
+ * `{ kind: 'stdout' | 'stderr' | 'progress', data }` — or, exactly once and
213
+ * last, `{ kind: 'result', result, status }` carrying what {@link call}
214
+ * would have returned. If the request failed, iterating past the result
215
+ * throws what {@link call} would have thrown.
216
+ *
217
+ * ```js
218
+ * for await (const event of client.stream('render', { pages: 400 })) {
219
+ * if (event.kind === 'result') console.log(event.result.result);
220
+ * else process.stdout.write(event.data);
221
+ * }
222
+ * ```
223
+ *
224
+ * The connection is held for the whole request and is not pooled: it is in
225
+ * use for as long as the handler runs. Breaking out of the loop closes it,
226
+ * which does **not** cancel the request — pass a `key` or a `signal` and use
227
+ * {@link cancel} for that.
228
+ */
229
+ stream(name, event = null, options = {}) {
230
+ return this.#streamed(`/fn/${esc(name)}?stream=1`, event, options);
231
+ }
232
+
233
+ /** Run a script in a pool, yielding its output. See {@link stream}. */
234
+ streamScript(runtime, script, event = null, options = {}) {
235
+ const body = {
236
+ script: script.startsWith('sha256:') ? script : { source: script },
237
+ event,
238
+ };
239
+ if (options.entryPoint !== undefined) body.entry_point = options.entryPoint;
240
+ return this.#streamed(`/runtimes/${esc(runtime)}/call?stream=1`, body, options);
241
+ }
242
+
243
+ async *#streamed(path, body, options) {
244
+ const key = options.key || (options.signal ? requestKey() : undefined);
245
+ const headers = timeoutHeader(options.timeout);
246
+ headers.accept = 'application/x-ndjson';
247
+ if (key) headers['x-zygo-request-key'] = key;
248
+ const stop = this.#onAbort(options.signal, key);
249
+ try {
250
+ let response;
251
+ for (let attempt = 0; ; attempt += 1) {
252
+ response = await this.#open('POST', path, body, headers);
253
+ if ((response.statusCode ?? 0) < 400) break;
254
+ // A refusal — a bad token, no such function — is an ordinary JSON
255
+ // answer with a status, not a stream. Nothing has been yielded yet,
256
+ // so a refusal that is safe to send again is sent again.
257
+ const error = fromResponse(response.statusCode ?? 0, await readJson(response), retryAfterOf(response));
258
+ const wait = this.#retryWait(error, attempt);
259
+ if (wait === null) throw error;
260
+ await sleep(wait);
261
+ }
262
+ for await (const line of lines(response)) {
263
+ const raw = JSON.parse(line);
264
+ const event = parseEvent(raw);
265
+ yield event;
266
+ if (event.kind === 'result') {
267
+ if (!(event.status >= 200 && event.status < 300)) {
268
+ throw fromResponse(event.status, raw, 1);
269
+ }
270
+ return;
271
+ }
272
+ }
273
+ } finally {
274
+ stop();
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Store a tar this host will hold under its digest.
280
+ *
281
+ * For the case an embedder actually has: the same fixture across a thousand
282
+ * calls. Sent once, named with `workspace` on every call after — the bargain
283
+ * {@link putScript} makes for code. The body is the tar itself.
284
+ */
285
+ async putBlob(tar) {
286
+ const body = await this.#request('PUT', '/blobs', {
287
+ rawBody: Buffer.from(tar),
288
+ binary: true,
289
+ });
290
+ return { sha256: String(body.sha256 ?? ''), size: Number(body.size ?? 0), existed: Boolean(body.existed) };
291
+ }
292
+
293
+ /** Whether this host holds a blob, and how big it is. */
294
+ async blob(digest) {
295
+ const body = await this.#request('GET', `/blobs/${escDigest(digest)}`);
296
+ return { sha256: String(body.sha256 ?? ''), size: Number(body.size ?? 0), existed: true };
297
+ }
298
+
299
+ /** Forget a blob. Operator-only: the store is shared by digest. */
300
+ async deleteBlob(digest) {
301
+ const body = await this.#request('DELETE', `/blobs/${escDigest(digest)}`);
302
+ return Boolean(body.deleted);
303
+ }
304
+
305
+ /**
306
+ * Stop a request that is running.
307
+ *
308
+ * `requestId` is either the server's own id — from `X-Zygo-Request-Id`, or
309
+ * from a result that has already come back — or the `key` the call was made
310
+ * with, which is the only name a caller has for a request that has not
311
+ * answered yet.
312
+ *
313
+ * Answers as soon as the kill has been sent, not when the request has
314
+ * stopped: whoever is waiting on that request gets the outcome, as a
315
+ * {@link Cancelled}. `started` says whether the handler had begun — `false`
316
+ * is the better outcome, because no handler code ran at all.
317
+ *
318
+ * Throws {@link NotFound} when nothing is running under that name, which
319
+ * includes a request that finished a moment ago and one belonging to another
320
+ * tenant.
321
+ */
322
+ async cancel(requestId) {
323
+ return this.#request('DELETE', `/requests/${esc(requestId)}`);
324
+ }
325
+
326
+ /**
327
+ * Send a cancel when `signal` aborts, and return a function that unsubscribes.
328
+ *
329
+ * The cancel is fire-and-forget: the caller is already unwinding with their
330
+ * own abort error, and a request that had finished — or a connection that has
331
+ * gone — is "nothing left to stop" rather than something to report over the
332
+ * top of it.
333
+ */
334
+ #onAbort(signal, key) {
335
+ if (!signal || !key) return () => {};
336
+ const send = () => {
337
+ this.cancel(key).catch(() => {});
338
+ };
339
+ if (signal.aborted) {
340
+ send();
341
+ return () => {};
342
+ }
343
+ signal.addEventListener('abort', send, { once: true });
344
+ return () => signal.removeEventListener('abort', send);
345
+ }
346
+
347
+ /**
348
+ * Call a function with several events at once, answers in order.
349
+ *
350
+ * Each element is a result or the error that element would have thrown —
351
+ * returned rather than thrown, because one event being refused must not hide
352
+ * the answers to the others.
353
+ */
354
+ async batch(name, events, options = {}) {
355
+ const answers = await this.#request('POST', `/fn/${esc(name)}/batch`, {
356
+ body: [...events],
357
+ headers: timeoutHeader(options.timeout),
358
+ });
359
+ return answers.map(batchElement);
360
+ }
361
+
362
+ /**
363
+ * A function's recent log. `after` is a sequence number: zero for the last
364
+ * `limit` entries, and a previous page's `next` for everything since.
365
+ */
366
+ async logs(name, { after = 0, limit = 50, failed = false } = {}) {
367
+ const query = `?after=${Number(after)}&limit=${Number(limit)}&failed=${failed ? 'true' : 'false'}`;
368
+ const body = await this.#request('GET', `/fn/${esc(name)}/logs${query}`);
369
+ return {
370
+ name: String(body.name ?? name),
371
+ entries: (body.entries ?? []).map((e) => ({ ...e, seq: Number(e.seq ?? 0) })),
372
+ next: Number(body.next ?? 0),
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Warm a function, replacing whatever held the name.
378
+ *
379
+ * `layer` is a `[fn.<name>]` table as an object. `baseDir` is what its
380
+ * relative paths are relative to **on the host the API runs on**; it
381
+ * defaults to this process's working directory, which is right only when the
382
+ * two are the same machine.
383
+ *
384
+ * Needs an API started with `--allow-deploy`; without it this throws
385
+ * {@link AuthError} and says so.
386
+ */
387
+ async serve(name, layer, { baseDir, secrets = {}, ifChanged = false } = {}) {
388
+ const { resolve: resolvePath } = await import('node:path');
389
+ const body = await this.#request('PUT', `/fn/${esc(name)}`, {
390
+ body: {
391
+ layer: { ...layer },
392
+ base_dir: resolvePath(baseDir ?? process.cwd()),
393
+ secrets: { ...secrets },
394
+ if_changed: Boolean(ifChanged),
395
+ },
396
+ });
397
+ return {
398
+ name: String(body.name ?? name),
399
+ change: String(body.change ?? 'started'),
400
+ runtime: String(body.runtime ?? ''),
401
+ rssKb: Number(body.rss_kb ?? 0),
402
+ importsMs: Number(body.imports_ms ?? 0),
403
+ warmMs: Number(body.warm_ms ?? 0),
404
+ warnings: body.warnings ?? [],
405
+ };
406
+ }
407
+
408
+ /** Stop one function. Throws {@link NotFound} if it is not there. */
409
+ async stop(name) {
410
+ const body = await this.#request('DELETE', `/fn/${esc(name)}`);
411
+ return body.stopped ?? [];
412
+ }
413
+
414
+ /**
415
+ * Run one command in a fresh sandbox and collect its output.
416
+ *
417
+ * `layer` holds spec fields — `mem`, `cpu`, `pids`, `timeout`, `network`,
418
+ * `allow`, `mounts`, `env` — written exactly as they would be in
419
+ * `sandbox.toml`. Mount sources must be absolute, because a relative path in
420
+ * a request body has no directory to be relative to.
421
+ *
422
+ * A non-zero exit code is not an error: the sandbox ran, and this is what it
423
+ * said. Only Zygo failing to run it at all throws.
424
+ *
425
+ * Needs an API started with `--allow-deploy`.
426
+ */
427
+ async run(image, cmd = null, { stdin = '', ...layer } = {}) {
428
+ const described = { ...layer, image };
429
+ if (cmd !== null) described.cmd = [...cmd];
430
+ const body = await this.#request('POST', '/run', { body: { layer: described, stdin } });
431
+ // `timedOut` and `oomKilled` are *why* it ended, which the exit code
432
+ // cannot carry: a deadline kill and an out-of-memory kill are both
433
+ // SIGKILL, so both are 137.
434
+ return {
435
+ exitCode: Number(body.exit_code ?? -1),
436
+ stdout: String(body.stdout ?? ''),
437
+ stderr: String(body.stderr ?? ''),
438
+ timedOut: Boolean(body.timed_out),
439
+ oomKilled: Boolean(body.oom_killed),
440
+ peakRssKb: Number(body.peak_rss_kb ?? 0),
441
+ wallMs: Number(body.wall_ms ?? 0),
442
+ // Whether the program ran at all. `false` is Zygo failing to build the
443
+ // sandbox — *unavailable*, not the program's failure — and `phase`
444
+ // says how far it got. An API one release behind does not send it,
445
+ // and only answered once the program had run.
446
+ started: body.started === undefined ? true : Boolean(body.started),
447
+ phase: String(body.phase ?? 'run'),
448
+ get ok() {
449
+ return this.started && this.exitCode === 0 && !this.timedOut && !this.oomKilled;
450
+ },
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Register a customer, or find the one already registered.
456
+ *
457
+ * Idempotent, so a deploy that runs twice is not an error. A tenant owns the
458
+ * scripts registered for it, and its functions and pools live in a cgroup of
459
+ * its own — which is what makes {@link deleteTenant} able to stop everything
460
+ * of theirs and nobody else's. Operator-only.
461
+ */
462
+ async createTenant(id) {
463
+ const body = await this.#request('POST', '/tenants', { body: { id } });
464
+ return body.tenant ?? {};
465
+ }
466
+
467
+ /** Every tenant this host holds. Operator-only. */
468
+ async tenants() {
469
+ const body = await this.#request('GET', '/tenants');
470
+ return body.tenants ?? [];
471
+ }
472
+
473
+ /** One tenant. Throws {@link NotFound} if there is no such id. */
474
+ async tenant(id) {
475
+ const body = await this.#request('GET', `/tenants/${esc(id)}`);
476
+ return body.tenant ?? {};
477
+ }
478
+
479
+ /**
480
+ * Forget a tenant: stop its work, then remove the scripts only it had.
481
+ *
482
+ * Answers with what it stopped and what it removed, because neither can be
483
+ * reconstructed afterwards. Operator-only, and needs `--allow-deploy`.
484
+ */
485
+ async deleteTenant(id) {
486
+ return this.#request('DELETE', `/tenants/${esc(id)}`);
487
+ }
488
+
489
+ /**
490
+ * What a tenant may not exceed: `mem`, `cpu`, `pids`, `timeout`, `scratch`,
491
+ * `network`, `allow`.
492
+ *
493
+ * They only ever **narrow**: applied as the minimum of themselves and
494
+ * whatever the function or pool was declared with, so the worst a wrong
495
+ * value can do is give a customer less than they were promised.
496
+ *
497
+ * A value above every ceiling the tenant currently has is refused with 422,
498
+ * naming the key — it could not take effect, and storing it would leave you
499
+ * believing you had tightened something you had not.
500
+ *
501
+ * Partial: the keys you pass are set and the rest are left alone.
502
+ * Operator-only, and needs deploy rights.
503
+ */
504
+ async setLimits(tenant, limits) {
505
+ const body = await this.#request('PATCH', `/tenants/${esc(tenant)}/limits`, {
506
+ body: limits,
507
+ });
508
+ return body.tenant ?? {};
509
+ }
510
+
511
+ /**
512
+ * Store one of a tenant's secrets, and get back their names.
513
+ *
514
+ * The body is the value itself, and the host encrypts it the moment it
515
+ * lands. Operator-only, and needs deploy rights.
516
+ */
517
+ async putSecret(tenant, name, value) {
518
+ const body = await this.#request('PUT', `/tenants/${esc(tenant)}/secrets/${esc(name)}`, {
519
+ rawBody: Buffer.from(String(value)),
520
+ });
521
+ return body.secrets ?? [];
522
+ }
523
+
524
+ /**
525
+ * The **names** a tenant has. There is no way to read a value back.
526
+ *
527
+ * A tenant may read its own; anything else is the operator's.
528
+ */
529
+ async secrets(tenant) {
530
+ const body = await this.#request('GET', `/tenants/${esc(tenant)}/secrets`);
531
+ return body.secrets ?? [];
532
+ }
533
+
534
+ /** Forget one. Operator-only, and needs deploy rights. */
535
+ async deleteSecret(tenant, name) {
536
+ const body = await this.#request('DELETE', `/tenants/${esc(tenant)}/secrets/${esc(name)}`);
537
+ return body.secrets ?? [];
538
+ }
539
+
540
+ /**
541
+ * Mint an API token, and get its secret — once.
542
+ *
543
+ * Without `tenant` this is an **operator** token: tenants, functions, pools,
544
+ * and more tokens. With one it is that tenant's, and it may register scripts
545
+ * and call, for itself only. Minting for a tenant registers the tenant if it
546
+ * is new.
547
+ *
548
+ * The secret is in the answer and nowhere else. The server keeps a SHA-256
549
+ * of it, so it cannot be fetched again; keep it or revoke it.
550
+ *
551
+ * Operator-only, and needs deploy rights.
552
+ */
553
+ async mintToken(tenant = null) {
554
+ const path = tenant == null ? '/tokens' : `/tenants/${esc(tenant)}/tokens`;
555
+ const body = await this.#request('POST', path);
556
+ return { token: body.token ?? {}, secret: body.secret ?? '' };
557
+ }
558
+
559
+ /** Every token this host holds, revoked ones included. Never a secret. */
560
+ async tokens() {
561
+ const body = await this.#request('GET', '/tokens');
562
+ return body.tokens ?? [];
563
+ }
564
+
565
+ /**
566
+ * Revoke one, from the next request onwards.
567
+ *
568
+ * The record stays, marked with when it went, so an id in a log line still
569
+ * resolves to something.
570
+ */
571
+ async revokeToken(id) {
572
+ return this.#request('DELETE', `/tokens/${esc(id)}`);
573
+ }
574
+
575
+ /**
576
+ * A view of this client that acts for one tenant.
577
+ *
578
+ * Every call through it carries the tenant, so scripts are registered
579
+ * against them and pool calls may only name their own. The connection is
580
+ * shared — this is a header, not a second client.
581
+ *
582
+ * For an **operator** token: the header says which of your customers you are
583
+ * acting for. A **tenant** token already names its tenant and does not need
584
+ * this — and the server refuses a header that disagrees with the token
585
+ * rather than ignoring it.
586
+ */
587
+ forTenant(id) {
588
+ // A real `new Client`, not a copy of this one's properties: the request
589
+ // path is a private class field, which `Object.assign` does not carry, so
590
+ // a view built that way looked like a client and threw `Receiver must be
591
+ // an instance of class Client` on its first call.
592
+ //
593
+ // The connection pool is passed rather than rebuilt, so this stays a
594
+ // header on the same client. Closing either closes both.
595
+ return new Client(this.endpoint.url, {
596
+ token: this.token,
597
+ timeout: this.timeout,
598
+ retries: this.retries,
599
+ backoff: this.backoff,
600
+ tenant: id,
601
+ agent: this._agent,
602
+ });
603
+ }
604
+
605
+ /**
606
+ * Register a **runtime pool**: an image, a dependency set, an agent.
607
+ *
608
+ * A pool holds no code. Scripts arrive with each call, so one pool serves
609
+ * ten thousand of them where ten thousand functions would be ten thousand
610
+ * warm zygotes. `layer` is a `[runtime.<name>]` table as an object —
611
+ * `image`, `agent`, `min_warm`, `max_warm` and the limits.
612
+ *
613
+ * `deps` is an id from {@link putDeps}. A pool named against one that is
614
+ * still building **throws** rather than starting: a zygote warmed without
615
+ * the dependencies it was promised serves requests that fail at import. The
616
+ * right reaction is to wait and send the same call again.
617
+ *
618
+ * `secrets` is a list of **names**, not values — the difference from
619
+ * {@link serve}. A pool is shared, so no value belongs to it: each call is
620
+ * given the *calling* tenant's values from the tenant store
621
+ * ({@link putSecret}), as files under `/run/secrets`, for that call only. A
622
+ * call from a tenant that lacks one of the names throws {@link BadRequest}
623
+ * before anything runs.
624
+ *
625
+ * Needs an API started with `--allow-deploy`.
626
+ */
627
+ async serveRuntime(name, layer, { baseDir, deps, secrets } = {}) {
628
+ const body = { name, layer: { ...layer } };
629
+ if (secrets !== undefined) body.layer.secrets = [...secrets];
630
+ if (baseDir !== undefined) {
631
+ const { resolve: resolvePath } = await import('node:path');
632
+ body.base_dir = resolvePath(baseDir);
633
+ }
634
+ if (deps !== undefined) body.deps = deps;
635
+ return this.#request('POST', '/runtimes', { body });
636
+ }
637
+
638
+ /**
639
+ * Build a dependency set from a lockfile, inside `image`.
640
+ *
641
+ * The API-native half of the spec's `requirements`, which names a file on
642
+ * the Zygo host — the one thing an embedder has none of. Send the files
643
+ * themselves: `{ 'package.json': …, 'package-lock.json': … }` for Node, or
644
+ * `{ 'requirements.txt': … }` for Python.
645
+ *
646
+ * **Answers before the build finishes.** An `npm ci` is minutes, so this
647
+ * returns as soon as the files are on disk, with `state === 'building'`.
648
+ * Idempotent by content: the same files against the same image are the same
649
+ * id, however many callers send them.
650
+ *
651
+ * The build runs in a sandbox that can reach the package registries and
652
+ * nothing else, because installing a package runs that package's code.
653
+ */
654
+ async putDeps(image, files) {
655
+ const encoded = {};
656
+ for (const [name, content] of Object.entries(files)) {
657
+ encoded[name] = Buffer.from(content).toString('base64');
658
+ }
659
+ return depsOf(await this.#request('POST', '/deps', { body: { image, files: encoded } }));
660
+ }
661
+
662
+ /** One dependency set with its build log, or every one you can see. */
663
+ async deps(id) {
664
+ if (id === undefined) {
665
+ const body = await this.#request('GET', '/deps');
666
+ return (body.deps ?? []).map(depsOf);
667
+ }
668
+ return depsOf(await this.#request('GET', `/deps/${esc(id)}`));
669
+ }
670
+
671
+ /**
672
+ * Forget a dependency set.
673
+ *
674
+ * Refused while a pool is built on it: the pool holds a read-only mount of
675
+ * the directory, and removing it under a warm zygote would leave the pool
676
+ * serving requests whose imports fail one at a time. Stop the pool first.
677
+ */
678
+ async deleteDeps(id) {
679
+ const body = await this.#request('DELETE', `/deps/${esc(id)}`);
680
+ return Boolean(body.deleted ?? false);
681
+ }
682
+
683
+ /** Every runtime pool this host holds. */
684
+ async runtimes() {
685
+ const body = await this.#request('GET', '/runtimes');
686
+ return body.runtimes ?? [];
687
+ }
688
+
689
+ /** Stop a pool and drop its zygotes. */
690
+ async stopRuntime(name) {
691
+ const body = await this.#request('DELETE', `/runtimes/${esc(name)}`);
692
+ return body.stopped ?? [];
693
+ }
694
+
695
+ /**
696
+ * Run one script in a pool.
697
+ *
698
+ * `script` is either a `sha256:…` digest this host holds — register it once
699
+ * with {@link putScript} — or the source itself. The digest is the shape to
700
+ * build on: the bytes cross the wire once rather than on every call, and the
701
+ * host can put the file in the sandbox instead of sending it through the
702
+ * zygote.
703
+ */
704
+ async runScript(runtime, script, event = null, { entryPoint, timeout, key, signal, workspace, out } = {}) {
705
+ const body = {
706
+ script: script.startsWith('sha256:') ? script : { source: script },
707
+ event,
708
+ };
709
+ if (entryPoint !== undefined) body.entry_point = entryPoint;
710
+ if (workspace !== undefined) body.workspace = workspace;
711
+ const name = key || (signal ? requestKey() : undefined);
712
+ const headers = timeoutHeader(timeout);
713
+ if (name) headers['x-zygo-request-key'] = name;
714
+ const stop = this.#onAbort(signal, name);
715
+ try {
716
+ const path = `/runtimes/${esc(runtime)}/call${out ? '?out=1' : ''}`;
717
+ const answer = await this.#request('POST', path, { body, headers });
718
+ return parseResult(answer);
719
+ } finally {
720
+ stop();
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Register a script and get back the name the host gave it.
726
+ *
727
+ * The name is the SHA-256 of the bytes, so this is idempotent in the
728
+ * strongest sense: the same script registered twice — or by two tenants — is
729
+ * one file, and `existed` says which call wrote it. Register once and name
730
+ * the digest on every call after that.
731
+ *
732
+ * Needs an API started with `--allow-deploy`.
733
+ */
734
+ async putScript(source) {
735
+ const body = await this.#request('PUT', '/scripts', { rawBody: Buffer.from(source, 'utf8') });
736
+ return { sha256: String(body.sha256 ?? ''), size: Number(body.size ?? 0), existed: Boolean(body.existed) };
737
+ }
738
+
739
+ /**
740
+ * Whether this host holds a script, and how big it is.
741
+ *
742
+ * Never the bytes: a digest is not a capability, so a store that answered
743
+ * with the script would make every tenant's code readable by anyone who
744
+ * could guess what it was. Throws {@link NotFound} when the host does not
745
+ * have it.
746
+ */
747
+ async script(digest) {
748
+ const body = await this.#request('GET', `/scripts/${escDigest(digest)}`);
749
+ return { sha256: String(body.sha256 ?? ''), size: Number(body.size ?? 0), existed: true };
750
+ }
751
+
752
+ /** Forget a script. Throws {@link NotFound} if it was not there. */
753
+ async deleteScript(digest) {
754
+ const body = await this.#request('DELETE', `/scripts/${escDigest(digest)}`);
755
+ return Boolean(body.deleted);
756
+ }
757
+
758
+ /**
759
+ * A callable bound to one function. `client.fn('resize')(event)` reads
760
+ * better than repeating the name at every call site, and it is the shape an
761
+ * embedder wraps as a tool.
762
+ */
763
+ fn(name) {
764
+ const call = (event, options) => this.call(name, event, options);
765
+ call.name_ = name;
766
+ call.batch = (events, options) => this.batch(name, events, options);
767
+ call.stats = () => this.stats(name);
768
+ call.logs = (options) => this.logs(name, options);
769
+ call.warm = () => this.warm(name);
770
+ call.stop = () => this.stop(name);
771
+ return call;
772
+ }
773
+
774
+ // ---- transport ----------------------------------------------------
775
+
776
+ /**
777
+ * Send a request and resolve with the *response object*, unread.
778
+ *
779
+ * What {@link stream} needs and {@link #request} does not: a stream's body
780
+ * has no end to wait for, so the caller reads it a line at a time. A fresh
781
+ * agent rather than the pooled one, because this connection is in use for
782
+ * as long as the handler runs and a pooled connection is one that is
783
+ * finished with.
784
+ */
785
+ #open(method, path, body, headers) {
786
+ const payload = Buffer.from(JSON.stringify(body === undefined ? null : body));
787
+ const sent = {
788
+ 'content-type': 'application/json',
789
+ 'content-length': String(payload.length),
790
+ ...headers,
791
+ };
792
+ if (this.token) sent.authorization = `Bearer ${this.token}`;
793
+ if (this._tenantId) sent['x-zygo-tenant'] = this._tenantId;
794
+
795
+ const options = { method, path, headers: sent, agent: false, timeout: this.timeout };
796
+ if (this.endpoint.isUnix) options.socketPath = this.endpoint.socketPath;
797
+ else {
798
+ options.host = this.endpoint.host;
799
+ options.port = this.endpoint.port;
800
+ }
801
+
802
+ return new Promise((resolvePromise, reject) => {
803
+ const request = this._transport.request(options, resolvePromise);
804
+ request.on('error', (e) =>
805
+ reject(new TransportError(`${method} ${path} failed against ${this.endpoint.url}: ${e.message}`))
806
+ );
807
+ request.end(payload);
808
+ });
809
+ }
810
+
811
+ async #request(method, path, { body = undefined, headers = {}, authenticated = true, rawBody = undefined, binary = false } = {}) {
812
+ // `rawBody` is for the routes whose body is not JSON: a script is a file
813
+ // and a blob is a tar, and wrapping either in a JSON string to unwrap it
814
+ // again is a transformation with no reader.
815
+ const payload = rawBody !== undefined ? rawBody : body === undefined ? null : Buffer.from(JSON.stringify(body));
816
+ const sent = { accept: 'application/json', ...headers };
817
+ if (payload !== null) {
818
+ sent['content-type'] =
819
+ rawBody === undefined
820
+ ? 'application/json'
821
+ : binary
822
+ ? 'application/octet-stream'
823
+ : 'text/plain; charset=utf-8';
824
+ sent['content-length'] = String(payload.length);
825
+ }
826
+ if (authenticated && this.token) sent.authorization = `Bearer ${this.token}`;
827
+ if (this._tenantId) sent['x-zygo-tenant'] = this._tenantId;
828
+
829
+ for (let attempt = 0; ; attempt += 1) {
830
+ try {
831
+ return await this.#exchange(method, path, payload, sent);
832
+ } catch (error) {
833
+ const wait = this.#retryWait(error, attempt);
834
+ if (wait === null) throw error;
835
+ await sleep(wait);
836
+ }
837
+ }
838
+ }
839
+
840
+ /**
841
+ * How long to wait before sending a refused request again, in seconds, or
842
+ * `null` when it should not be sent again.
843
+ *
844
+ * Only a refusal qualifies — the request never ran — and only while
845
+ * `retries` allows. The wait honours the server's `Retry-After`: it never
846
+ * sleeps less than that, and it grows past it on repeated refusals so a
847
+ * client does not hammer a host that keeps saying no.
848
+ */
849
+ #retryWait(error, attempt) {
850
+ if (attempt >= this.retries) return null;
851
+ if (!(error instanceof Busy) && !(error instanceof Unavailable)) return null;
852
+ return Math.max(Number(error.retryAfter) || 0, this.backoff * 2 ** attempt);
853
+ }
854
+
855
+ /** One request and its answer, on a pooled connection. */
856
+ #exchange(method, path, payload, sent) {
857
+ const options = {
858
+ method,
859
+ path,
860
+ headers: sent,
861
+ agent: this._agent,
862
+ timeout: this.timeout,
863
+ };
864
+ if (this.endpoint.isUnix) {
865
+ options.socketPath = this.endpoint.socketPath;
866
+ } else {
867
+ options.host = this.endpoint.host;
868
+ options.port = this.endpoint.port;
869
+ }
870
+
871
+ return new Promise((resolvePromise, reject) => {
872
+ const request = this._transport.request(options, (response) => {
873
+ const chunks = [];
874
+ let size = 0;
875
+ response.on('data', (chunk) => {
876
+ size += chunk.length;
877
+ if (size > MAX_BODY) {
878
+ response.destroy();
879
+ reject(new TransportError(`the API answered with more than ${MAX_BODY} bytes`));
880
+ return;
881
+ }
882
+ chunks.push(chunk);
883
+ });
884
+ response.on('end', () => {
885
+ const raw = Buffer.concat(chunks).toString('utf8');
886
+ try {
887
+ resolvePromise(decode(response.statusCode ?? 0, raw, retryAfterOf(response)));
888
+ } catch (e) {
889
+ reject(e);
890
+ }
891
+ });
892
+ response.on('error', (e) =>
893
+ reject(new TransportError(`${method} ${path} failed against ${this.endpoint.url}: ${e.message}`))
894
+ );
895
+ });
896
+
897
+ request.on('timeout', () => {
898
+ request.destroy();
899
+ reject(new TransportError(`${method} ${path} got no answer within ${this.timeout} ms`));
900
+ });
901
+ request.on('error', (e) => {
902
+ const hint =
903
+ e.code === 'ECONNREFUSED' || e.code === 'ENOENT'
904
+ ? '\n -> nothing is listening there; start one with `zygo api`'
905
+ : '';
906
+ reject(new TransportError(`${method} ${path} failed against ${this.endpoint.url}: ${e.message}${hint}`));
907
+ });
908
+
909
+ if (payload !== null) request.write(payload);
910
+ request.end();
911
+ });
912
+ }
913
+ }
914
+
915
+ /** Open a client. See {@link Client} for what the arguments mean. */
916
+ export function connect(url, options = {}) {
917
+ return new Client(url, options);
918
+ }
919
+
920
+ // ---- parsing ----------------------------------------------------------
921
+
922
+ /**
923
+ * `setTimeout` as a promise, in seconds. The timer keeps the process alive on
924
+ * purpose: a script whose only pending work is a retry must not exit before
925
+ * sending it.
926
+ */
927
+ function sleep(seconds) {
928
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, Math.round(seconds * 1000)));
929
+ }
930
+
931
+ /**
932
+ * The server's `Retry-After` in seconds, or 1 when it sent none. `0` is a
933
+ * value — "now" — and not the absence of one, which `Number(h) || 1` got wrong.
934
+ */
935
+ function retryAfterOf(response) {
936
+ const header = response.headers['retry-after'];
937
+ if (header === undefined || header === '') return 1;
938
+ const seconds = Number(header);
939
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : 1;
940
+ }
941
+
942
+ function decode(status, raw, retryAfter) {
943
+ let body;
944
+ try {
945
+ body = raw ? JSON.parse(raw) : {};
946
+ } catch {
947
+ throw new TransportError(`the API answered HTTP ${status} with something that is not JSON`);
948
+ }
949
+ if (status >= 200 && status < 300) return body;
950
+ throw fromResponse(status, typeof body === 'object' && body !== null ? body : { error: String(body) }, retryAfter);
951
+ }
952
+
953
+ /**
954
+ * A name for one request, unique enough that a cancel finds only it.
955
+ *
956
+ * 128 bits from the crypto source. The server checks ownership as well, so this
957
+ * is the second lock rather than the only one.
958
+ */
959
+ function requestKey() {
960
+ return 'k-' + randomBytes(16).toString('hex');
961
+ }
962
+
963
+ /// One line of a streaming call. See {@link Client#stream}.
964
+ function parseEvent(raw) {
965
+ if (typeof raw.stream === 'string') {
966
+ return { kind: raw.stream, data: String(raw.data ?? '') };
967
+ }
968
+ return { kind: 'result', result: parseResult(raw), status: Number(raw.status ?? 200) };
969
+ }
970
+
971
+ /// Newline-delimited JSON off a response, a line at a time as it arrives.
972
+ async function* lines(response) {
973
+ let buffer = '';
974
+ for await (const chunk of response) {
975
+ buffer += chunk.toString('utf8');
976
+ let at;
977
+ while ((at = buffer.indexOf('\n')) >= 0) {
978
+ const line = buffer.slice(0, at).trim();
979
+ buffer = buffer.slice(at + 1);
980
+ if (line) yield line;
981
+ }
982
+ }
983
+ const last = buffer.trim();
984
+ if (last) yield last;
985
+ }
986
+
987
+ /// The whole of a response as JSON. For the refusal that is not a stream.
988
+ async function readJson(response) {
989
+ const parts = [];
990
+ for await (const chunk of response) parts.push(chunk);
991
+ try {
992
+ return JSON.parse(Buffer.concat(parts).toString('utf8') || '{}');
993
+ } catch {
994
+ return {};
995
+ }
996
+ }
997
+
998
+ /// `?workspace=…&out=1`, for the route whose body is the event itself.
999
+ ///
1000
+ /// Only a *blob* can be named here: an inline tar in a URL would be a megabyte
1001
+ /// of base64 in a request line, which every proxy in between has an opinion
1002
+ /// about. Use a pool's body for that.
1003
+ function workspaceQuery(blob, out) {
1004
+ const parts = [];
1005
+ if (blob !== undefined && blob !== null) {
1006
+ if (typeof blob !== 'string' || !blob.startsWith('sha256:')) {
1007
+ throw new SpecError(`\`${blob}\` is not a blob digest; store one with putBlob()`);
1008
+ }
1009
+ parts.push(`workspace=${blob}`);
1010
+ }
1011
+ if (out) parts.push('out=1');
1012
+ return parts.length ? `?${parts.join('&')}` : '';
1013
+ }
1014
+
1015
+ function parseResult(raw) {
1016
+ return {
1017
+ result: raw.result ?? null,
1018
+ requestId: String(raw.request_id ?? ''),
1019
+ // Already decoded: a caller wanting files back should get files, not an
1020
+ // encoding to undo.
1021
+ workspace: typeof raw.workspace === 'string' ? Buffer.from(raw.workspace, 'base64') : null,
1022
+ stdout: String(raw.stdout ?? ''),
1023
+ stderr: String(raw.stderr ?? ''),
1024
+ metrics: {
1025
+ wallMs: Number(raw.metrics?.wall_ms ?? 0),
1026
+ cpuMs: Number(raw.metrics?.cpu_ms ?? 0),
1027
+ peakRssKb: Number(raw.metrics?.peak_rss_kb ?? 0),
1028
+ },
1029
+ };
1030
+ }
1031
+
1032
+ function parseFunction(raw) {
1033
+ return {
1034
+ name: String(raw.name ?? ''),
1035
+ state: String(raw.state ?? ''),
1036
+ image: String(raw.image ?? ''),
1037
+ runtime: String(raw.runtime ?? ''),
1038
+ rssKb: Number(raw.rss_kb ?? 0),
1039
+ importsMs: Number(raw.imports_ms ?? 0),
1040
+ requests: Number(raw.requests ?? 0),
1041
+ failures: Number(raw.failures ?? 0),
1042
+ raw,
1043
+ };
1044
+ }
1045
+
1046
+ /**
1047
+ * One element of a batch: a result, or the error it would have thrown.
1048
+ *
1049
+ * Returned rather than thrown. A batch exists so that one refused event does
1050
+ * not hide the answers to the others, and throwing on the first bad element
1051
+ * would undo exactly that.
1052
+ */
1053
+ function batchElement(answer) {
1054
+ if (typeof answer !== 'object' || answer === null) {
1055
+ return new ZygoError(`unreadable batch element: ${JSON.stringify(answer)}`);
1056
+ }
1057
+ const status = Number(answer.status ?? 200);
1058
+ if (status >= 200 && status < 300) return parseResult(answer);
1059
+ return fromResponse(status, answer);
1060
+ }
1061
+
1062
+ function esc(name) {
1063
+ return encodeURIComponent(name);
1064
+ }
1065
+
1066
+ /**
1067
+ * One dependency set as a caller reads it.
1068
+ *
1069
+ * `ready` and `building` as booleans beside the string, because every caller
1070
+ * writes one of those two comparisons and a typo in `'buidling'` is a
1071
+ * condition that is silently never true.
1072
+ */
1073
+ function depsOf(raw) {
1074
+ const state = String(raw.state ?? 'building');
1075
+ return {
1076
+ id: String(raw.id ?? ''),
1077
+ state,
1078
+ ready: state === 'ready',
1079
+ building: state === 'building',
1080
+ kind: String(raw.kind ?? ''),
1081
+ image: String(raw.image ?? ''),
1082
+ error: raw.error ?? null,
1083
+ log: String(raw.log ?? ''),
1084
+ files: raw.files ?? {},
1085
+ // A dependency set is shared by content, so two customers who send the
1086
+ // same lockfile share one build and both are listed. Empty is the
1087
+ // operator's own.
1088
+ tenants: raw.tenants ?? [],
1089
+ };
1090
+ }
1091
+
1092
+ /**
1093
+ * A digest, checked here so it can go into the path as it stands.
1094
+ *
1095
+ * `encodeURIComponent` would escape the colon, and the route matches on the
1096
+ * segment it was given. Checking the shape instead of escaping it also means
1097
+ * `../../etc/passwd` is a mistake this client names, rather than a request
1098
+ * somebody's proxy might normalise into a different route.
1099
+ */
1100
+ function escDigest(digest) {
1101
+ if (!/^sha256:[0-9a-f]{64}$/.test(String(digest))) {
1102
+ throw new SpecError(
1103
+ `\`${digest}\` is not a script digest; expected sha256: followed by 64 lowercase hex digits`
1104
+ );
1105
+ }
1106
+ return digest;
1107
+ }
1108
+
1109
+ function timeoutHeader(seconds) {
1110
+ if (seconds === undefined || seconds === null) return {};
1111
+ return { 'x-zygo-timeout-ms': String(Math.max(1, Math.round(seconds * 1000))) };
1112
+ }