withruntime 0.7.2 → 0.8.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.
@@ -0,0 +1,768 @@
1
+ import { clientFor, defaultRegion, } from "./client.js";
2
+ import { checkEnvNames, envFileCommand, envFileLines, parseProcessLine, toRuntimePath, } from "./context.js";
3
+ import { codeOf, guard, NotSupportedError, responseError, translate } from "./errors.js";
4
+ import { SandboxFileSystem } from "./filesystem.js";
5
+ import { ARCHIVED, DISPLAY_NAME, EXTERNAL_ID, IDLE_PAUSE, LABEL, findByExternalId, findSandbox, LEASE_SECONDS, ownLabels, STANDBY, userLabels, whenNameFree, } from "./lookup.js";
6
+ import { paginate } from "./pagination.js";
7
+ import { portAccess, SandboxPreviews } from "./preview.js";
8
+ import { SandboxProcess } from "./process.js";
9
+ import { appendEnvs, envRecord, KEEP_DAYS, refuseApplication, SandboxSnapshotsResource, startFromSnapshot, } from "./snapshot.js";
10
+ import { normalizeEnvs, normalizePorts, normalizeVolumes, } from "./types.js";
11
+ import { CODEGEN, DRIVES, SCHEDULES, SESSIONS, SYSTEM, unsupportedPart, } from "./unsupported.js";
12
+ /* Blaxel's SandboxInstance over a Runtime sandbox. Every call is one or a few
13
+ calls of the withruntime SDK; nothing here speaks HTTP. What Runtime cannot
14
+ do the way Blaxel does throws NotSupportedError before anything happens.
15
+
16
+ A Blaxel sandbox goes to standby by itself when unused and resumes on the
17
+ next call, and lives until deleted or until its TTL. On Runtime it pauses
18
+ after the shortest idle time Runtime allows (60 s), wakes on the next call,
19
+ pauses rather than ends when its lease runs out, and a paused one is kept
20
+ 365 days unless a TTL says otherwise: never deleted before Blaxel would
21
+ delete it (BLAXEL.md, "Standby and lifetime"). */
22
+ /** Blaxel's defaults (checked 27 September 2026). */
23
+ export const DEFAULT_IMAGE = "blaxel/base-image:latest";
24
+ export const DEFAULT_MEMORY = 4096;
25
+ /** Blaxel gives a sandbox one vCPU per 2048 MB of memory. */
26
+ export const MEMORY_PER_VCPU = 2048;
27
+ const MAX_VCPU = 16;
28
+ /** Blaxel's stock images whose tools Runtime's stock image has (Node.js,
29
+ * Python, a shell, Docker after `sudo enable-docker`), and the Jupyter server
30
+ * that CodeInterpreter uses, whose work Runtime's interpreter does. */
31
+ const STOCK_IMAGE = /^(docker\.io\/)?blaxel\/(prod-)?(base|base-image|py-app|ts-app|node|jupyter-server|docker-in-sandbox)(:latest)?$/;
32
+ /** Blaxel regions in the United States, where Runtime runs. */
33
+ const US_REGIONS = new Set(["us-pdx-1", "us-was-1", "auto"]);
34
+ const REGION = "us-was-1";
35
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
36
+ const DAY_MS = 86_400_000;
37
+ /** The lease is renewed in the background once this little of it is left. */
38
+ const RENEW_BELOW_MS = 10 * 60_000;
39
+ /** The labels a new sandbox carries: the caller's, and under `blaxel/` what
40
+ * Blaxel keeps and Runtime has no field for, so any client (the Python
41
+ * adapter too) reads the same `spec`. */
42
+ export function labelsFor(config, image) {
43
+ const ports = normalizePorts(config.ports);
44
+ const mine = {
45
+ image,
46
+ memory: String(config.memory || DEFAULT_MEMORY),
47
+ ...(config.externalId ? { externalId: config.externalId } : {}),
48
+ ...(ports ? { ports: ports.map((port) => port.target).join(",") } : {}),
49
+ ...(config.region ? { region: config.region } : {}),
50
+ ...(config.ttl ? { ttl: config.ttl } : {}),
51
+ ...(config.expires ? { expires: config.expires.toISOString() } : {}),
52
+ ...(config.lifecycle ? { lifecycle: JSON.stringify(config.lifecycle) } : {}),
53
+ };
54
+ return {
55
+ ...Object.fromEntries(Object.entries(config.labels ?? {}).map(([k, v]) => [k, String(v)])),
56
+ ...Object.fromEntries(Object.entries(mine).map(([k, v]) => [`${LABEL}${k}`, v])),
57
+ };
58
+ }
59
+ /** Port accesses for `sandbox.fetch`, by sandbox and port. */
60
+ const accesses = new Map();
61
+ /** A duration as Blaxel writes it ("30m", "24h", "7d", "1w", "1h30m"). */
62
+ export function durationMs(value) {
63
+ const match = /^(?:\d+[smhdw])+$/.exec(value.trim());
64
+ if (!match)
65
+ throw responseError(400, `invalid duration "${value}": use units s, m, h, d or w`);
66
+ const unit = { s: 1000, m: 60_000, h: 3_600_000, d: DAY_MS, w: 7 * DAY_MS };
67
+ let total = 0;
68
+ for (const [, count, letter] of value.matchAll(/(\d+)([smhdw])/g))
69
+ total += Number(count) * unit[letter];
70
+ return total;
71
+ }
72
+ /** The lease and the paused retention that honour Blaxel's TTL, expiry and
73
+ * lifecycle: never deleted before Blaxel would delete it. A fixed deadline
74
+ * within the hour is a lease that ends the sandbox; any other deadline keeps
75
+ * a paused sandbox that many days, rounded up; none keeps it 365 days. */
76
+ export function lifetimeOf(asked) {
77
+ const now = Date.now();
78
+ const fixed = [];
79
+ const idle = [];
80
+ if (asked.ttl)
81
+ fixed.push(durationMs(asked.ttl));
82
+ if (asked.expires)
83
+ fixed.push(asked.expires.getTime() - now);
84
+ for (const policy of asked.lifecycle?.expirationPolicies ?? []) {
85
+ if (policy.action !== undefined && policy.action !== "delete")
86
+ throw new NotSupportedError(`The expiration action ${String(policy.action)}`, 'Use action "delete", the only one Blaxel defines.');
87
+ if (!policy.value)
88
+ throw responseError(400, "an expiration policy needs a value");
89
+ if (policy.type === "ttl-max-age")
90
+ fixed.push(durationMs(policy.value));
91
+ else if (policy.type === "ttl-idle")
92
+ idle.push(durationMs(policy.value));
93
+ else if (policy.type === "date") {
94
+ const at = Date.parse(policy.value);
95
+ if (Number.isNaN(at))
96
+ throw responseError(400, `invalid date "${policy.value}"`);
97
+ fixed.push(at - now);
98
+ }
99
+ else
100
+ throw responseError(400, `unknown expiration policy type ${String(policy.type)}`);
101
+ }
102
+ if (fixed.some((ms) => ms <= 0))
103
+ throw responseError(400, "the sandbox's expiry is in the past");
104
+ const soonest = fixed.length ? Math.min(...fixed) : Infinity;
105
+ const all = [...fixed, ...idle];
106
+ const days = all.length
107
+ ? Math.min(365, Math.max(1, Math.ceil(Math.min(...all) / DAY_MS)))
108
+ : KEEP_DAYS;
109
+ return soonest <= LEASE_SECONDS * 1000
110
+ ? { timeoutSeconds: Math.max(60, Math.ceil(soonest / 1000)), onLeaseEnd: "stop", days }
111
+ : { timeoutSeconds: LEASE_SECONDS, onLeaseEnd: "pause", days };
112
+ }
113
+ /** Blaxel's network settings as Runtime's rules: allowed and forbidden
114
+ * domains, from the network itself or from its proxy (Blaxel's current
115
+ * place for them). Anything else of the proxy is refused. */
116
+ export function networkRules(network) {
117
+ for (const [field, alternative] of [
118
+ [
119
+ "egress",
120
+ "Runtime's outbound traffic leaves from Runtime's addresses; allow destinations with allowedDomains.",
121
+ ],
122
+ ["firewall", "Allow destinations with allowedDomains and refuse them with forbiddenDomains."],
123
+ ["subnet", "Runtime chooses each sandbox's addresses."],
124
+ ])
125
+ if (network[field] !== undefined)
126
+ throw new NotSupportedError(`A sandbox network's ${field}`, alternative);
127
+ const proxy = (network.proxy ?? {});
128
+ const other = Object.keys(proxy).find((key) => key !== "allowedDomains" && key !== "forbiddenDomains");
129
+ if (other !== undefined)
130
+ throw new NotSupportedError(`A network proxy's ${other}`, "To send credentials to a host without the sandbox seeing them, use Runtime secrets (runtime.secrets): the egress proxy adds them on HTTPS to the hosts you name.");
131
+ const allow = [...(network.allowedDomains ?? []), ...(proxy.allowedDomains ?? [])];
132
+ const deny = [...(network.forbiddenDomains ?? []), ...(proxy.forbiddenDomains ?? [])];
133
+ return { internet: true, ...(allow.length ? { allow } : {}), ...(deny.length ? { deny } : {}) };
134
+ }
135
+ /** A Blaxel create in either of its shapes (a configuration, or a Sandbox
136
+ * model with metadata and spec) as one configuration. */
137
+ function configOf(input) {
138
+ if (!input)
139
+ return {};
140
+ if (!("metadata" in input) && !("spec" in input))
141
+ return input;
142
+ const model = input;
143
+ const runtime = model.spec?.runtime ?? {};
144
+ const config = {};
145
+ if (model.metadata?.name)
146
+ config.name = model.metadata.name;
147
+ if (model.metadata?.labels)
148
+ config.labels = model.metadata.labels;
149
+ if (model.metadata?.externalId)
150
+ config.externalId = model.metadata.externalId;
151
+ if (runtime.image)
152
+ config.image = runtime.image;
153
+ if (runtime.memory)
154
+ config.memory = runtime.memory;
155
+ if (runtime.ports)
156
+ config.ports = runtime.ports;
157
+ if (runtime.envs)
158
+ config.envs = runtime.envs.map((env) => ({ name: env.name ?? "", value: env.value ?? "" }));
159
+ if (runtime.ttl)
160
+ config.ttl = runtime.ttl;
161
+ if (runtime.expires)
162
+ config.expires = new Date(runtime.expires);
163
+ if (runtime.extraArgs)
164
+ config.extraArgs = runtime.extraArgs;
165
+ if (model.spec?.region)
166
+ config.region = model.spec.region;
167
+ if (model.spec?.lifecycle)
168
+ config.lifecycle = model.spec.lifecycle;
169
+ if (model.spec?.network)
170
+ config.network = model.spec.network;
171
+ if (model.spec?.volumes)
172
+ config.volumes = model.spec.volumes;
173
+ if (model.withruntime)
174
+ config.withruntime = model.withruntime;
175
+ return config;
176
+ }
177
+ function checkRegion(region) {
178
+ if (region !== undefined && region !== "" && !US_REGIONS.has(region))
179
+ throw new NotSupportedError(`The region ${region}`, "Runtime runs in one US region: remove region (and BL_REGION), or use us-was-1.");
180
+ }
181
+ /** Where an image sends the create: Runtime's stock image, or a ready Runtime
182
+ * image by name or id. */
183
+ async function resolveImage(client, image) {
184
+ if (image === undefined || STOCK_IMAGE.test(image))
185
+ return {};
186
+ if (UUID.test(image))
187
+ return { image };
188
+ const name = image.replace(/:latest$/, "");
189
+ let found;
190
+ try {
191
+ found = (await client.images.list({ name, state: "ready", limit: 1 })).data[0];
192
+ }
193
+ catch (error) {
194
+ // A name Runtime could not hold is simply not one of its images.
195
+ if (codeOf(error) !== "invalid_request")
196
+ throw translate(error);
197
+ }
198
+ if (found)
199
+ return { image: found.id };
200
+ throw new NotSupportedError(`The image ${image}, which is not a Runtime image`, `Build it as a Runtime image with that name and this call starts from it: \`npx withruntime image build --dockerfile Dockerfile --name ${name}\`, or runtime.images.build({ name: "${name}", image: "<registry reference>" }).`);
201
+ }
202
+ /** Blaxel volumes (by name) as Runtime volumes. */
203
+ async function resolveVolumes(client, volumes) {
204
+ if (!volumes?.length)
205
+ return [];
206
+ for (const volume of volumes) {
207
+ if (volume.type === "ephemeral")
208
+ throw new NotSupportedError("Ephemeral volumes", "Write scratch files under /workspace (or /tmp); they go with the sandbox.");
209
+ if (volume.readOnly)
210
+ throw new NotSupportedError("Read-only volume mounts", 'Mount it read-write, or give the sandbox a copy with withruntime: { create: { volumes: [{ volumeId, path, mode: "snapshot" }] } }.');
211
+ }
212
+ return Promise.all(volumes.map(async (volume) => {
213
+ const found = (await guard(() => client.volumes.list({ name: volume.name }))).data[0];
214
+ if (!found)
215
+ throw new NotSupportedError(`The volume ${volume.name}, which is not a Runtime volume`, `Create it first: runtime.volumes.create({ name: "${volume.name}" }) or \`npx withruntime volume create --name ${volume.name}\`.`);
216
+ return { volumeId: found.id, path: toRuntimePath(volume.mountPath) };
217
+ }));
218
+ }
219
+ function statusOf(state) {
220
+ switch (state) {
221
+ case "starting":
222
+ return "DEPLOYING";
223
+ case "stopping":
224
+ return "DELETING";
225
+ case "stopped":
226
+ return "TERMINATED";
227
+ default:
228
+ return "DEPLOYED";
229
+ }
230
+ }
231
+ /** A sandbox with Blaxel's methods. `sandbox.withruntime` is the Runtime
232
+ * sandbox underneath, for anything Blaxel has no name for. */
233
+ export class SandboxInstance {
234
+ fs;
235
+ network;
236
+ process;
237
+ previews;
238
+ snapshots;
239
+ sessions = unsupportedPart("sandbox.sessions", SESSIONS);
240
+ schedules = unsupportedPart("sandbox.schedules", SCHEDULES);
241
+ codegen = unsupportedPart("sandbox.codegen", CODEGEN);
242
+ system = unsupportedPart("sandbox.system", SYSTEM);
243
+ drives = unsupportedPart("sandbox.drives", DRIVES);
244
+ /** Blaxel's HTTP/2 session; Runtime's SDK keeps its own connections. */
245
+ h2Session = null;
246
+ #rt;
247
+ #client;
248
+ #asked;
249
+ #deleted = false;
250
+ #renewing;
251
+ /** Use SandboxInstance.create, createIfNotExists or get. */
252
+ constructor(resolved) {
253
+ this.#rt = resolved.runtime;
254
+ this.#client = resolved.client;
255
+ this.#asked = resolved.asked ?? {};
256
+ const run = (work) => this.#run(work);
257
+ this.fs = new SandboxFileSystem({ run });
258
+ this.process = new SandboxProcess({
259
+ id: this.#rt.id,
260
+ live: () => this.#live(),
261
+ wake: () => this.#wake(),
262
+ keepAwake: (seconds) => this.#keepAwake(seconds),
263
+ keepAliveEnded: () => this.#giveBackIdle(),
264
+ });
265
+ this.previews = new SandboxPreviews({
266
+ sandboxName: this.name,
267
+ run,
268
+ labels: () => this.#rt.info.labels,
269
+ setLabels: (changes) => this.#setLabels(changes),
270
+ });
271
+ this.snapshots = new SandboxSnapshotsResource({
272
+ sandboxName: this.name,
273
+ sandboxId: this.#rt.id,
274
+ client: this.#client,
275
+ run,
276
+ labels: () => this.#rt.info.labels,
277
+ });
278
+ this.network = { fetch: (port, path, init) => this.fetch(port, path, init) };
279
+ }
280
+ /** The Runtime sandbox underneath: metrics, network rules, desktop,
281
+ * terminals and the rest of Runtime's SDK. */
282
+ get withruntime() {
283
+ return this.#rt;
284
+ }
285
+ /** The sandbox's name, or its Runtime id when it was made without one. */
286
+ get name() {
287
+ return this.#rt.info.name ?? this.#rt.id;
288
+ }
289
+ get metadata() {
290
+ const info = this.#rt.info;
291
+ const labels = info.labels ?? {};
292
+ return {
293
+ name: this.name,
294
+ labels: userLabels(labels),
295
+ ...(labels[DISPLAY_NAME] ? { displayName: labels[DISPLAY_NAME] } : {}),
296
+ ...(labels[EXTERNAL_ID] ? { externalId: labels[EXTERNAL_ID] } : {}),
297
+ createdAt: info.createdAt,
298
+ updatedAt: info.readyAt ?? info.createdAt,
299
+ };
300
+ }
301
+ /** From the sandbox's labels, so every client sees the same; the envs,
302
+ * network and volumes only in the client that created it. */
303
+ get spec() {
304
+ const asked = this.#asked;
305
+ const labels = this.#rt.info.labels ?? {};
306
+ const own = (key) => labels[`${LABEL}${key}`];
307
+ const image = own("image") ??
308
+ (typeof this.#rt.info.image === "string" ? this.#rt.info.image : DEFAULT_IMAGE);
309
+ const ports = (own("ports") ?? "")
310
+ .split(",")
311
+ .filter(Boolean)
312
+ .map((port) => ({ target: Number(port), protocol: "HTTP" }));
313
+ const lifecycle = own("lifecycle");
314
+ const region = own("region");
315
+ return {
316
+ enabled: true,
317
+ region: region && region !== "auto" ? region : REGION,
318
+ runtime: {
319
+ image,
320
+ memory: this.#rt.info.memoryMiB,
321
+ ...(ports.length ? { ports } : {}),
322
+ ...(asked.envs
323
+ ? { envs: Object.entries(asked.envs).map(([name, value]) => ({ name, value })) }
324
+ : {}),
325
+ ...(own("ttl") ? { ttl: own("ttl") } : {}),
326
+ ...(own("expires") ? { expires: own("expires") } : {}),
327
+ },
328
+ ...(lifecycle ? { lifecycle: JSON.parse(lifecycle) } : {}),
329
+ ...(asked.network ? { network: asked.network } : {}),
330
+ ...(asked.volumes ? { volumes: asked.volumes } : {}),
331
+ };
332
+ }
333
+ get status() {
334
+ const state = this.#rt.state;
335
+ if ((state === "paused" || state === "pausing") && this.#rt.info.labels?.[ARCHIVED] === "1")
336
+ return "ARCHIVED";
337
+ return statusOf(state);
338
+ }
339
+ /** RUNNING, or STANDBY for a paused sandbox. */
340
+ get state() {
341
+ const state = this.#rt.state;
342
+ if (state === "paused" || state === "pausing")
343
+ return "STANDBY";
344
+ if (state === "running" || state === "starting" || state === "resuming")
345
+ return "RUNNING";
346
+ return undefined;
347
+ }
348
+ get events() {
349
+ return [];
350
+ }
351
+ get errors() {
352
+ return [];
353
+ }
354
+ get lastUsedAt() {
355
+ return this.#rt.info.lastActiveAt ?? undefined;
356
+ }
357
+ get h2Domain() {
358
+ return null;
359
+ }
360
+ /** Seconds until Runtime deletes the sandbox: the end of a lease that ends
361
+ * it, or of a paused one's retention; undefined while not yet known. */
362
+ get expiresIn() {
363
+ const info = this.#rt.info;
364
+ const left = (at) => Math.max(0, Math.floor((Date.parse(at) - Date.now()) / 1000));
365
+ if (info.state === "paused" && info.pausedExpiresAt)
366
+ return left(info.pausedExpiresAt);
367
+ if ((info.state === "running" || info.state === "starting" || info.state === "resuming") &&
368
+ info.onLeaseEnd === "stop")
369
+ return left(info.expiresAt);
370
+ return undefined;
371
+ }
372
+ #model(status) {
373
+ const state = this.state;
374
+ return {
375
+ metadata: this.metadata,
376
+ spec: this.spec,
377
+ status: status ?? this.status,
378
+ ...(state ? { state } : {}),
379
+ events: [],
380
+ };
381
+ }
382
+ // ---- the live sandbox ---------------------------------------------------
383
+ /** The sandbox, ready for a call. A paused one wakes by itself on the call
384
+ * (autoWake); one made without autoWake is woken here. A lease nearing its
385
+ * end is renewed in the background, so a sandbox in use is not paused by
386
+ * its lease. */
387
+ async #live() {
388
+ if (this.#deleted)
389
+ throw responseError(404, `Sandbox ${this.name} was deleted.`);
390
+ const rt = this.#rt;
391
+ const state = rt.state;
392
+ if ((state === "paused" || state === "pausing") && rt.info.autoWake === false)
393
+ await this.#wake();
394
+ else if (state === "running" &&
395
+ rt.info.onLeaseEnd === "pause" &&
396
+ !this.#renewing &&
397
+ Date.parse(rt.info.expiresAt) - Date.now() < RENEW_BELOW_MS)
398
+ this.#renewing = rt
399
+ .extend(LEASE_SECONDS)
400
+ .catch(() => undefined)
401
+ .finally(() => (this.#renewing = undefined));
402
+ return this.#rt;
403
+ }
404
+ async #wake() {
405
+ await guard(() => this.#rt.wake({}));
406
+ }
407
+ /** Runs `work` on the live sandbox. A call refused because the sandbox was
408
+ * paused (autoWake off) never started, so it is made again once, after a
409
+ * wake. */
410
+ async #run(work) {
411
+ const runtime = await this.#live();
412
+ try {
413
+ return await work(runtime);
414
+ }
415
+ catch (error) {
416
+ if (codeOf(error) !== "sandbox_paused")
417
+ throw translate(error);
418
+ await this.#wake();
419
+ return guard(() => work(this.#rt));
420
+ }
421
+ }
422
+ /** Keeps the sandbox awake while a keepAlive process runs: its idle pause
423
+ * becomes the process's time limit (off for one without), and its lease
424
+ * covers that time, up to Runtime's hour. The idle pause it had is kept in
425
+ * the `blaxel/idlePauseSeconds` label, written in the same update and only
426
+ * when absent, to be given back once no keepAlive process runs. */
427
+ async #keepAwake(seconds) {
428
+ const rt = await this.#live();
429
+ const forever = seconds >= 86_400;
430
+ const idle = forever ? 0 : Math.max(STANDBY.idlePauseSeconds, Math.ceil(seconds));
431
+ const current = rt.info.idlePauseSeconds;
432
+ const raise = current !== 0 && (idle === 0 || current === undefined || idle > current);
433
+ const labels = rt.info.labels ?? {};
434
+ const need = Math.min(LEASE_SECONDS, forever ? LEASE_SECONDS : seconds) -
435
+ (Date.parse(rt.info.expiresAt) - Date.now()) / 1000;
436
+ await Promise.all([
437
+ raise
438
+ ? guard(() => rt.update({
439
+ idlePauseSeconds: idle,
440
+ labels: { ...labels, [IDLE_PAUSE]: labels[IDLE_PAUSE] ?? String(current ?? 0) },
441
+ }))
442
+ : undefined,
443
+ rt.info.onLeaseEnd === "pause" && need >= 1
444
+ ? rt.extend(Math.ceil(need)).catch(() => undefined)
445
+ : undefined,
446
+ ]);
447
+ }
448
+ /** Gives the sandbox back the idle pause it had before keepAlive raised it,
449
+ * once no keepAlive process runs, so no idle time is paid for after: the
450
+ * rule the Python adapter follows too. The label `blaxel/idlePauseSeconds`
451
+ * holds the idle pause to give back; a keepAlive process is one whose
452
+ * command line carries ": rt-blaxel-keep". Any client does it when it sees
453
+ * a keepAlive process end (a waited exec, wait, get), after stop or kill,
454
+ * and in SandboxInstance.get. With no label it costs nothing; a failure is
455
+ * left for the next of those, never thrown. */
456
+ async #giveBackIdle() {
457
+ const rt = this.#rt;
458
+ const labels = rt.info.labels ?? {};
459
+ const saved = labels[IDLE_PAUSE];
460
+ if (saved === undefined || this.#deleted)
461
+ return;
462
+ try {
463
+ const running = (await rt.processes.list()).some((one) => one.state === "running" && parseProcessLine(one.command)?.keepAlive);
464
+ if (running)
465
+ return;
466
+ const rest = { ...labels };
467
+ delete rest[IDLE_PAUSE];
468
+ await rt.update({ idlePauseSeconds: Number(saved), labels: rest });
469
+ }
470
+ catch {
471
+ // Given back at the next chance.
472
+ }
473
+ }
474
+ async #setLabels(changes) {
475
+ const labels = { ...this.#rt.info.labels };
476
+ for (const [key, value] of Object.entries(changes))
477
+ if (value === undefined)
478
+ delete labels[key];
479
+ else
480
+ labels[key] = value;
481
+ await guard(() => this.#rt.update({ labels }));
482
+ }
483
+ /** Sets the sandbox up after a create: its envs, in a file every process
484
+ * reads, and (paid sandboxes) how long a paused one is kept. */
485
+ async #setUp(envs, days) {
486
+ const rt = this.#rt;
487
+ await Promise.all([
488
+ Object.keys(envs).length
489
+ ? guard(() => rt.exec(envFileCommand(false), { stdin: envFileLines(envs) })).then((result) => {
490
+ if (result.exitCode !== 0)
491
+ throw responseError(500, `Setting the sandbox's envs failed: ${result.stderr.trim()}`);
492
+ })
493
+ : undefined,
494
+ rt.info.funding === "paid" ? guard(() => rt.setRetention(days)) : undefined,
495
+ ]);
496
+ }
497
+ // ---- create, get, list ---------------------------------------------------
498
+ /** Creates a sandbox and answers once it runs. Blaxel's defaults: the base
499
+ * image (Runtime's stock image) and 4096 MB, with vCPUs at one per 2048 MB.
500
+ * Funding is left to Runtime: the free trial while the account has trial
501
+ * time, then prepaid credit. A name another live sandbox holds is refused
502
+ * with a 409, as in Blaxel. */
503
+ static async create(sandbox, { createIfNotExist = false } = {}) {
504
+ const config = configOf(sandbox);
505
+ const region = config.region || defaultRegion();
506
+ checkRegion(region);
507
+ for (const key of Object.keys(config.extraArgs ?? {}))
508
+ if (key !== "iptables")
509
+ throw new NotSupportedError(`The extra argument ${key}`, "Remove it; iptables (iptables-legacy) is always there on Runtime.");
510
+ const envs = Object.fromEntries((normalizeEnvs(config.envs) ?? []).map((env) => [env.name, env.value]));
511
+ checkEnvNames(Object.keys(envs));
512
+ normalizePorts(config.ports);
513
+ const volumes = normalizeVolumes(config.volumes);
514
+ const lifetime = lifetimeOf(config);
515
+ const network = config.network ? networkRules(config.network) : undefined;
516
+ const memory = config.memory || DEFAULT_MEMORY;
517
+ const client = clientFor(config);
518
+ const [image, mounts] = await Promise.all([
519
+ guard(() => resolveImage(client, config.image)),
520
+ resolveVolumes(client, volumes),
521
+ ]);
522
+ const labels = labelsFor(config, config.image ?? DEFAULT_IMAGE);
523
+ const input = {
524
+ vcpu: Math.min(MAX_VCPU, Math.max(1, Math.round(memory / MEMORY_PER_VCPU))),
525
+ memoryMiB: memory,
526
+ ...STANDBY,
527
+ timeoutSeconds: lifetime.timeoutSeconds,
528
+ onLeaseEnd: lifetime.onLeaseEnd,
529
+ ...(config.name ? { name: config.name } : {}),
530
+ labels,
531
+ ...(network ? { network } : {}),
532
+ ...(mounts.length ? { volumes: mounts } : {}),
533
+ ...image,
534
+ ...(createIfNotExist && config.name ? { getOrCreate: true } : {}),
535
+ ...config.withruntime?.create,
536
+ };
537
+ const runtime = await guard(() => whenNameFree(client, () => client.sandboxes.create(input)));
538
+ const asked = {
539
+ ...(Object.keys(envs).length ? { envs } : {}),
540
+ ...(config.network ? { network: config.network } : {}),
541
+ ...(volumes ? { volumes } : {}),
542
+ };
543
+ // A sandbox that already held the name is answered as it is.
544
+ if (runtime.info.reused)
545
+ return new this({ runtime, client });
546
+ const instance = new this({ runtime, client, asked });
547
+ try {
548
+ await instance.#setUp(envs, lifetime.days);
549
+ }
550
+ catch (error) {
551
+ await runtime.stop({ wait: false }).catch(() => undefined);
552
+ throw translate(error);
553
+ }
554
+ return instance;
555
+ }
556
+ /** The named sandbox, or a new one when no live sandbox has the name.
557
+ * Runtime settles a race between two callers: both get the same sandbox. */
558
+ static async createIfNotExists(sandbox) {
559
+ return this.create(sandbox, { createIfNotExist: true });
560
+ }
561
+ /** The live sandbox with this name (or Runtime id). It is not woken: the
562
+ * next call wakes it. */
563
+ static async get(sandboxName, options = {}) {
564
+ const client = clientFor(options);
565
+ const runtime = await guard(() => findSandbox(client, sandboxName));
566
+ const sandbox = new this({ runtime, client });
567
+ await sandbox.#giveBackIdle();
568
+ return sandbox;
569
+ }
570
+ /** The newest live sandbox created with this externalId. */
571
+ static async getByExternalId(externalId, options = {}) {
572
+ const client = clientFor(options);
573
+ const runtime = await guard(() => findByExternalId(client, externalId));
574
+ return new this({ runtime, client });
575
+ }
576
+ /** Live sandboxes, oldest first, a page at a time; `for await` walks them
577
+ * all. `externalId` narrows; `showTerminated` adds ended ones. */
578
+ static async list(query = {}, options = {}) {
579
+ for (const field of ["cursor", "q", "anchor"])
580
+ if (query[field] !== undefined)
581
+ throw new NotSupportedError(`Listing sandboxes by ${field}`, "List them (oldest first) and filter the result yourself; nextPage() walks the pages.");
582
+ if (query.sort !== undefined && query.sort !== "createdAt:asc")
583
+ throw new NotSupportedError(`Listing sandboxes sorted by ${query.sort}`, "Runtime lists them oldest first: sort the result yourself.");
584
+ if (query.status !== undefined && query.status !== "DEPLOYED")
585
+ throw new NotSupportedError(`Listing sandboxes by status ${query.status}`, "List them and filter by sandbox.status yourself.");
586
+ const client = clientFor(options);
587
+ const page = await guard(() => client.sandboxes.list({
588
+ ...(query.externalId ? { labels: { [EXTERNAL_ID]: query.externalId } } : {}),
589
+ ...(query.showTerminated ? { includeStopped: true } : {}),
590
+ ...(query.limit ? { limit: Math.min(query.limit, 100) } : {}),
591
+ }));
592
+ return paginate(page, (runtime) => new this({ runtime, client }));
593
+ }
594
+ /** Deletes the sandbox: it ends at once, its files and memory with it. */
595
+ static async delete(sandboxName, options = {}) {
596
+ return (await this.get(sandboxName, options)).delete();
597
+ }
598
+ async delete() {
599
+ if (this.#deleted)
600
+ throw responseError(404, `Sandbox ${this.name} was deleted.`);
601
+ await guard(() => this.#rt.stop({ wait: false }));
602
+ this.#deleted = true;
603
+ for (const key of accesses.keys())
604
+ if (key.startsWith(`${this.#rt.id}:`))
605
+ accesses.delete(key);
606
+ return this.#model("DELETING");
607
+ }
608
+ // ---- updates -------------------------------------------------------------
609
+ /** Replaces the labels (when given), the display name and the externalId. */
610
+ static async updateMetadata(sandboxName, metadata, options = {}) {
611
+ const sandbox = await this.get(sandboxName, options);
612
+ const current = sandbox.#rt.info.labels;
613
+ const labels = {
614
+ ...(metadata.labels ?? userLabels(current)),
615
+ ...ownLabels(current),
616
+ ...(metadata.displayName !== undefined ? { [DISPLAY_NAME]: metadata.displayName } : {}),
617
+ ...(metadata.externalId !== undefined ? { [EXTERNAL_ID]: metadata.externalId } : {}),
618
+ };
619
+ await guard(() => sandbox.#rt.update({ labels }));
620
+ return sandbox;
621
+ }
622
+ /** A new TTL (null: none): how long a paused sandbox is kept follows it, as
623
+ * at create. A running lease is not shortened. */
624
+ static async updateTtl(sandboxName, ttl, options = {}) {
625
+ const sandbox = await this.get(sandboxName, options);
626
+ const labels = sandbox.#rt.info.labels ?? {};
627
+ const lifetime = lifetimeOf({
628
+ ttl: ttl || null,
629
+ ...(labels[`${LABEL}expires`] ? { expires: new Date(labels[`${LABEL}expires`]) } : {}),
630
+ ...(labels[`${LABEL}lifecycle`]
631
+ ? { lifecycle: JSON.parse(labels[`${LABEL}lifecycle`]) }
632
+ : {}),
633
+ });
634
+ await Promise.all([
635
+ sandbox.#setLabels({ [`${LABEL}ttl`]: ttl || undefined }),
636
+ sandbox.#rt.info.funding === "paid"
637
+ ? guard(() => sandbox.#rt.setRetention(lifetime.days))
638
+ : undefined,
639
+ ]);
640
+ return sandbox;
641
+ }
642
+ /** A new lifecycle (null: none), as updateTtl. */
643
+ static async updateLifecycle(sandboxName, lifecycle, options = {}) {
644
+ const sandbox = await this.get(sandboxName, options);
645
+ const labels = sandbox.#rt.info.labels ?? {};
646
+ const lifetime = lifetimeOf({
647
+ lifecycle,
648
+ ...(labels[`${LABEL}ttl`] ? { ttl: labels[`${LABEL}ttl`] } : {}),
649
+ ...(labels[`${LABEL}expires`] ? { expires: new Date(labels[`${LABEL}expires`]) } : {}),
650
+ });
651
+ await Promise.all([
652
+ sandbox.#setLabels({
653
+ [`${LABEL}lifecycle`]: lifecycle ? JSON.stringify(lifecycle) : undefined,
654
+ }),
655
+ sandbox.#rt.info.funding === "paid"
656
+ ? guard(() => sandbox.#rt.setRetention(lifetime.days))
657
+ : undefined,
658
+ ]);
659
+ return sandbox;
660
+ }
661
+ /** Replaces the sandbox's network rules. */
662
+ static async updateNetwork(sandboxName, network, options = {}) {
663
+ const sandbox = await this.get(sandboxName, options);
664
+ const rules = networkRules(network.network ?? {});
665
+ await sandbox.#run((runtime) => runtime.network.set(rules));
666
+ if (network.network)
667
+ sandbox.#asked.network = network.network;
668
+ return sandbox;
669
+ }
670
+ // ---- lifecycle ------------------------------------------------------------
671
+ /** Deprecated in Blaxel: a sandbox is ready when create answers. */
672
+ async wait(_options = {}) {
673
+ return this;
674
+ }
675
+ /** Archives the sandbox: on Runtime it pauses, keeping its files and also
676
+ * its memory and processes. The next call, or unarchive, wakes it. */
677
+ static async archive(sandboxName, options = {}) {
678
+ return (await this.get(sandboxName, options)).archive(options);
679
+ }
680
+ async archive(options = {}) {
681
+ await this.#live();
682
+ await this.#setLabels({ [ARCHIVED]: "1" });
683
+ if (this.#rt.state !== "paused" && this.#rt.state !== "pausing")
684
+ await guard(() => this.#rt.pause({ wait: options.wait !== false }));
685
+ return this;
686
+ }
687
+ static async unarchive(sandboxName, options = {}) {
688
+ return (await this.get(sandboxName, options)).unarchive(options);
689
+ }
690
+ async unarchive(options = {}) {
691
+ await this.#live();
692
+ await this.#setLabels({ [ARCHIVED]: undefined });
693
+ if (this.#rt.state === "paused" || this.#rt.state === "pausing")
694
+ await guard(() => this.#rt.wake({ wait: options.wait !== false }));
695
+ return this;
696
+ }
697
+ // ---- ports -------------------------------------------------------------------
698
+ /** A request to a port inside the sandbox, through a private preview of
699
+ * that port (made on first use, then reused) and its token. */
700
+ async fetch(port, path = "/", init) {
701
+ const key = `${this.#rt.id}:${port}`;
702
+ let access = accesses.get(key);
703
+ if (!access || access.until - Date.now() < 60_000) {
704
+ access = await this.#run((runtime) => portAccess(runtime, port));
705
+ accesses.set(key, access);
706
+ }
707
+ const headers = new Headers(init?.headers);
708
+ if (access.token)
709
+ headers.set("x-runtime-preview-token", access.token);
710
+ return fetch(`${access.url}${path.startsWith("/") ? path : `/${path}`}`, { ...init, headers });
711
+ }
712
+ // ---- snapshots and forks ----------------------------------------------------
713
+ /** Deprecated in Blaxel: sandbox.snapshots.create(name). */
714
+ async snapshot(name) {
715
+ return modelOfSnapshot(await this.snapshots.create(name));
716
+ }
717
+ /** Deprecated in Blaxel: sandbox.snapshots.list(). */
718
+ async listSnapshots() {
719
+ return (await this.snapshots.list()).map(modelOfSnapshot);
720
+ }
721
+ /** Deprecated in Blaxel: sandbox.snapshots.delete(name). */
722
+ async deleteSnapshot(snapshotId) {
723
+ await this.snapshots.delete(snapshotId);
724
+ }
725
+ restore(snapshotId) {
726
+ return this.snapshots.restore(snapshotId);
727
+ }
728
+ /** A new sandbox named `targetName`: a copy of this one as it is now
729
+ * (files, memory and running processes), or started from a snapshot with
730
+ * `snapshotId`. `envs` are added over this sandbox's. */
731
+ async fork(targetName, options = {}) {
732
+ refuseApplication(options);
733
+ const envs = envRecord(options.envs);
734
+ checkEnvNames(Object.keys(envs));
735
+ const labels = userLabels(this.#rt.info.labels);
736
+ if (options.snapshotId !== undefined) {
737
+ const id = await this.snapshots.idOf(options.snapshotId);
738
+ await startFromSnapshot(this.#client, id, targetName, labels, envs);
739
+ return { name: targetName, snapshotId: id, type: "sandbox" };
740
+ }
741
+ const copy = await this.#run((runtime) => whenNameFree(this.#client, () => runtime.fork({ name: targetName, labels })));
742
+ try {
743
+ await Promise.all([
744
+ appendEnvs(copy, envs),
745
+ copy.info.funding === "paid" ? guard(() => copy.setRetention(KEEP_DAYS)) : undefined,
746
+ ]);
747
+ }
748
+ catch (error) {
749
+ await copy.stop({ wait: false }).catch(() => undefined);
750
+ throw error;
751
+ }
752
+ return { name: targetName, snapshotId: "", type: "sandbox" };
753
+ }
754
+ static fromSession(_session) {
755
+ return Promise.reject(new NotSupportedError("Blaxel sessions (SandboxInstance.fromSession)", "Use SandboxInstance.get(name) with a Runtime key; to reach a port from a browser, share it with sandbox.previews.create and a token."));
756
+ }
757
+ }
758
+ function modelOfSnapshot(snapshot) {
759
+ return {
760
+ id: snapshot.id,
761
+ name: snapshot.name,
762
+ status: snapshot.status,
763
+ workspace: snapshot.workspace,
764
+ createdAt: snapshot.createdAt,
765
+ ...(snapshot.source ? { source: snapshot.source, sandboxName: snapshot.source.name } : {}),
766
+ ...(snapshot.spec ? { spec: snapshot.spec } : {}),
767
+ };
768
+ }