withruntime 0.21.0 → 0.33.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.
Files changed (79) hide show
  1. package/COMPATIBILITY.md +64 -3
  2. package/E2B.md +1 -1
  3. package/README.md +36 -2
  4. package/README.src.md +35 -1
  5. package/compatibility-lock.json +3 -3
  6. package/dist/agents/browser.d.ts +12 -0
  7. package/dist/agents/browser.js +12 -0
  8. package/dist/agents/index.d.ts +7 -7
  9. package/dist/agents/index.js +7 -8
  10. package/dist/agents-cli.d.ts +1 -1
  11. package/dist/agents-cli.js +250 -3
  12. package/dist/agents-deploy.d.ts +186 -0
  13. package/dist/agents-deploy.js +752 -0
  14. package/dist/agents-file.d.ts +19 -0
  15. package/dist/agents-file.js +135 -0
  16. package/dist/apps/index.d.ts +10 -0
  17. package/dist/apps/index.js +11 -0
  18. package/dist/apps-cli.d.ts +3 -1
  19. package/dist/apps-cli.js +285 -38
  20. package/dist/blaxel/errors.js +2 -1
  21. package/dist/ci/index.d.ts +10 -0
  22. package/dist/ci/index.js +11 -0
  23. package/dist/ci-cli.d.ts +10 -0
  24. package/dist/ci-cli.js +343 -0
  25. package/dist/cli.d.ts +18 -7
  26. package/dist/cli.js +862 -99
  27. package/dist/daytona/errors.js +2 -1
  28. package/dist/e2b/errors.js +2 -1
  29. package/dist/e2b/filesystem.d.ts +1 -0
  30. package/dist/e2b/filesystem.js +1 -1
  31. package/dist/e2b/sandbox.d.ts +11 -2
  32. package/dist/e2b/sandbox.js +22 -6
  33. package/dist/errors.d.ts +11 -4
  34. package/dist/errors.js +27 -4
  35. package/dist/fly/index.d.ts +102 -0
  36. package/dist/fly/index.js +930 -0
  37. package/dist/index.d.ts +7 -3
  38. package/dist/keys.d.ts +6 -0
  39. package/dist/keys.js +10 -1
  40. package/dist/machine.d.ts +10 -6
  41. package/dist/machine.js +14 -6
  42. package/dist/modal/index.d.ts +34 -0
  43. package/dist/modal/index.js +128 -22
  44. package/dist/product-names.js +7 -0
  45. package/dist/products/agents.d.ts +101 -3
  46. package/dist/products/agents.js +49 -0
  47. package/dist/products/apps.d.ts +206 -10
  48. package/dist/products/apps.js +172 -1
  49. package/dist/products/ci.d.ts +332 -0
  50. package/dist/products/ci.js +101 -0
  51. package/dist/products/index.d.ts +8 -0
  52. package/dist/products/index.js +15 -0
  53. package/dist/products/jobs.d.ts +105 -5
  54. package/dist/products/jobs.js +47 -3
  55. package/dist/products/network.d.ts +13 -0
  56. package/dist/products/observability.d.ts +1 -1
  57. package/dist/products/plans.d.ts +131 -0
  58. package/dist/products/plans.js +24 -0
  59. package/dist/products/reservations.d.ts +108 -0
  60. package/dist/products/reservations.js +41 -0
  61. package/dist/products/resources.d.ts +2 -1
  62. package/dist/products/secrets.d.ts +109 -6
  63. package/dist/products/secrets.js +38 -2
  64. package/dist/proxy.d.ts +3 -3
  65. package/dist/proxy.js +20 -0
  66. package/dist/sandbox.d.ts +29 -1
  67. package/dist/sandbox.js +56 -3
  68. package/dist/snapshots.d.ts +1 -1
  69. package/dist/switched-off.d.ts +5 -0
  70. package/dist/switched-off.js +8 -0
  71. package/dist/tar.d.ts +1 -1
  72. package/dist/tar.js +19 -1
  73. package/dist/transport.d.ts +1 -1
  74. package/dist/transport.js +48 -9
  75. package/dist/types.d.ts +35 -4
  76. package/dist/vercel/errors.d.ts +1 -1
  77. package/dist/vercel/errors.js +2 -1
  78. package/dist/vercel/snapshot.js +4 -2
  79. package/package.json +23 -6
