@pablofdezr/microvm 0.1.0

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/dist/client.js ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * The TypeScript client for the microvm API.
3
+ *
4
+ * The resource types in `types.gen.ts` are generated from `api/openapi.yaml`,
5
+ * the same file the server and the Go SDK are generated from. What is written
6
+ * here by hand is only what a generator does badly: the transport, typed
7
+ * errors, streaming, auto-pagination, and the few helpers that turn three calls
8
+ * into one.
9
+ *
10
+ * ```ts
11
+ * const client = new Client("http://127.0.0.1:8080", { token });
12
+ *
13
+ * const sb = await client.sandboxes.create({ image: "python" });
14
+ * try {
15
+ * await client.files.write(sb.id, "main.py", 'print("hello")');
16
+ * const exe = await client.run(sb.id, "python3", ["main.py"]);
17
+ * console.log(exe.stdout);
18
+ * } finally {
19
+ * await client.sandboxes.delete(sb.id);
20
+ * }
21
+ * ```
22
+ */
23
+ /** How long an ordinary request may take. Streams and waits opt out. */
24
+ export const DEFAULT_TIMEOUT_MS = 30_000;
25
+ /** Where a daemon listens unless told otherwise. */
26
+ export const DEFAULT_BASE_URL = "http://127.0.0.1:8080";
27
+ /** This SDK's version, sent in the User-Agent. */
28
+ export const SDK_VERSION = "0.1.0";
29
+ /** How many times a transient failure is retried unless told otherwise. */
30
+ export const DEFAULT_MAX_RETRIES = 2;
31
+ /**
32
+ * An error the API reported.
33
+ *
34
+ * `type` is what to branch on: it says what class of thing went wrong and so
35
+ * what to do about it. `code` says exactly which thing, for when that matters.
36
+ */
37
+ export class APIError extends Error {
38
+ status;
39
+ type;
40
+ code;
41
+ param;
42
+ requestId;
43
+ constructor(status, type, code, message, param, requestId) {
44
+ super(message);
45
+ this.status = status;
46
+ this.type = type;
47
+ this.code = code;
48
+ this.param = param;
49
+ this.requestId = requestId;
50
+ this.name = "APIError";
51
+ }
52
+ /** The object does not exist. */
53
+ get isNotFound() {
54
+ return this.status === 404;
55
+ }
56
+ /**
57
+ * The node has no room.
58
+ *
59
+ * The one error worth retrying unchanged — and the signal to consider a task
60
+ * instead, since tasks wait for a slot anywhere in the fleet rather than
61
+ * failing.
62
+ */
63
+ get isCapacity() {
64
+ return this.type === "capacity_error";
65
+ }
66
+ /** The object is in a state that forbids the call. */
67
+ get isConflict() {
68
+ return this.status === 409;
69
+ }
70
+ /**
71
+ * The key lacks permission — an ordinary token calling an admin-only endpoint,
72
+ * such as setting a tenant's policy. Distinct from a missing token: the
73
+ * request was authenticated, and refused.
74
+ */
75
+ get isForbidden() {
76
+ return this.status === 403;
77
+ }
78
+ }
79
+ export class Client {
80
+ sandboxes;
81
+ executions;
82
+ files;
83
+ tasks;
84
+ queue;
85
+ images;
86
+ tenants;
87
+ #baseURL;
88
+ #token;
89
+ #fetch;
90
+ #timeoutMs;
91
+ #maxRetries;
92
+ #onResponse;
93
+ constructor(baseURL = DEFAULT_BASE_URL, opts = {}) {
94
+ this.#baseURL = baseURL.replace(/\/+$/, "");
95
+ this.#token = opts.token;
96
+ // Bound, not merely captured: a detached `fetch` throws "Illegal
97
+ // invocation" in a browser.
98
+ this.#fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
99
+ this.#timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
100
+ this.#maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
101
+ this.#onResponse = opts.onResponse;
102
+ this.sandboxes = new SandboxResource(this);
103
+ this.executions = new ExecutionResource(this);
104
+ this.files = new FileResource(this);
105
+ this.tasks = new TaskResource(this);
106
+ this.queue = new QueueResource(this);
107
+ this.images = new ImageResource(this);
108
+ this.tenants = new TenantResource(this);
109
+ }
110
+ /** @internal */
111
+ async request(method, path, init = {}) {
112
+ const url = new URL(this.#baseURL + "/v1" + path);
113
+ for (const [k, v] of Object.entries(init.query ?? {})) {
114
+ if (v !== undefined)
115
+ url.searchParams.set(k, String(v));
116
+ }
117
+ const headers = {
118
+ "User-Agent": `microvm-ts/${SDK_VERSION}`,
119
+ };
120
+ if (this.#token)
121
+ headers["Authorization"] = `Bearer ${this.#token}`;
122
+ if (init.body !== undefined)
123
+ headers["Content-Type"] = "application/json";
124
+ if (init.opts?.idempotencyKey) {
125
+ headers["Idempotency-Key"] = init.opts.idempotencyKey;
126
+ }
127
+ const body = init.body === undefined ? undefined : JSON.stringify(init.body);
128
+ // Retrying a request that is not idempotent could run the work twice.
129
+ const idempotent = method === "GET" ||
130
+ method === "HEAD" ||
131
+ method === "PUT" ||
132
+ method === "DELETE" ||
133
+ !!init.opts?.idempotencyKey;
134
+ let lastErr;
135
+ for (let attempt = 1; attempt <= this.#maxRetries + 1; attempt++) {
136
+ // The caller's signal and our timeout must both be able to abort, and the
137
+ // caller's must survive: AbortSignal.any takes whichever fires first.
138
+ const signals = [];
139
+ if (init.opts?.signal)
140
+ signals.push(init.opts.signal);
141
+ let timer;
142
+ if (!init.noTimeout) {
143
+ const ctrl = new AbortController();
144
+ timer = setTimeout(() => ctrl.abort(), this.#timeoutMs);
145
+ signals.push(ctrl.signal);
146
+ }
147
+ const start = Date.now();
148
+ let resp;
149
+ let err;
150
+ try {
151
+ resp = await this.#fetch(url.toString(), {
152
+ method,
153
+ headers,
154
+ body,
155
+ signal: signals.length > 0 ? AbortSignal.any(signals) : undefined,
156
+ });
157
+ }
158
+ catch (e) {
159
+ err = e;
160
+ }
161
+ finally {
162
+ if (timer !== undefined)
163
+ clearTimeout(timer);
164
+ }
165
+ this.#onResponse?.({
166
+ method,
167
+ path,
168
+ attempt,
169
+ status: resp?.status ?? 0,
170
+ error: err,
171
+ durationMs: Date.now() - start,
172
+ });
173
+ const callerAborted = init.opts?.signal?.aborted ?? false;
174
+ if (err !== undefined) {
175
+ // A network error or a timeout. A caller-triggered abort is final; our
176
+ // own timeout on an idempotent call is worth another try.
177
+ lastErr = err;
178
+ if (idempotent && attempt <= this.#maxRetries && !callerAborted) {
179
+ await backoff(attempt, undefined, init.opts?.signal);
180
+ continue;
181
+ }
182
+ throw err;
183
+ }
184
+ if (!resp.ok) {
185
+ if (retryableStatus(resp.status) &&
186
+ idempotent &&
187
+ attempt <= this.#maxRetries) {
188
+ const retryAfter = resp.headers.get("Retry-After") ?? undefined;
189
+ await resp.text().catch(() => undefined); // free the body
190
+ await backoff(attempt, retryAfter, init.opts?.signal);
191
+ continue;
192
+ }
193
+ throw await parseError(resp);
194
+ }
195
+ return resp;
196
+ }
197
+ throw lastErr;
198
+ }
199
+ /** @internal */
200
+ async json(method, path, init = {}) {
201
+ const resp = await this.request(method, path, init);
202
+ return (await resp.json());
203
+ }
204
+ /** Whether the daemon is up. Needs no token. */
205
+ health(opts) {
206
+ return this.json("GET", "/health", { opts });
207
+ }
208
+ /**
209
+ * Start a command and wait for it to finish.
210
+ *
211
+ * Check `err(exe)` afterwards: a non-zero exit is the code's own verdict,
212
+ * whereas a timeout or a vanished sandbox is not.
213
+ */
214
+ async run(sandboxID, cmd, args = [], opts) {
215
+ const exe = await this.executions.create(sandboxID, { cmd, args }, opts);
216
+ return this.executions.wait(sandboxID, exe.id, opts);
217
+ }
218
+ }
219
+ /**
220
+ * Turns an error reply into an APIError.
221
+ *
222
+ * It falls back rather than throwing. A proxy in front of the daemon can answer
223
+ * with an HTML error page that is not our envelope at all, and a client that
224
+ * breaks on that breaks exactly when things are already going wrong.
225
+ */
226
+ async function parseError(resp) {
227
+ const requestId = resp.headers.get("X-Request-Id") ?? undefined;
228
+ const fallback = () => new APIError(resp.status, "api_error", "unknown", resp.statusText || `HTTP ${resp.status}`, undefined, requestId);
229
+ let body;
230
+ try {
231
+ body = await resp.json();
232
+ }
233
+ catch {
234
+ return fallback();
235
+ }
236
+ const err = body?.error;
237
+ if (!err?.code)
238
+ return fallback();
239
+ return new APIError(resp.status, err.type, err.code, err.message, err.param, err.request_id ?? requestId);
240
+ }
241
+ class SandboxResource {
242
+ c;
243
+ constructor(c) {
244
+ this.c = c;
245
+ }
246
+ /**
247
+ * Boot a sandbox and wait for it to be ready.
248
+ *
249
+ * Throws a capacity error when the node is full — see `APIError.isCapacity`.
250
+ * That is by design: a sandbox is a reservation, so you are told at once
251
+ * rather than left waiting. Submit a task if you would rather wait.
252
+ */
253
+ create(params, opts) {
254
+ return this.c.json("POST", "/sandboxes", { body: params, opts });
255
+ }
256
+ retrieve(id, opts) {
257
+ return this.c.json("GET", `/sandboxes/${id}`, { opts });
258
+ }
259
+ /**
260
+ * Kill the sandbox and get back its final cost.
261
+ *
262
+ * Those numbers are sampled just before the kill and cannot be had after: the
263
+ * accounting dies with the VM. This reply is the only record of what the
264
+ * sandbox consumed.
265
+ */
266
+ delete(id, opts) {
267
+ return this.c.json("DELETE", `/sandboxes/${id}`, { opts });
268
+ }
269
+ list(params = {}, opts) {
270
+ return this.c.json("GET", "/sandboxes", { query: params, opts });
271
+ }
272
+ /**
273
+ * Every sandbox, fetching pages as needed.
274
+ *
275
+ * Paging is mechanical and easy to get subtly wrong — forgetting `has_more`,
276
+ * or taking the cursor from the wrong end — and it is the same loop every
277
+ * time, so it lives here rather than in every caller.
278
+ */
279
+ async *all(params = {}, opts) {
280
+ let cursor = params.starting_after;
281
+ for (;;) {
282
+ const page = await this.list({ ...params, starting_after: cursor }, opts);
283
+ for (const sb of page.data)
284
+ yield sb;
285
+ if (!page.has_more || page.data.length === 0)
286
+ return;
287
+ cursor = page.data[page.data.length - 1].id;
288
+ }
289
+ }
290
+ }
291
+ class ExecutionResource {
292
+ c;
293
+ constructor(c) {
294
+ this.c = c;
295
+ }
296
+ /**
297
+ * Start a command and return at once, without waiting for it.
298
+ *
299
+ * The command belongs to the sandbox, not to this call: dropping the
300
+ * connection does not kill it. Follow it with `stream`, or collect it later
301
+ * with `retrieve`.
302
+ */
303
+ create(sandboxID, params, opts) {
304
+ return this.c.json("POST", `/sandboxes/${sandboxID}/executions`, {
305
+ body: params,
306
+ opts,
307
+ });
308
+ }
309
+ /**
310
+ * An execution and everything it printed.
311
+ *
312
+ * Works after the sandbox is gone, which is the point: the output you most
313
+ * want is from the run that was killed.
314
+ */
315
+ retrieve(sandboxID, executionID, opts) {
316
+ return this.c.json("GET", `/sandboxes/${sandboxID}/executions/${executionID}`, { opts });
317
+ }
318
+ list(sandboxID, params = {}, opts) {
319
+ return this.c.json("GET", `/sandboxes/${sandboxID}/executions`, {
320
+ query: params,
321
+ opts,
322
+ });
323
+ }
324
+ /**
325
+ * Signal a running execution.
326
+ *
327
+ * The signal reaches the whole process group, so a program that spawned
328
+ * children does not leave them behind. Defaults to SIGKILL. Cancelling
329
+ * something that already finished is not an error.
330
+ */
331
+ cancel(sandboxID, executionID, params = {}, opts) {
332
+ return this.c.json("POST", `/sandboxes/${sandboxID}/executions/${executionID}/cancel`, { body: params, opts });
333
+ }
334
+ /**
335
+ * Follow an execution's output as it is produced.
336
+ *
337
+ * The stream replays from the beginning before it follows, so connecting late
338
+ * — or reconnecting after a dropped connection — loses nothing. Aborting the
339
+ * signal stops watching; the execution keeps running, because it belongs to
340
+ * its sandbox. To stop the execution itself, use `cancel`.
341
+ *
342
+ * ```ts
343
+ * for await (const frame of client.executions.stream(sbID, exeID)) {
344
+ * if (frame.type === "stdout") process.stdout.write(frameText(frame));
345
+ * }
346
+ * ```
347
+ */
348
+ async *stream(sandboxID, executionID, opts) {
349
+ const resp = await this.c.request("GET", `/sandboxes/${sandboxID}/executions/${executionID}/stream`, { opts, noTimeout: true });
350
+ if (!resp.body)
351
+ throw new Error("microvm: the stream had no body");
352
+ const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
353
+ let buffer = "";
354
+ try {
355
+ for (;;) {
356
+ const { done, value } = await reader.read();
357
+ if (done)
358
+ break;
359
+ buffer += value;
360
+ // SSE events are separated by a blank line, and a network chunk can
361
+ // split one anywhere. Only whole events are parsed; the tail waits for
362
+ // the rest of itself.
363
+ let sep;
364
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
365
+ const event = buffer.slice(0, sep);
366
+ buffer = buffer.slice(sep + 2);
367
+ for (const line of event.split("\n")) {
368
+ if (!line.startsWith("data: "))
369
+ continue;
370
+ yield JSON.parse(line.slice(6));
371
+ }
372
+ }
373
+ }
374
+ }
375
+ finally {
376
+ // Matters on an early return: breaking out of the loop must not leave the
377
+ // connection held open.
378
+ await reader.cancel().catch(() => { });
379
+ }
380
+ }
381
+ /**
382
+ * Wait for an execution to finish.
383
+ *
384
+ * Polls rather than streams: streaming is for showing output as it appears,
385
+ * waiting is for knowing the result, and polling survives a dropped
386
+ * connection without any work from the caller.
387
+ */
388
+ async wait(sandboxID, executionID, opts) {
389
+ let delay = 25;
390
+ for (;;) {
391
+ const exe = await this.retrieve(sandboxID, executionID, opts);
392
+ if (exe.status !== "running")
393
+ return exe;
394
+ await sleep(delay, opts?.signal);
395
+ // Tight at first so a 50ms command is noticed in 50ms, easing off so a
396
+ // ten-minute one is not asked about twelve thousand times.
397
+ delay = Math.min(delay * 2, 1_000);
398
+ }
399
+ }
400
+ }
401
+ class FileResource {
402
+ c;
403
+ constructor(c) {
404
+ this.c = c;
405
+ }
406
+ /** Write a file into the sandbox, making parent directories. */
407
+ write(sandboxID, path, content, opts) {
408
+ return this.c.json("POST", `/sandboxes/${sandboxID}/files`, {
409
+ body: { path, content: encodeContent(content) },
410
+ opts,
411
+ });
412
+ }
413
+ /** Download a file's bytes. */
414
+ async retrieve(sandboxID, path, opts) {
415
+ const resp = await this.c.request("GET", `/sandboxes/${sandboxID}/files`, {
416
+ query: { path },
417
+ opts,
418
+ });
419
+ return new Uint8Array(await resp.arrayBuffer());
420
+ }
421
+ /** Download a file as text. */
422
+ async readText(sandboxID, path, opts) {
423
+ return new TextDecoder().decode(await this.retrieve(sandboxID, path, opts));
424
+ }
425
+ }
426
+ class TaskResource {
427
+ c;
428
+ constructor(c) {
429
+ this.c = c;
430
+ }
431
+ /**
432
+ * Queue work for the fleet.
433
+ *
434
+ * Unlike creating a sandbox this never fails for capacity: the task waits for
435
+ * a slot on any node. Use it for throughput, and a sandbox for several
436
+ * commands that share state.
437
+ */
438
+ create(params, opts) {
439
+ // The files arrive as text or bytes for the caller's convenience; the wire
440
+ // wants base64, so encode them here rather than leaving it as a trap.
441
+ const body = params.files
442
+ ? { ...params, files: encodeFiles(params.files) }
443
+ : params;
444
+ return this.c.json("POST", "/tasks", { body, opts });
445
+ }
446
+ retrieve(taskID, opts) {
447
+ return this.c.json("GET", `/tasks/${taskID}`, { opts });
448
+ }
449
+ /** Wait for a task to have a result. */
450
+ async wait(taskID, opts) {
451
+ let delay = 50;
452
+ for (;;) {
453
+ const task = await this.retrieve(taskID, opts);
454
+ if (task.status !== "pending" && task.status !== "running")
455
+ return task;
456
+ await sleep(delay, opts?.signal);
457
+ delay = Math.min(delay * 2, 2_000);
458
+ }
459
+ }
460
+ }
461
+ class QueueResource {
462
+ c;
463
+ constructor(c) {
464
+ this.c = c;
465
+ }
466
+ /**
467
+ * The queue's depth and this node's slots.
468
+ *
469
+ * The depth is the fleet's; the slots are this node's alone. No node knows
470
+ * the fleet's capacity, which is what lets one be added without telling
471
+ * anything else.
472
+ */
473
+ retrieve(opts) {
474
+ return this.c.json("GET", "/queue", { opts });
475
+ }
476
+ }
477
+ class ImageResource {
478
+ c;
479
+ constructor(c) {
480
+ this.c = c;
481
+ }
482
+ list(opts) {
483
+ return this.c.json("GET", "/images", { opts });
484
+ }
485
+ }
486
+ /**
487
+ * The `/v1/tenants` resource, and it is administrative: a tenant's storage cap
488
+ * is set by an operator, never by the code that runs under it. Updating needs an
489
+ * admin token; an ordinary key is refused with a 403 (see `APIError.isForbidden`).
490
+ */
491
+ class TenantResource {
492
+ c;
493
+ constructor(c) {
494
+ this.c = c;
495
+ }
496
+ /** Set a tenant's byte cap and full policy, replacing any previous one. */
497
+ update(tenantID, params, opts) {
498
+ return this.c.json("PUT", `/tenants/${tenantID}`, { body: params, opts });
499
+ }
500
+ /**
501
+ * `update` for the common case: a byte cap and a policy. Pass `"preserve"` to
502
+ * reject writes when full, or `"evict"` to delete the oldest objects to make
503
+ * room. A `maxBytes` of 0 means unlimited.
504
+ */
505
+ setLimit(tenantID, maxBytes, policy, opts) {
506
+ return this.update(tenantID, { max_bytes: maxBytes, policy }, opts);
507
+ }
508
+ /**
509
+ * A tenant's policy and its current usage, the usage read live from the bucket
510
+ * at call time (so it costs a listing — see `Tenant.usage_bytes`).
511
+ */
512
+ retrieve(tenantID, opts) {
513
+ return this.c.json("GET", `/tenants/${tenantID}`, { opts });
514
+ }
515
+ /** Every configured tenant. A tenant with no policy is absent: it is unlimited. */
516
+ list(opts) {
517
+ return this.c.json("GET", "/tenants", { opts });
518
+ }
519
+ }
520
+ /**
521
+ * Why an execution did not simply run to completion, or null.
522
+ *
523
+ * A non-zero exit returns null: the process ran, and that is its own verdict
524
+ * rather than a failure of ours. The endings that are *not* the code's doing —
525
+ * a timeout, a cancel, a VM taken away, a command that never started — return
526
+ * an error, because those are the ones that must not be mistaken for a program
527
+ * choosing to fail.
528
+ */
529
+ export function err(exe) {
530
+ switch (exe.status) {
531
+ case "running":
532
+ case "exited":
533
+ return null;
534
+ case "timed_out":
535
+ return new Error(`microvm: execution ${exe.id} exceeded its timeout and was killed`);
536
+ case "canceled":
537
+ return new Error(`microvm: execution ${exe.id} was cancelled`);
538
+ case "vanished":
539
+ return new Error(`microvm: the sandbox holding execution ${exe.id} was taken away ` +
540
+ `(its TTL, the idle reclaim, or the VM died); your code did not fail`);
541
+ case "failed":
542
+ return new Error(`microvm: execution ${exe.id}: ${exe.error ?? "the command could never start"}`);
543
+ default:
544
+ return new Error(`microvm: execution ${exe.id} has an unknown status`);
545
+ }
546
+ }
547
+ /** A frame's bytes. */
548
+ export function frameBytes(frame) {
549
+ return frame.data ? fromBase64(frame.data) : new Uint8Array(0);
550
+ }
551
+ /** A frame's bytes as text. */
552
+ export function frameText(frame) {
553
+ return new TextDecoder().decode(frameBytes(frame));
554
+ }
555
+ /**
556
+ * Sleep, honouring an abort signal.
557
+ *
558
+ * The listener is removed on the way out. Without that, polling in a loop
559
+ * against one long-lived signal adds a listener per iteration, and a wait that
560
+ * runs for an hour leaks steadily.
561
+ */
562
+ function sleep(ms, signal) {
563
+ return new Promise((resolve, reject) => {
564
+ if (signal?.aborted)
565
+ return reject(signal.reason);
566
+ const onAbort = () => {
567
+ clearTimeout(timer);
568
+ reject(signal.reason);
569
+ };
570
+ const timer = setTimeout(() => {
571
+ signal?.removeEventListener("abort", onAbort);
572
+ resolve();
573
+ }, ms);
574
+ signal?.addEventListener("abort", onAbort, { once: true });
575
+ });
576
+ }
577
+ /** Whether a status is worth another try: the server overloaded (429) or
578
+ * momentarily broken (5xx), not the request being wrong. */
579
+ function retryableStatus(status) {
580
+ return (status === 429 ||
581
+ status === 500 ||
582
+ status === 502 ||
583
+ status === 503 ||
584
+ status === 504);
585
+ }
586
+ /** Waits before the next attempt: Retry-After if the server gave one, else
587
+ * exponential backoff with full jitter, capped. Aborts with the signal. */
588
+ async function backoff(attempt, retryAfter, signal) {
589
+ const baseMs = 100;
590
+ const maxMs = 3_000;
591
+ let waitMs = parseRetryAfter(retryAfter);
592
+ if (waitMs <= 0) {
593
+ // base, 2x, 4x, ... capped; full jitter spreads a herd of clients that all
594
+ // failed at the same instant.
595
+ const ceil = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
596
+ waitMs = Math.floor(Math.random() * (ceil + 1));
597
+ }
598
+ await sleep(Math.min(waitMs, maxMs), signal);
599
+ }
600
+ /** Reads Retry-After in both forms: a number of seconds, or an HTTP date. An
601
+ * unparseable value is 0, which falls back to the client's own backoff. */
602
+ function parseRetryAfter(v) {
603
+ if (!v)
604
+ return 0;
605
+ const trimmed = v.trim();
606
+ const secs = Number(trimmed);
607
+ if (Number.isFinite(secs))
608
+ return secs > 0 ? secs * 1000 : 0;
609
+ const date = Date.parse(trimmed);
610
+ if (!Number.isNaN(date)) {
611
+ const delta = date - Date.now();
612
+ return delta > 0 ? delta : 0;
613
+ }
614
+ return 0;
615
+ }
616
+ /**
617
+ * Base64, in chunks.
618
+ *
619
+ * `String.fromCharCode(...bytes)` is the obvious one-liner and it throws on
620
+ * anything large: spreading a megabyte-long array overflows the call stack, and
621
+ * a file upload is exactly that size.
622
+ */
623
+ const CHUNK = 0x8000;
624
+ function toBase64(bytes) {
625
+ let binary = "";
626
+ for (let i = 0; i < bytes.length; i += CHUNK) {
627
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
628
+ }
629
+ return btoa(binary);
630
+ }
631
+ /** Base64 of file content given as text or raw bytes — the one place that turns
632
+ * what a caller has into what the wire wants, shared by uploads and tasks. */
633
+ function encodeContent(content) {
634
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
635
+ return toBase64(bytes);
636
+ }
637
+ /** A task's files map, each value encoded, the paths untouched. */
638
+ function encodeFiles(files) {
639
+ const out = {};
640
+ for (const [path, content] of Object.entries(files)) {
641
+ out[path] = encodeContent(content);
642
+ }
643
+ return out;
644
+ }
645
+ function fromBase64(b64) {
646
+ const binary = atob(b64);
647
+ const out = new Uint8Array(binary.length);
648
+ for (let i = 0; i < binary.length; i++)
649
+ out[i] = binary.charCodeAt(i);
650
+ return out;
651
+ }