@@ -0,0 +1,930 @@
1
+ /* `withruntime/fly`: Fly.io's Machines API (api.machines.dev, OpenAPI at
2
+ docs.fly.io/api/machines/openapi.json) answered by Runtime sandboxes, so
3
+ code written for Fly Machines in any language moves by changing one
4
+ address. `machinesApi()` is a fetch handler for the /v1 routes;
5
+ `serveMachinesApi()` (and `npx withruntime fly-api`) serves it on a port.
6
+
7
+ How the words map:
8
+ - an app is a label (`fly.app`) on its machines; apps hold nothing else.
9
+ - a machine is a sandbox started from a Runtime image of the Machine's
10
+ `config.image`, built once per image and `init` as `FROM <image>` with
11
+ Fly's entrypoint and cmd rules, and `files` copied in. The image's start
12
+ command is the Machine's main process.
13
+ - states: running is started, paused is suspended, stopped is stopped. A
14
+ main process that has ended makes the machine stopped (and destroyed with
15
+ `auto_destroy`) the next time it is read, as on Fly.
16
+ - stop keeps the disk (Fly resets the root disk); start of a stopped one
17
+ restarts it from that disk; suspend pauses with memory, of any size.
18
+ - an idle machine pauses itself after a minute and wakes on the next
19
+ request, like Fly's `autostop: "suspend"`, billing only storage.
20
+ - the restart policy is kept by a supervisor in the machine: the main
21
+ process runs under a small shell loop that starts it again when it exits
22
+ (`always`, or `on-failure` up to `max_retries`, 10 by default, within
23
+ five minutes), as flyd does, and lets the machine stop otherwise.
24
+ Anything Fly does that this cannot keep (services, mounts, checks, other
25
+ regions) answers 400 naming Runtime's alternative, never accepted and
26
+ ignored. */
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import { NotFoundError, RuntimeError } from "../errors.js";
29
+ import { client } from "../compat/core.js";
30
+ const PROVIDER = "fly";
31
+ const APP = "fly.app";
32
+ const NAME = "fly.name";
33
+ const IMAGE = "fly.image";
34
+ const INIT = "fly.init";
35
+ /** The Runtime image the machine was made from, whose start command is its main process. */
36
+ const BUILT = "fly.built";
37
+ const MAIN = "fly.main";
38
+ const DESTROY = "fly.auto_destroy";
39
+ const LEASE = "fly.lease";
40
+ const META = "fly.meta.";
41
+ /** The restart policy, as `policy` or `policy:max_retries`. */
42
+ const RESTART = "fly.restart";
43
+ /** Where the supervisor reads the policy: the environment it started with,
44
+ * or a file an update writes, so a change reaches a running supervisor. */
45
+ const RESTART_ENV = "RUNTIME_FLY_RESTART";
46
+ const RESTART_FILE = "/tmp/.fly-restart";
47
+ /** The main process's pid, written by the supervisor before each run. */
48
+ const PID_FILE = "/tmp/.fly-main-pid";
49
+ const POLICIES = ["no", "always", "on-failure"];
50
+ /** How long a destroyed machine still reads as destroyed. */
51
+ const DESTROYED_KEPT_MS = 10 * 60_000;
52
+ /** Fly's regions that are next to Runtime's us-east: Ashburn, Virginia. */
53
+ const NEAR = ["iad"];
54
+ /** Runtime's regions as the Fly region next to each. */
55
+ const FLY_REGION = { "us-east": "iad" };
56
+ /** The parts of a Machine config this answers in full. */
57
+ const CONFIG = ["image", "env", "guest", "init", "metadata", "files", "auto_destroy", "restart"];
58
+ /** Parts Fly's own tools send empty or default, kept only when they are. */
59
+ const EMPTY_OK = ["services", "mounts", "checks", "processes", "statics", "dns", "stop_config"];
60
+ const ALTERNATIVES = {
61
+ services: "a preview of the port: sandbox.previews.create({ port }) or `runtime sandboxes preview <id> <port>`",
62
+ mounts: "Runtime volumes: runtime.volumes.create(), then sandboxes.create({ volumes: [{ volumeId, path }] })",
63
+ checks: "an image start check: images.build({ start: { readyPort } }) answers create when it passes",
64
+ processes: "one main process per machine; start others with sandbox.spawn()",
65
+ schedule: "Runtime jobs: runtime.jobs.create({ schedule })",
66
+ statics: "a preview of a static file server",
67
+ dns: "Runtime's default resolver",
68
+ standbys: "Runtime fork() for a warm copy",
69
+ containers: "one image per machine",
70
+ volumes: "Runtime volumes",
71
+ stop_config: "the default stop",
72
+ memory: "a machine's whole size: POST /machines/{id} with config.guest.memory_mb",
73
+ };
74
+ class FlyError extends Error {
75
+ status;
76
+ constructor(status, message) {
77
+ super(message);
78
+ this.status = status;
79
+ }
80
+ }
81
+ const refuse = (what) => {
82
+ const alternative = ALTERNATIVES[what];
83
+ throw new FlyError(400, `${what} is not supported by Runtime's Fly Machines API${alternative ? `; use ${alternative}` : ""}.`);
84
+ };
85
+ const json = (body, status = 200) => new Response(body === undefined ? null : JSON.stringify(body), {
86
+ status,
87
+ headers: body === undefined ? {} : { "content-type": "application/json" },
88
+ });
89
+ const ok = () => json({ ok: true });
90
+ /** A Machine's state as Fly words it, from the sandbox's. */
91
+ export function machineState(state) {
92
+ switch (state) {
93
+ case "running":
94
+ return "started";
95
+ case "paused":
96
+ return "suspended";
97
+ case "pausing":
98
+ return "suspending";
99
+ case "resuming":
100
+ case "starting":
101
+ return "starting";
102
+ default:
103
+ return state;
104
+ }
105
+ }
106
+ /** The Dockerfile that makes a Fly image and `init` one Runtime image: Fly's
107
+ * `exec` replaces both entrypoint and cmd, `entrypoint` drops the image's
108
+ * cmd unless `cmd` is given, and `cmd` keeps the entrypoint, as Docker does. */
109
+ export function dockerfileFor(image, init = {}, files = []) {
110
+ if (!/^[^\s"\\]+$/.test(image))
111
+ throw new FlyError(400, `config.image ${JSON.stringify(image)} is not an image reference.`);
112
+ const lines = [`FROM ${image}`];
113
+ files.forEach((file, index) => lines.push(`COPY fly-file-${index} ${JSON.stringify(file.guest_path)}`));
114
+ if (init.exec)
115
+ lines.push(`ENTRYPOINT ${JSON.stringify(init.exec)}`, "CMD []");
116
+ else {
117
+ if (init.entrypoint)
118
+ lines.push(`ENTRYPOINT ${JSON.stringify(init.entrypoint)}`);
119
+ if (init.cmd)
120
+ lines.push(`CMD ${JSON.stringify(init.cmd)}`);
121
+ else if (init.entrypoint)
122
+ lines.push("CMD []");
123
+ }
124
+ return `${lines.join("\n")}\n`;
125
+ }
126
+ /** The supervisor the main process runs under: it writes the pid it will
127
+ * exec as (so a signal reaches the program, not the loop), runs it in the
128
+ * foreground, and decides on its exit as flyd's restart policy does. */
129
+ const SUPERVISOR = [
130
+ "n=0; t=0;",
131
+ "while :; do",
132
+ `sh -c 'echo $$ > ${PID_FILE}; exec "$@"' fly-main "$@"; code=$?;`,
133
+ `r=$(cat ${RESTART_FILE} 2>/dev/null) || r=$${RESTART_ENV}; p=\${r%%:*}; m=\${r#*:}; [ "$m" = "$r" ] && m=10;`,
134
+ "now=$(date +%s); [ $((now - t)) -gt 300 ] && n=0;",
135
+ 'case "$p" in always) ;; on-failure) { [ "$code" -ne 0 ] && [ "$n" -lt "$m" ]; } || exit "$code" ;; *) exit "$code" ;; esac;',
136
+ "n=$((n + 1)); t=$now; sleep 1;",
137
+ "done",
138
+ ].join(" ");
139
+ /** The supervised form of a main process. */
140
+ export function supervised(argv) {
141
+ return ["/bin/sh", "-c", SUPERVISOR, "fly-supervisor", ...argv];
142
+ }
143
+ /** A restart policy as the supervisor reads it. */
144
+ export function restartOf(restart) {
145
+ const policy = restart?.policy || "no";
146
+ return policy === "on-failure" && restart?.max_retries !== undefined
147
+ ? `${policy}:${restart.max_retries}`
148
+ : policy;
149
+ }
150
+ function checkConfig(config, region, regions) {
151
+ if (!config || typeof config !== "object")
152
+ throw new FlyError(400, "config is required.");
153
+ if (typeof config.image !== "string" || !config.image)
154
+ throw new FlyError(400, "config.image is required.");
155
+ for (const [key, value] of Object.entries(config)) {
156
+ if (value === undefined || value === null || CONFIG.includes(key))
157
+ continue;
158
+ const empty = value === false ||
159
+ (Array.isArray(value) && value.length === 0) ||
160
+ (typeof value === "object" && Object.keys(value).length === 0);
161
+ if (empty && EMPTY_OK.includes(key))
162
+ continue;
163
+ refuse(key);
164
+ }
165
+ const restart = config.restart ?? {};
166
+ if (restart.policy !== undefined && !POLICIES.includes(restart.policy))
167
+ throw new FlyError(400, `restart.policy must be one of ${POLICIES.join(", ")}.`);
168
+ for (const key of Object.keys(restart))
169
+ if (!["policy", "max_retries"].includes(key) && restart[key] !== undefined)
170
+ refuse(`restart.${key}`);
171
+ if (restart.max_retries !== undefined &&
172
+ (!Number.isInteger(restart.max_retries) || restart.max_retries < 0))
173
+ throw new FlyError(400, "restart.max_retries must be a whole number.");
174
+ const guest = config.guest ?? {};
175
+ for (const key of Object.keys(guest))
176
+ if (!["cpu_kind", "cpus", "memory_mb"].includes(key) &&
177
+ guest[key] !== undefined &&
178
+ guest[key] !== 0)
179
+ refuse(`guest.${key}`);
180
+ for (const key of Object.keys(config.init ?? {}))
181
+ if (!["exec", "entrypoint", "cmd"].includes(key) &&
182
+ config.init[key] !== undefined)
183
+ refuse(`init.${key}`);
184
+ for (const file of config.files ?? []) {
185
+ if (typeof file.guest_path !== "string" || !file.guest_path.startsWith("/"))
186
+ throw new FlyError(400, "files[].guest_path must be an absolute path.");
187
+ if (typeof file.raw_value !== "string")
188
+ refuse("files[].secret_name (Runtime secrets: runtime.secrets.set, then env)");
189
+ }
190
+ if (region && !regions.includes(region))
191
+ throw new FlyError(400, `region ${region} is not available: Runtime runs in us-east (northern Virginia), next to Fly's ${regions.join(", ")}. Leave region out or use ${regions[0]}.`);
192
+ }
193
+ /** A Fly-style name: lower-case words and a number. */
194
+ const generatedName = () => `machine-${randomBytes(4).toString("hex")}`;
195
+ export class MachinesApi {
196
+ #options;
197
+ #regions;
198
+ #requireKey;
199
+ #clients = new Map();
200
+ /** Machines this handler destroyed, read back as destroyed for a while, as
201
+ * Fly answers a destroyed machine's id before forgetting it. */
202
+ #destroyed = new Map();
203
+ constructor({ regions = NEAR, requireKey = false, ...options } = {}) {
204
+ this.#options = options;
205
+ this.#regions = regions;
206
+ this.#requireKey = requireKey;
207
+ }
208
+ /** The Runtime client for a request: a Runtime key in its bearer token, or
209
+ * this handler's own. A Fly token is never sent anywhere. */
210
+ #runtime(request) {
211
+ const token = (request.headers.get("authorization") ?? "")
212
+ .replace(/^(Bearer|FlyV1)\s+/i, "")
213
+ .trim();
214
+ if (this.#requireKey && !token.startsWith("rtcloud_"))
215
+ throw new FlyError(401, "This Fly Machines API is reachable from other computers, so each request must carry its own Runtime API key (rtcloud_...) as the bearer token, where your Fly token went.");
216
+ const key = token.startsWith("rtcloud_") ? token : (this.#options.apiKey ?? "");
217
+ if (!key && !this.#options.client)
218
+ throw new FlyError(401, "Send a Runtime API key (rtcloud_...) as the bearer token, where your Fly token went.");
219
+ if (!key)
220
+ return client(this.#options);
221
+ let found = this.#clients.get(key);
222
+ if (!found) {
223
+ found = client({ ...this.#options, client: undefined, apiKey: key });
224
+ this.#clients.set(key, found);
225
+ }
226
+ return found;
227
+ }
228
+ /** Answers one Machines API request. */
229
+ fetch = async (request) => {
230
+ try {
231
+ return await this.#route(request);
232
+ }
233
+ catch (error) {
234
+ if (error instanceof FlyError)
235
+ return json({ error: error.message }, error.status);
236
+ if (error instanceof NotFoundError)
237
+ return json({ error: error.message }, 404);
238
+ if (error instanceof RuntimeError)
239
+ return json({ error: `${error.message}${error.hint ? ` ${error.hint}` : ""}` }, error.status >= 400 ? error.status : 502);
240
+ return json({ error: error instanceof Error ? error.message : String(error) }, 500);
241
+ }
242
+ };
243
+ async #route(request) {
244
+ const url = new URL(request.url);
245
+ const parts = url.pathname.replace(/\/+$/, "").split("/").slice(1).map(decodeURIComponent);
246
+ if (parts[0] !== "v1")
247
+ throw new FlyError(404, `No route ${url.pathname}.`);
248
+ const method = request.method.toUpperCase();
249
+ const body = async () => {
250
+ if (method === "GET" || method === "DELETE")
251
+ return {};
252
+ const text = await request.text();
253
+ if (!text.trim())
254
+ return {};
255
+ try {
256
+ return JSON.parse(text);
257
+ }
258
+ catch (error) {
259
+ throw new FlyError(400, `The request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}.`);
260
+ }
261
+ };
262
+ const runtime = this.#runtime(request);
263
+ const [, apps, app, machines, id, verb, key] = parts;
264
+ if (method === "GET" && apps === "platform" && app === "regions" && !machines)
265
+ return json({
266
+ nearest: this.#regions[0],
267
+ regions: this.#regions.map((code) => ({
268
+ code,
269
+ name: "Ashburn, Virginia (US): Runtime us-east",
270
+ geo_region: "us",
271
+ gateway_available: false,
272
+ requires_paid_plan: false,
273
+ })),
274
+ });
275
+ if (method === "GET" && apps === "orgs" && app && machines === "machines" && !id) {
276
+ const machines = [];
277
+ for (const sandbox of await this.#list(runtime)) {
278
+ const machine = await this.#observe(sandbox);
279
+ if (!machine)
280
+ continue;
281
+ const { events: _events, image_ref: _ref, host_status: _host, instance_id, ...rest } = machine;
282
+ machines.push({ app_name: sandbox.info.labels[APP], ...rest, version: instance_id });
283
+ }
284
+ return json({ machines });
285
+ }
286
+ if (apps !== "apps")
287
+ throw new FlyError(404, `No route ${url.pathname}.`);
288
+ if (app === undefined) {
289
+ if (method === "GET")
290
+ return json(await this.#apps(runtime));
291
+ if (method === "POST") {
292
+ const { app_name } = await body();
293
+ if (!app_name)
294
+ throw new FlyError(400, "app_name is required.");
295
+ return json(undefined, 201);
296
+ }
297
+ }
298
+ if (app !== undefined && machines === undefined) {
299
+ if (method === "GET") {
300
+ const count = (await this.#list(runtime, app)).length;
301
+ return json({
302
+ id: app,
303
+ name: app,
304
+ status: count ? "deployed" : "pending",
305
+ organization: { name: "runtime", slug: "personal" },
306
+ });
307
+ }
308
+ if (method === "DELETE") {
309
+ for (const sandbox of await this.#list(runtime, app))
310
+ await this.#destroy(sandbox);
311
+ return json(undefined, 202);
312
+ }
313
+ }
314
+ if (machines !== "machines")
315
+ throw new FlyError(404, `No route ${url.pathname}.`);
316
+ const app_ = app;
317
+ if (id === undefined) {
318
+ if (method === "GET") {
319
+ const filter = [...url.searchParams].filter(([k]) => k.startsWith("metadata."));
320
+ const region = url.searchParams.get("region");
321
+ const found = await Promise.all((await this.#list(runtime, app_)).map((sandbox) => this.#observe(sandbox)));
322
+ return json(found
323
+ .filter((m) => m !== undefined)
324
+ .filter((m) => !region || m.region === region || this.#regions.includes(region))
325
+ .filter((m) => filter.every(([k, v]) => m.config.metadata?.[k.slice(9)] === v)));
326
+ }
327
+ if (method === "POST")
328
+ return json(await this.#create(runtime, app_, await body()));
329
+ }
330
+ const sandbox = await this.#get(runtime, app_, id).catch((error) => {
331
+ if (!(error instanceof NotFoundError))
332
+ throw error;
333
+ return undefined;
334
+ });
335
+ if (!sandbox) {
336
+ const gone = this.#gone(app_, id);
337
+ if (gone && method === "GET" && verb === undefined)
338
+ return json(gone);
339
+ if (gone && method === "GET" && verb === "wait") {
340
+ const wanted = (url.searchParams.get("state") ?? "started").split(",");
341
+ if (wanted.includes("destroyed"))
342
+ return json({ ok: true, state: "destroyed" });
343
+ }
344
+ throw new FlyError(404, "machine not found");
345
+ }
346
+ const lease = request.headers.get("fly-machine-lease-nonce");
347
+ // A lease guards changes to the machine, as on Fly; exec and reads go on.
348
+ const mutating = method !== "GET" && verb !== "lease" && verb !== "exec";
349
+ if (mutating)
350
+ this.#checkLease(sandbox, lease);
351
+ if (verb === undefined) {
352
+ if (method === "GET")
353
+ return json(await this.#machine(sandbox));
354
+ if (method === "POST")
355
+ return json(await this.#update(runtime, sandbox, await body()));
356
+ if (method === "DELETE") {
357
+ // fly-admin and older clients spell force `kill`.
358
+ const force = ["force", "kill"].some((name) => url.searchParams.get(name) === "true");
359
+ await this.#observe(sandbox);
360
+ if (!force && (sandbox.state === "running" || sandbox.state === "starting"))
361
+ throw new FlyError(412, "failed_precondition: unable to destroy machine, not currently stopped or suspended; stop it first or pass force=true");
362
+ await this.#destroy(sandbox);
363
+ return ok();
364
+ }
365
+ }
366
+ switch (`${method} ${verb}`) {
367
+ case "POST start": {
368
+ const previous = (await this.#machine(sandbox)).state;
369
+ if (sandbox.state === "paused")
370
+ await sandbox.wake();
371
+ else if (sandbox.state === "stopped")
372
+ await this.#restart(runtime, sandbox);
373
+ return json({ previous_state: previous, migrated: false, new_host: "" });
374
+ }
375
+ case "POST stop":
376
+ await sandbox.stop();
377
+ return ok();
378
+ case "POST suspend":
379
+ await sandbox.pause();
380
+ return ok();
381
+ case "POST restart":
382
+ await sandbox.stop();
383
+ await this.#restart(runtime, sandbox);
384
+ return ok();
385
+ case "GET wait":
386
+ return this.#wait(sandbox, url);
387
+ case "POST exec":
388
+ return json(await this.#exec(sandbox, await body()));
389
+ case "GET metadata": {
390
+ const metadata = metadataOf(sandbox);
391
+ if (key === undefined)
392
+ return json(metadata);
393
+ if (!(key in metadata))
394
+ throw new FlyError(404, `metadata key ${key} not found`);
395
+ return json({ value: metadata[key] });
396
+ }
397
+ case "PATCH metadata":
398
+ case "PUT metadata": {
399
+ const { metadata = {} } = await body();
400
+ await this.#label(sandbox, Object.fromEntries(Object.entries(metadata).map(([k, v]) => [META + k, v === null ? null : String(v)])));
401
+ return json(undefined, 204);
402
+ }
403
+ case "GET versions": {
404
+ const machine = await this.#machine(sandbox);
405
+ return json([{ version: machine.instance_id, user_config: machine }]);
406
+ }
407
+ case "GET memory":
408
+ case "PUT memory":
409
+ case "POST memory":
410
+ return refuse("memory");
411
+ case "POST metadata": {
412
+ const { value } = await body();
413
+ if (key === undefined || typeof value !== "string")
414
+ throw new FlyError(400, "POST /metadata/{key} takes { value }.");
415
+ await this.#label(sandbox, { [META + key]: value });
416
+ return json(undefined, 204);
417
+ }
418
+ case "DELETE metadata":
419
+ if (key === undefined)
420
+ throw new FlyError(400, "DELETE /metadata/{key} needs a key.");
421
+ await this.#label(sandbox, { [META + key]: null });
422
+ return json(undefined, 204);
423
+ case "GET lease": {
424
+ const held = leaseOf(sandbox);
425
+ if (!held)
426
+ throw new FlyError(404, "lease not found");
427
+ return json({ status: "success", data: held });
428
+ }
429
+ case "POST lease":
430
+ return json({ status: "success", data: await this.#lease(sandbox, lease, await body()) });
431
+ case "DELETE lease":
432
+ this.#checkLease(sandbox, lease);
433
+ await this.#label(sandbox, { [LEASE]: null });
434
+ return json({ status: "success", data: { ok: true } });
435
+ case "POST signal": {
436
+ const { signal } = await body();
437
+ const signals = [
438
+ "SIGTERM",
439
+ "SIGKILL",
440
+ "SIGINT",
441
+ "SIGHUP",
442
+ "SIGQUIT",
443
+ "SIGUSR1",
444
+ "SIGUSR2",
445
+ ];
446
+ const named = signals.find((name) => name === signal);
447
+ if (!named)
448
+ throw new FlyError(400, `signal must be one of ${signals.join(", ")}.`);
449
+ const main = sandbox.info.labels[MAIN];
450
+ if (!main)
451
+ throw new FlyError(412, "this machine has no main process to signal");
452
+ // The program under the supervisor, as Fly signals the main process.
453
+ const sent = await sandbox.exec([
454
+ "sh",
455
+ "-c",
456
+ `[ -s ${PID_FILE} ] && kill -s "$1" "$(cat ${PID_FILE})"`,
457
+ "fly",
458
+ named.slice(3),
459
+ ]);
460
+ if (sent.exitCode !== 0)
461
+ await (await sandbox.processes.get(main)).kill(named);
462
+ return ok();
463
+ }
464
+ case "GET ps":
465
+ return json((await sandbox.processes.list())
466
+ .filter((p) => p.state === "running")
467
+ .map((p) => ({
468
+ command: p.command,
469
+ directory: p.cwd,
470
+ pid: 0,
471
+ cpu: 0,
472
+ rss: 0,
473
+ rtime: 0,
474
+ stime: 0,
475
+ listen_sockets: [],
476
+ })));
477
+ case "GET events":
478
+ return json((await this.#machine(sandbox)).events);
479
+ case "POST cordon":
480
+ case "POST uncordon":
481
+ return refuse("services");
482
+ }
483
+ throw new FlyError(404, `No route ${method} ${url.pathname}.`);
484
+ }
485
+ async #apps(runtime) {
486
+ const counts = new Map();
487
+ for await (const sandbox of await runtime.sandboxes.list({
488
+ labels: { "compat.provider": PROVIDER },
489
+ includeStopped: true,
490
+ })) {
491
+ const app = sandbox.info.labels[APP];
492
+ if (app)
493
+ counts.set(app, (counts.get(app) ?? 0) + 1);
494
+ }
495
+ return {
496
+ total_apps: counts.size,
497
+ apps: [...counts].map(([name, machine_count]) => ({
498
+ id: name,
499
+ name,
500
+ machine_count,
501
+ network: "default",
502
+ })),
503
+ };
504
+ }
505
+ async #list(runtime, app) {
506
+ const found = [];
507
+ for await (const sandbox of await runtime.sandboxes.list({
508
+ labels: { "compat.provider": PROVIDER, ...(app === undefined ? {} : { [APP]: app }) },
509
+ includeStopped: true,
510
+ }))
511
+ found.push(sandbox);
512
+ return found;
513
+ }
514
+ async #get(runtime, app, id) {
515
+ const sandbox = await runtime.sandboxes.get(id);
516
+ if (sandbox.info.labels[APP] !== app)
517
+ throw new FlyError(404, "machine not found");
518
+ return sandbox;
519
+ }
520
+ /** The Runtime image for a Fly image, init and files, built once: the
521
+ * image as Docker would run it, then, when it has a main process, the same
522
+ * with that process under the restart supervisor. */
523
+ async #image(runtime, config) {
524
+ const files = config.files ?? [];
525
+ const dockerfile = dockerfileFor(config.image, config.init, files);
526
+ const inline = files.map((file, index) => ({
527
+ path: `fly-file-${index}`,
528
+ content: file.raw_value,
529
+ encoding: "base64",
530
+ }));
531
+ const plain = await this.#build(runtime, dockerfile, inline);
532
+ const start = (await runtime.images.get(plain)).start;
533
+ const argv = start?.argv?.length
534
+ ? start.argv
535
+ : start?.command
536
+ ? ["/bin/sh", "-c", start.command]
537
+ : undefined;
538
+ if (!argv || argv[3] === "fly-supervisor")
539
+ return plain;
540
+ const lines = dockerfileFor(config.image, {}, files).trimEnd().split("\n");
541
+ lines.push(`ENTRYPOINT ${JSON.stringify(supervised(argv))}`, "CMD []");
542
+ return this.#build(runtime, `${lines.join("\n")}\n`, inline);
543
+ }
544
+ async #build(runtime, dockerfile, files) {
545
+ const digest = createHash("sha256")
546
+ .update(JSON.stringify([dockerfile, files]))
547
+ .digest("hex")
548
+ .slice(0, 20);
549
+ const name = `fly-${digest}`;
550
+ const ready = (await runtime.images.list({ name, state: "ready", limit: 1 })).data[0];
551
+ if (ready)
552
+ return ready.id;
553
+ return (await runtime.images.build({
554
+ name,
555
+ dockerfile,
556
+ files,
557
+ labels: { "compat.provider": PROVIDER },
558
+ })).id;
559
+ }
560
+ async #create(runtime, app, input) {
561
+ const config = input.config;
562
+ checkConfig(config, input.region, this.#regions);
563
+ const guest = config.guest ?? {};
564
+ const metadata = Object.entries(config.metadata ?? {});
565
+ if (metadata.length > 20)
566
+ throw new FlyError(400, "At most 20 metadata keys per machine.");
567
+ const image = await this.#image(runtime, config);
568
+ const init = config.init && Object.keys(config.init).length ? JSON.stringify(config.init) : undefined;
569
+ const labels = {
570
+ "compat.provider": PROVIDER,
571
+ [APP]: app,
572
+ [NAME]: input.name ?? generatedName(),
573
+ [IMAGE]: config.image,
574
+ [BUILT]: image,
575
+ ...(init ? { [INIT]: init } : {}),
576
+ ...(config.auto_destroy ? { [DESTROY]: "true" } : {}),
577
+ [RESTART]: restartOf(config.restart),
578
+ ...Object.fromEntries(metadata.map(([k, v]) => [META + k, String(v)])),
579
+ };
580
+ for (const [k, v] of Object.entries(labels))
581
+ if (v.length > 256)
582
+ throw new FlyError(400, `${k === INIT ? "config.init" : k} is longer than 256 characters.`);
583
+ const sandbox = await runtime.sandboxes.create({
584
+ image,
585
+ labels,
586
+ env: { ...config.env, [RESTART_ENV]: restartOf(config.restart) },
587
+ // Fly's size when none is given: shared-cpu-1x with 256 MB.
588
+ vcpu: guest.cpus ?? 1,
589
+ memoryMiB: guest.memory_mb ?? 256,
590
+ cpu: guest.cpu_kind === "performance" ? "reserved" : "shared",
591
+ });
592
+ const main = sandbox.info.start?.processId;
593
+ if (main)
594
+ await this.#label(sandbox, { [MAIN]: main });
595
+ if (input.skip_launch)
596
+ await sandbox.stop();
597
+ if (input.lease_ttl)
598
+ await this.#lease(sandbox, null, { ttl: input.lease_ttl });
599
+ return this.#machine(sandbox);
600
+ }
601
+ async #update(runtime, sandbox, input) {
602
+ const config = input.config;
603
+ checkConfig(config, input.region, this.#regions);
604
+ const labels = sandbox.info.labels;
605
+ const init = config.init && Object.keys(config.init).length ? JSON.stringify(config.init) : undefined;
606
+ if (config.image !== labels[IMAGE] || init !== labels[INIT] || config.files?.length)
607
+ throw new FlyError(400, "Changing image, init or files is not supported by Runtime's Fly Machines API; create a new machine, or move a sandbox to another image with sandbox.switchImage().");
608
+ const guest = config.guest ?? {};
609
+ if ((guest.cpus && guest.cpus !== sandbox.info.vcpu) ||
610
+ (guest.memory_mb && guest.memory_mb !== sandbox.info.memoryMiB)) {
611
+ // A resize restarts the sandbox, and so the machine's main process.
612
+ await sandbox.resize({
613
+ ...(guest.cpus ? { vcpu: guest.cpus } : {}),
614
+ ...(guest.memory_mb ? { memoryMiB: guest.memory_mb } : {}),
615
+ });
616
+ await this.#runMain(runtime, sandbox);
617
+ }
618
+ const restart = restartOf(config.restart);
619
+ const env = { ...config.env, [RESTART_ENV]: restart };
620
+ const removed = (sandbox.info.envNames ?? []).filter((name) => !(name in env));
621
+ if (restart !== labels[RESTART] && sandbox.state === "running")
622
+ await sandbox.exec(["sh", "-c", `printf %s "$1" > ${RESTART_FILE}`, "fly", restart]);
623
+ const metadata = config.metadata ?? {};
624
+ const labelChange = {
625
+ [DESTROY]: config.auto_destroy ? "true" : null,
626
+ [RESTART]: restart,
627
+ ...Object.fromEntries(Object.keys(labels)
628
+ .filter((k) => k.startsWith(META) && !(k.slice(META.length) in metadata))
629
+ .map((k) => [k, null])),
630
+ ...Object.fromEntries(Object.entries(metadata).map(([k, v]) => [META + k, String(v)])),
631
+ };
632
+ await sandbox.update({
633
+ labels: mergeLabels(labels, labelChange),
634
+ env: { ...Object.fromEntries(removed.map((name) => [name, null])), ...env },
635
+ });
636
+ return this.#machine(sandbox);
637
+ }
638
+ /** Starts a stopped machine and its main process: Fly runs the image's
639
+ * command on every start, and a restarted sandbox runs only what it is
640
+ * told (15 October 2026), so the image's start command is run here. */
641
+ async #restart(runtime, sandbox) {
642
+ await sandbox.restart();
643
+ await this.#runMain(runtime, sandbox);
644
+ }
645
+ /** Runs the image's start command, unless it already runs. A server that
646
+ * runs it again on a restart or resize says so in `start` (#750): that
647
+ * process is the main one, and nothing is started twice. */
648
+ async #runMain(runtime, sandbox) {
649
+ const started = sandbox.info.start?.processId;
650
+ if (started && started !== sandbox.info.labels[MAIN]) {
651
+ await this.#label(sandbox, { [MAIN]: started });
652
+ return;
653
+ }
654
+ const built = sandbox.info.labels[BUILT];
655
+ const start = built ? (await runtime.images.get(built)).start : undefined;
656
+ const command = start?.argv?.length ? start.argv : start?.command;
657
+ if (!command)
658
+ return;
659
+ const shown = typeof command === "string" ? command : command.join(" ");
660
+ const running = (await sandbox.processes.list()).find((p) => p.state === "running" && (p.command === shown || p.command === `/usr/bin/env ${shown}`));
661
+ const main = running ??
662
+ (await sandbox.spawn(command, {
663
+ ...(start?.cwd ? { cwd: start.cwd } : {}),
664
+ ...(start?.env ? { env: start.env } : {}),
665
+ }));
666
+ await this.#label(sandbox, { [MAIN]: main.id });
667
+ }
668
+ async #destroy(sandbox) {
669
+ await sandbox.delete();
670
+ const now = Date.now();
671
+ for (const [id, { at }] of this.#destroyed)
672
+ if (now - at > DESTROYED_KEPT_MS)
673
+ this.#destroyed.delete(id);
674
+ this.#destroyed.set(sandbox.id, {
675
+ app: sandbox.info.labels[APP] ?? "",
676
+ machine: { ...toMachine(sandbox), state: "destroyed" },
677
+ at: now,
678
+ });
679
+ }
680
+ #gone(app, id) {
681
+ const gone = this.#destroyed.get(id);
682
+ if (!gone || gone.app !== app || Date.now() - gone.at > DESTROYED_KEPT_MS)
683
+ return undefined;
684
+ return gone.machine;
685
+ }
686
+ async #label(sandbox, change) {
687
+ await sandbox.update({ labels: mergeLabels(sandbox.info.labels, change) });
688
+ }
689
+ /** The machine, after acting on a main process that has ended: Fly stops a
690
+ * machine whose process exits, and destroys it with auto_destroy.
691
+ * Undefined when that destroyed it. */
692
+ async #observe(sandbox) {
693
+ const main = sandbox.info.labels[MAIN];
694
+ if (main && sandbox.state === "running") {
695
+ const process = await sandbox.processes.get(main).catch((error) => {
696
+ if (error instanceof NotFoundError)
697
+ return undefined;
698
+ throw error;
699
+ });
700
+ if (process && process.state !== "running") {
701
+ if (sandbox.info.labels[DESTROY] === "true") {
702
+ await this.#destroy(sandbox);
703
+ return undefined;
704
+ }
705
+ await sandbox.stop();
706
+ }
707
+ }
708
+ return toMachine(sandbox);
709
+ }
710
+ async #machine(sandbox) {
711
+ const machine = await this.#observe(sandbox);
712
+ if (!machine)
713
+ return { ...toMachine(sandbox), state: "destroyed" };
714
+ return machine;
715
+ }
716
+ /** GET /wait: until the machine is in `state` (default started), or
717
+ * `timeout` seconds (default 60) pass: 408 then, as on Fly. */
718
+ async #wait(sandbox, url) {
719
+ const wanted = (url.searchParams.get("state") ?? "started").split(",");
720
+ const timeout = Math.min(Number(url.searchParams.get("timeout") ?? 60) || 60, 300);
721
+ const deadline = Date.now() + timeout * 1000;
722
+ for (;;) {
723
+ let state;
724
+ try {
725
+ await sandbox.refresh();
726
+ state = (await this.#machine(sandbox)).state;
727
+ }
728
+ catch (error) {
729
+ if (!(error instanceof NotFoundError))
730
+ throw error;
731
+ state = "destroyed";
732
+ }
733
+ if (state === "destroyed" &&
734
+ sandbox.info.labels[DESTROY] !== "true" &&
735
+ !wanted.includes("destroyed"))
736
+ throw new FlyError(404, "machine not found");
737
+ if (wanted.includes(state))
738
+ return json({ ok: true, state });
739
+ if (Date.now() >= deadline)
740
+ throw new FlyError(408, `machine did not reach ${wanted.join(" or ")} in ${timeout}s`);
741
+ await new Promise((resolve) => setTimeout(resolve, 250));
742
+ }
743
+ }
744
+ async #exec(sandbox, input) {
745
+ const argv = input.command?.length
746
+ ? input.command
747
+ : input.cmd
748
+ ? ["sh", "-c", input.cmd]
749
+ : undefined;
750
+ if (!argv)
751
+ throw new FlyError(400, "exec takes command (an argument list) or cmd.");
752
+ const result = await sandbox.exec(argv, {
753
+ ...(input.timeout ? { timeoutMs: input.timeout * 1000 } : {}),
754
+ ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
755
+ });
756
+ if (result.timedOut)
757
+ throw new FlyError(408, `exec did not finish in ${input.timeout ?? 60}s`);
758
+ return {
759
+ exit_code: result.exitCode ?? -1,
760
+ exit_signal: result.exitCode === null ? 9 : 0,
761
+ stdout: result.stdout,
762
+ stderr: result.stderr,
763
+ };
764
+ }
765
+ #checkLease(sandbox, nonce) {
766
+ const held = leaseOf(sandbox);
767
+ if (held && held.nonce !== nonce)
768
+ throw new FlyError(409, `machine ${sandbox.id} is leased by another client until ${new Date(held.expires_at * 1000).toISOString()}`);
769
+ }
770
+ /** A lease kept in a label, read back after it is written so two clients
771
+ * asking at once cannot both hold it: the later write wins and the earlier
772
+ * client's read-back sees it lost. */
773
+ async #lease(sandbox, nonce, input) {
774
+ const held = leaseOf(sandbox);
775
+ if (held && held.nonce !== nonce)
776
+ this.#checkLease(sandbox, nonce);
777
+ const ttl = Math.max(1, Math.min(input.ttl ?? 30, 3600));
778
+ const lease = {
779
+ nonce: held?.nonce ?? randomBytes(8).toString("hex"),
780
+ expires_at: Math.floor(Date.now() / 1000) + ttl,
781
+ owner: "runtime",
782
+ description: (input.description ?? "").slice(0, 120),
783
+ version: randomBytes(4).toString("hex"),
784
+ };
785
+ await this.#label(sandbox, { [LEASE]: JSON.stringify(lease) });
786
+ await sandbox.refresh();
787
+ if (leaseOf(sandbox)?.version !== lease.version)
788
+ throw new FlyError(409, `machine ${sandbox.id} was leased by another client`);
789
+ return lease;
790
+ }
791
+ }
792
+ function mergeLabels(labels, change) {
793
+ const next = { ...labels };
794
+ for (const [k, v] of Object.entries(change)) {
795
+ if (v === null)
796
+ delete next[k];
797
+ else
798
+ next[k] = v;
799
+ }
800
+ return next;
801
+ }
802
+ function leaseOf(sandbox) {
803
+ const raw = sandbox.info.labels[LEASE];
804
+ if (!raw)
805
+ return undefined;
806
+ try {
807
+ const lease = JSON.parse(raw);
808
+ return lease.expires_at * 1000 > Date.now() ? lease : undefined;
809
+ }
810
+ catch {
811
+ return undefined;
812
+ }
813
+ }
814
+ function metadataOf(sandbox) {
815
+ return Object.fromEntries(Object.entries(sandbox.info.labels)
816
+ .filter(([k]) => k.startsWith(META))
817
+ .map(([k, v]) => [k.slice(META.length), v]));
818
+ }
819
+ function imageRef(image) {
820
+ const [path = image, digest = ""] = image.split("@");
821
+ const slash = path.indexOf("/");
822
+ const host = slash > 0 && /[.:]|localhost/.test(path.slice(0, slash)) ? path.slice(0, slash) : "docker.io";
823
+ const rest = host === "docker.io" && !path.startsWith("docker.io/") ? path : path.slice(slash + 1);
824
+ const colon = rest.lastIndexOf(":");
825
+ return {
826
+ registry: host,
827
+ repository: colon > 0 ? rest.slice(0, colon) : rest,
828
+ tag: colon > 0 ? rest.slice(colon + 1) : "latest",
829
+ digest,
830
+ };
831
+ }
832
+ function restartConfig(label = "no") {
833
+ const [policy = "no", max] = label.split(":");
834
+ return max === undefined ? { policy } : { policy, max_retries: Number(max) };
835
+ }
836
+ /** A sandbox as Fly's Machine object. */
837
+ export function toMachine(sandbox) {
838
+ const info = sandbox.info;
839
+ const labels = info.labels;
840
+ const init = labels[INIT] ? JSON.parse(labels[INIT]) : {};
841
+ const metadata = metadataOf(sandbox);
842
+ const created = Date.parse(info.createdAt);
843
+ return {
844
+ id: sandbox.id,
845
+ name: labels[NAME] ?? sandbox.id,
846
+ state: machineState(sandbox.state),
847
+ region: FLY_REGION[info.region] ?? info.region,
848
+ instance_id: labels[MAIN] ?? sandbox.id,
849
+ private_ip: "",
850
+ config: {
851
+ image: labels[IMAGE] ?? "",
852
+ env: Object.fromEntries((info.envNames ?? []).filter((name) => name !== RESTART_ENV).map((name) => [name, ""])),
853
+ guest: {
854
+ cpu_kind: info.cpu === "reserved" ? "performance" : "shared",
855
+ cpus: info.vcpu,
856
+ memory_mb: info.memoryMiB,
857
+ },
858
+ init,
859
+ metadata,
860
+ ...(labels[DESTROY] === "true" ? { auto_destroy: true } : {}),
861
+ restart: restartConfig(labels[RESTART]),
862
+ },
863
+ image_ref: imageRef(labels[IMAGE] ?? ""),
864
+ created_at: info.createdAt,
865
+ updated_at: info.createdAt,
866
+ events: [
867
+ {
868
+ id: `${sandbox.id}-launch`,
869
+ type: "launch",
870
+ status: "created",
871
+ source: "user",
872
+ timestamp: created,
873
+ },
874
+ ],
875
+ host_status: "ok",
876
+ };
877
+ }
878
+ /** One handler for every Machines API request, for Bun.serve, Deno.serve,
879
+ * Hono or any server that speaks fetch. */
880
+ export function machinesApi(options = {}) {
881
+ return new MachinesApi(options).fetch;
882
+ }
883
+ /** Serves the Machines API on a port of this computer (default 4280, the
884
+ * port of Fly's own `_api.internal`), for code that reads its address from
885
+ * FLY_API_HOSTNAME (fly-admin's `apiUrl`, fly-go's FLY_FLAPS_BASE_URL). Off
886
+ * this computer's loopback, a request without its own Runtime key is refused,
887
+ * so the login it runs as is never lent to the network. */
888
+ export async function serveMachinesApi(options = {}) {
889
+ const { port = 4280, hostname = "127.0.0.1", ...rest } = options;
890
+ const loopback = ["127.0.0.1", "::1", "localhost"].includes(hostname);
891
+ const handle = machinesApi({ ...rest, requireKey: rest.requireKey ?? !loopback });
892
+ const { createServer } = await import("node:http");
893
+ const answer = async (req, res) => {
894
+ const chunks = [];
895
+ for await (const chunk of req)
896
+ chunks.push(chunk);
897
+ const body = chunks.length ? Buffer.concat(chunks) : undefined;
898
+ const headers = new Headers();
899
+ for (const [k, v] of Object.entries(req.headers))
900
+ if (typeof v === "string")
901
+ headers.set(k, v);
902
+ else if (Array.isArray(v))
903
+ headers.set(k, v.join(", "));
904
+ const response = await handle(new Request(`http://${hostname}:${port}${req.url ?? "/"}`, {
905
+ method: req.method ?? "GET",
906
+ headers,
907
+ ...(body && req.method !== "GET" && req.method !== "HEAD" ? { body } : {}),
908
+ }));
909
+ const out = {};
910
+ response.headers.forEach((value, name) => (out[name] = value));
911
+ res.writeHead(response.status, out);
912
+ res.end(Buffer.from(await response.arrayBuffer()));
913
+ };
914
+ const server = createServer((req, res) => {
915
+ answer(req, res).catch((error) => {
916
+ res.writeHead(500, { "content-type": "application/json" });
917
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
918
+ });
919
+ });
920
+ await new Promise((resolve, reject) => {
921
+ server.once("error", reject);
922
+ server.listen(port, hostname, () => resolve());
923
+ });
924
+ const address = server.address();
925
+ const bound = typeof address === "object" && address ? address.port : port;
926
+ return {
927
+ url: `http://${hostname}:${bound}`,
928
+ close: () => new Promise((resolve) => server.close(() => resolve())),
929
+ };
930
+ }