@sandboxrouter/sdk 0.0.0-stage → 0.2.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/dist/bench-store.d.ts +12 -0
  4. package/dist/bench-store.js +23 -0
  5. package/dist/bench.d.ts +24 -0
  6. package/dist/bench.js +65 -0
  7. package/dist/catalog.d.ts +105 -0
  8. package/dist/catalog.js +320 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +195 -0
  11. package/dist/computers.d.ts +124 -0
  12. package/dist/computers.js +66 -0
  13. package/dist/index.d.ts +23 -0
  14. package/dist/index.js +23 -0
  15. package/dist/leaderboard.d.ts +39 -0
  16. package/dist/leaderboard.js +184 -0
  17. package/dist/providers/blaxel.d.ts +21 -0
  18. package/dist/providers/blaxel.js +152 -0
  19. package/dist/providers/boat.d.ts +25 -0
  20. package/dist/providers/boat.js +258 -0
  21. package/dist/providers/cloudflare.d.ts +30 -0
  22. package/dist/providers/cloudflare.js +147 -0
  23. package/dist/providers/daytona.d.ts +13 -0
  24. package/dist/providers/daytona.js +121 -0
  25. package/dist/providers/docker.d.ts +17 -0
  26. package/dist/providers/docker.js +148 -0
  27. package/dist/providers/e2b.d.ts +10 -0
  28. package/dist/providers/e2b.js +69 -0
  29. package/dist/providers/exedev.d.ts +23 -0
  30. package/dist/providers/exedev.js +278 -0
  31. package/dist/providers/hopx.d.ts +15 -0
  32. package/dist/providers/hopx.js +168 -0
  33. package/dist/providers/maritime.d.ts +38 -0
  34. package/dist/providers/maritime.js +240 -0
  35. package/dist/providers/modal.d.ts +10 -0
  36. package/dist/providers/modal.js +82 -0
  37. package/dist/providers/morph.d.ts +19 -0
  38. package/dist/providers/morph.js +181 -0
  39. package/dist/providers/northflank.d.ts +21 -0
  40. package/dist/providers/northflank.js +261 -0
  41. package/dist/providers/runloop.d.ts +16 -0
  42. package/dist/providers/runloop.js +145 -0
  43. package/dist/providers/sprites.d.ts +15 -0
  44. package/dist/providers/sprites.js +135 -0
  45. package/dist/providers/tensorlake.d.ts +14 -0
  46. package/dist/providers/tensorlake.js +192 -0
  47. package/dist/providers/vercel.d.ts +12 -0
  48. package/dist/providers/vercel.js +87 -0
  49. package/dist/router.d.ts +65 -0
  50. package/dist/router.js +199 -0
  51. package/dist/server.d.ts +31 -0
  52. package/dist/server.js +257 -0
  53. package/dist/sse.d.ts +6 -0
  54. package/dist/sse.js +33 -0
  55. package/dist/types.d.ts +87 -0
  56. package/dist/types.js +19 -0
  57. package/dist/util.d.ts +11 -0
  58. package/dist/util.js +37 -0
  59. package/package.json +127 -4
@@ -0,0 +1,181 @@
1
+ import { ProviderError } from "../types.js";
2
+ import { dirname, optionalImport, shq, toBytes, wrapCommand } from "../util.js";
3
+ /** Base image for the default snapshot (Morph's minimal Ubuntu VM). */
4
+ const BASE_IMAGE = "morphvm-minimal";
5
+ const DISK_MB = 8192;
6
+ /** spec.env lives here inside the VM so it survives connect(); every exec sources it. */
7
+ const ENV_FILE = "/etc/sandboxrouter.env";
8
+ const READY_TIMEOUT_S = 300;
9
+ /**
10
+ * Morph Cloud via `morphcloud`. Instances start from snapshots: by default a cached snapshot of
11
+ * morphvm-minimal sized to spec.vcpu / spec.memoryGb; set image.morph to a snapshot id to start
12
+ * from your own (its resources come from the snapshot).
13
+ */
14
+ export class MorphProvider {
15
+ apiKey;
16
+ id = "morph";
17
+ client;
18
+ constructor(apiKey = process.env.MORPH_API_KEY) {
19
+ this.apiKey = apiKey;
20
+ }
21
+ configured() {
22
+ return !!this.apiKey;
23
+ }
24
+ async morph() {
25
+ if (!this.client) {
26
+ const { MorphCloudClient } = await optionalImport("morphcloud", "morph");
27
+ this.client = new MorphCloudClient({ apiKey: this.apiKey });
28
+ }
29
+ return this.client;
30
+ }
31
+ async create(spec) {
32
+ const m = await this.morph();
33
+ try {
34
+ const snapshotId = spec.image?.morph ?? (await this.baseSnapshot(m, spec));
35
+ const inst = await m.instances.start({
36
+ snapshotId,
37
+ metadata: spec.metadata,
38
+ ttlSeconds: Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)),
39
+ ttlAction: "stop",
40
+ timeout: READY_TIMEOUT_S,
41
+ });
42
+ const box = new MorphBox(inst);
43
+ if (spec.env && Object.keys(spec.env).length) {
44
+ try {
45
+ await box.writeFile(ENV_FILE, envFile(spec.env));
46
+ }
47
+ catch (err) {
48
+ await inst.stop().catch(() => { });
49
+ throw err;
50
+ }
51
+ }
52
+ return box;
53
+ }
54
+ catch (err) {
55
+ throw wrap(err);
56
+ }
57
+ }
58
+ /** Reattach; a paused instance is resumed. */
59
+ async connect(nativeId) {
60
+ const m = await this.morph();
61
+ try {
62
+ const inst = await m.instances.get({ instanceId: nativeId });
63
+ if (String(inst.status) === "paused")
64
+ await inst.resume();
65
+ if (String(inst.status) !== "ready")
66
+ await inst.waitUntilReady(READY_TIMEOUT_S);
67
+ return new MorphBox(inst);
68
+ }
69
+ catch (err) {
70
+ throw wrap(err);
71
+ }
72
+ }
73
+ /** Reuse (or create once) a morphvm-minimal snapshot with this shape, keyed by digest. */
74
+ async baseSnapshot(m, spec) {
75
+ const vcpus = spec.vcpu ?? 2;
76
+ const memory = Math.round((spec.memoryGb ?? 4) * 1024);
77
+ const digest = `sandboxrouter-${BASE_IMAGE}-${vcpus}c-${memory}m-${DISK_MB}d`;
78
+ const existing = (await m.snapshots.list({ digest })).find((s) => String(s.status) === "ready");
79
+ if (existing)
80
+ return existing.id;
81
+ let snap = await m.snapshots.create({ imageId: BASE_IMAGE, vcpus, memory, diskSize: DISK_MB, digest });
82
+ const t0 = Date.now();
83
+ while (String(snap.status) === "pending") {
84
+ if (Date.now() - t0 > READY_TIMEOUT_S * 1000)
85
+ throw new ProviderError("morph", `snapshot ${snap.id} not ready`);
86
+ await sleep(1000);
87
+ snap = await m.snapshots.get({ snapshotId: snap.id });
88
+ }
89
+ if (String(snap.status) !== "ready")
90
+ throw new ProviderError("morph", `snapshot ${snap.id} is ${snap.status}`);
91
+ return snap.id;
92
+ }
93
+ }
94
+ class MorphBox {
95
+ inst;
96
+ nativeId;
97
+ constructor(inst) {
98
+ this.inst = inst;
99
+ this.nativeId = inst.id;
100
+ }
101
+ async exec(cmd, opts = {}) {
102
+ const t0 = Date.now();
103
+ const script = `[ -r ${ENV_FILE} ] && . ${ENV_FILE}; ${wrapCommand(cmd, { cwd: opts.cwd, env: opts.env })}`;
104
+ try {
105
+ // With callbacks the SDK switches to its SSE endpoint and streams; otherwise one request.
106
+ const r = await this.inst.exec(["sh", "-c", script], {
107
+ timeout: opts.timeoutSeconds ?? 60,
108
+ onStdout: opts.onStdout,
109
+ onStderr: opts.onStderr,
110
+ });
111
+ return { exitCode: r.exit_code, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
112
+ }
113
+ catch (err) {
114
+ throw wrap(err);
115
+ }
116
+ }
117
+ /** Over SSH: base64 on stdin, decoded in the VM (parent dirs created). */
118
+ async writeFile(path, content) {
119
+ const b64 = Buffer.from(toBytes(content)).toString("base64");
120
+ const r = await this.ssh((s) => s.execCommand(`mkdir -p ${shq(dirname(path))} && base64 -d > ${shq(path)}${path === ENV_FILE ? ` && chmod 600 ${ENV_FILE}` : ""}`, { stdin: b64 }));
121
+ if (r.code !== 0)
122
+ throw new ProviderError("morph", `writeFile ${path}: ${r.stderr || `exit ${r.code}`}`);
123
+ }
124
+ async readFile(path) {
125
+ const r = await this.ssh((s) => s.execCommand(`base64 < ${shq(path)}`));
126
+ if (r.code !== 0)
127
+ throw new ProviderError("morph", `readFile ${path}: ${r.stderr || `exit ${r.code}`}`);
128
+ return new Uint8Array(Buffer.from(r.stdout, "base64"));
129
+ }
130
+ /** Snapshot memory and disk. router.get() resumes it. */
131
+ async pause() {
132
+ try {
133
+ await this.inst.pause();
134
+ }
135
+ catch (err) {
136
+ throw wrap(err);
137
+ }
138
+ }
139
+ async kill() {
140
+ try {
141
+ await this.inst.stop();
142
+ }
143
+ catch (err) {
144
+ throw wrap(err);
145
+ }
146
+ }
147
+ /** One SSH connection per call so no socket keeps the process alive. */
148
+ async ssh(fn) {
149
+ let s;
150
+ try {
151
+ s = await this.inst.ssh();
152
+ return await fn(s);
153
+ }
154
+ catch (err) {
155
+ throw wrap(err);
156
+ }
157
+ finally {
158
+ s?.dispose();
159
+ }
160
+ }
161
+ }
162
+ function envFile(env) {
163
+ return Object.entries(env)
164
+ .map(([k, v]) => {
165
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
166
+ throw new Error(`invalid env var name: ${k}`);
167
+ return `export ${k}=${shq(v)}\n`;
168
+ })
169
+ .join("");
170
+ }
171
+ /** The SDK throws plain Errors whose message starts "HTTP <status> ...". */
172
+ function wrap(err) {
173
+ if (err instanceof ProviderError)
174
+ return err;
175
+ const msg = err?.message ?? String(err);
176
+ const status = /^HTTP (\d{3})/.exec(msg)?.[1];
177
+ return new ProviderError("morph", msg, status ? Number(status) : undefined);
178
+ }
179
+ function sleep(ms) {
180
+ return new Promise((r) => setTimeout(r, ms).unref());
181
+ }
@@ -0,0 +1,21 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ /**
3
+ * Northflank sandboxes via `@northflank/js-client`. Auth: NORTHFLANK_API_TOKEN plus
4
+ * NORTHFLANK_PROJECT_ID (the project every sandbox is created in; its region is fixed at
5
+ * project creation). A sandbox is a deployment service (microVM-isolated on Northflank cloud)
6
+ * kept alive by `tail -f /dev/null`; commands run over Northflank's exec API. Default image
7
+ * ubuntu:22.04; set image.northflank to any public image path (e.g. "python:3.13-slim"). The
8
+ * image's entrypoint is replaced, and it needs `sh` and `base64` on PATH for file transfer.
9
+ */
10
+ export declare class NorthflankProvider implements Provider {
11
+ private token;
12
+ private projectId;
13
+ readonly id: "northflank";
14
+ private client?;
15
+ constructor(token?: string | undefined, projectId?: string | undefined);
16
+ configured(): boolean;
17
+ private northflank;
18
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
19
+ /** Reattach to a running sandbox by service id. */
20
+ connect(nativeId: string): Promise<ProviderSandbox>;
21
+ }
@@ -0,0 +1,261 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
3
+ import { ProviderError } from "../types.js";
4
+ import { dirname, optionalImport, shq, toBytes, wrapCommand } from "../util.js";
5
+ const DEFAULT_IMAGE = "ubuntu:22.04";
6
+ /** Northflank's createOptions.expiryTime bounds: 5 minutes to 7 days. */
7
+ const MIN_EXPIRY_SECONDS = 300;
8
+ const MAX_EXPIRY_SECONDS = 604_800;
9
+ const READY_TIMEOUT_MS = 5 * 60_000;
10
+ /**
11
+ * Northflank sandboxes via `@northflank/js-client`. Auth: NORTHFLANK_API_TOKEN plus
12
+ * NORTHFLANK_PROJECT_ID (the project every sandbox is created in; its region is fixed at
13
+ * project creation). A sandbox is a deployment service (microVM-isolated on Northflank cloud)
14
+ * kept alive by `tail -f /dev/null`; commands run over Northflank's exec API. Default image
15
+ * ubuntu:22.04; set image.northflank to any public image path (e.g. "python:3.13-slim"). The
16
+ * image's entrypoint is replaced, and it needs `sh` and `base64` on PATH for file transfer.
17
+ */
18
+ export class NorthflankProvider {
19
+ token;
20
+ projectId;
21
+ id = "northflank";
22
+ client;
23
+ constructor(token = process.env.NORTHFLANK_API_TOKEN, projectId = process.env.NORTHFLANK_PROJECT_ID) {
24
+ this.token = token;
25
+ this.projectId = projectId;
26
+ }
27
+ configured() {
28
+ return !!this.token && !!this.projectId;
29
+ }
30
+ async northflank() {
31
+ if (!this.client) {
32
+ if (!this.token || !this.projectId) {
33
+ throw new ProviderError("northflank", "NORTHFLANK_API_TOKEN and NORTHFLANK_PROJECT_ID must both be set");
34
+ }
35
+ const { ApiClient, ApiClientInMemoryContextProvider } = await optionalImport("@northflank/js-client", "northflank");
36
+ const ctx = new ApiClientInMemoryContextProvider();
37
+ ctx.addContext({ name: "sandboxrouter", token: this.token, project: this.projectId });
38
+ this.client = new ApiClient(ctx);
39
+ }
40
+ return this.client;
41
+ }
42
+ async create(spec) {
43
+ const nf = await this.northflank();
44
+ const projectId = this.projectId;
45
+ const name = `sbr-${randomBytes(5).toString("hex")}`;
46
+ const expiry = Math.round((spec.timeoutMinutes ?? 30) * 60);
47
+ const svc = await call(() => nf.create.service.deployment({
48
+ parameters: { projectId },
49
+ data: {
50
+ name,
51
+ description: "sandboxrouter sandbox",
52
+ billing: { deploymentPlan: plan(spec.vcpu ?? DEFAULT_VCPU, spec.memoryGb ?? DEFAULT_MEMORY_GB) },
53
+ deployment: {
54
+ instances: 1,
55
+ external: { imagePath: spec.image?.northflank ?? DEFAULT_IMAGE },
56
+ docker: { configType: "customEntrypointCustomCommand", customEntrypoint: "/bin/sh", customCommand: "-c 'tail -f /dev/null'" },
57
+ },
58
+ runtimeEnvironment: spec.env,
59
+ // At expiry Northflank pauses the service (compute billing stops) and schedules its deletion.
60
+ createOptions: { expiryTime: Math.min(MAX_EXPIRY_SECONDS, Math.max(MIN_EXPIRY_SECONDS, expiry)) },
61
+ },
62
+ }));
63
+ const box = new NorthflankBox(nf, projectId, svc?.id ?? name);
64
+ try {
65
+ await box.waitReady();
66
+ }
67
+ catch (err) {
68
+ await box.kill().catch(() => { });
69
+ throw err;
70
+ }
71
+ return box;
72
+ }
73
+ /** Reattach to a running sandbox by service id. */
74
+ async connect(nativeId) {
75
+ const box = new NorthflankBox(await this.northflank(), this.projectId, nativeId);
76
+ const { expiredAt } = await box.info();
77
+ if (expiredAt)
78
+ throw new ProviderError("northflank", `sandbox ${nativeId} expired at ${expiredAt}`);
79
+ await box.waitReady();
80
+ return box;
81
+ }
82
+ }
83
+ class NorthflankBox {
84
+ nf;
85
+ projectId;
86
+ nativeId;
87
+ constructor(nf, projectId, nativeId) {
88
+ this.nf = nf;
89
+ this.projectId = projectId;
90
+ this.nativeId = nativeId;
91
+ }
92
+ get params() {
93
+ return { projectId: this.projectId, serviceId: this.nativeId };
94
+ }
95
+ /** The deployment-service fields this adapter reads (GetServiceResult is a union over service types). */
96
+ async info() {
97
+ return (await call(() => this.nf.get.service({ parameters: this.params })));
98
+ }
99
+ /**
100
+ * Poll until the deployment is COMPLETED (container running), then until exec is accepted:
101
+ * the exec endpoint can refuse briefly while the instance comes up.
102
+ */
103
+ async waitReady() {
104
+ const deadline = Date.now() + READY_TIMEOUT_MS;
105
+ for (;;) {
106
+ const status = (await this.info())?.status?.deployment?.status;
107
+ if (status === "FAILED")
108
+ throw new ProviderError("northflank", `sandbox ${this.nativeId} failed to deploy`);
109
+ if (status === "COMPLETED")
110
+ break;
111
+ if (Date.now() > deadline)
112
+ throw new ProviderError("northflank", `sandbox ${this.nativeId} not ready after ${READY_TIMEOUT_MS / 1000}s`);
113
+ await sleep(1000);
114
+ }
115
+ for (;;) {
116
+ try {
117
+ const r = await this.run(["/bin/sh", "-c", "true"], 30_000);
118
+ if (r.exitCode === 0)
119
+ return;
120
+ }
121
+ catch (err) {
122
+ if (Date.now() > deadline)
123
+ throw err;
124
+ }
125
+ if (Date.now() > deadline)
126
+ throw new ProviderError("northflank", `sandbox ${this.nativeId} not accepting exec`);
127
+ await sleep(1000);
128
+ }
129
+ }
130
+ /**
131
+ * Exec has no cwd/env/timeout, so cwd/env are wrapped into the command, the command runs
132
+ * under `timeout` when the image has it, and the deadline is also enforced here.
133
+ */
134
+ async exec(cmd, opts = {}) {
135
+ const t0 = Date.now();
136
+ const secs = opts.timeoutSeconds ?? 60;
137
+ const script = shq(wrapCommand(cmd, { cwd: opts.cwd, env: opts.env }));
138
+ const guarded = `if command -v timeout >/dev/null 2>&1; then exec timeout ${secs} /bin/sh -c ${script}; else exec /bin/sh -c ${script}; fi`;
139
+ const r = await this.run(["/bin/sh", "-c", guarded], secs * 1000 + 10_000, opts);
140
+ const durationMs = Date.now() - t0;
141
+ if (durationMs >= secs * 1000 && [124, 137, 143].includes(r.exitCode)) {
142
+ throw new ProviderError("northflank", `command timed out after ${secs}s`);
143
+ }
144
+ return { ...r, durationMs };
145
+ }
146
+ /** Stream base64 over stdin and decode in the sandbox. */
147
+ async writeFile(path, content) {
148
+ const b64 = Buffer.from(toBytes(content)).toString("base64");
149
+ const r = await this.run(["/bin/sh", "-c", `mkdir -p ${shq(dirname(path))} && base64 -d > ${shq(path)}`], 120_000 + b64.length / 100, {}, b64);
150
+ if (r.exitCode !== 0)
151
+ throw new ProviderError("northflank", `write ${path} failed: ${r.stderr.trim() || `exit ${r.exitCode}`}`);
152
+ }
153
+ async readFile(path) {
154
+ const r = await this.run(["/bin/sh", "-c", `base64 < ${shq(path)}`], 120_000);
155
+ if (r.exitCode !== 0)
156
+ throw new ProviderError("northflank", `read ${path} failed: ${r.stderr.trim() || `exit ${r.exitCode}`}`);
157
+ return new Uint8Array(Buffer.from(r.stdout, "base64"));
158
+ }
159
+ async kill() {
160
+ await call(() => this.nf.delete.service({ parameters: this.params }));
161
+ }
162
+ /** One exec session: optional stdin, streamed stdout/stderr, exit code. */
163
+ async run(command, limitMs, opts = {}, stdin) {
164
+ const t0 = Date.now();
165
+ let session;
166
+ try {
167
+ session = await this.nf.exec.execServiceSession(this.params, { command, shell: "none", tty: false });
168
+ }
169
+ catch (err) {
170
+ throw new ProviderError("northflank", err?.message ?? String(err), err?.status);
171
+ }
172
+ let stdout = "";
173
+ let stderr = "";
174
+ session.stdOut.setEncoding("utf8");
175
+ session.stdErr.setEncoding("utf8");
176
+ session.stdOut.on("data", (c) => {
177
+ stdout += c;
178
+ opts.onStdout?.(c);
179
+ });
180
+ session.stdErr.on("data", (c) => {
181
+ stderr += c;
182
+ opts.onStderr?.(c);
183
+ });
184
+ const failed = new Promise((_, reject) => session.on("error", (e) => reject(e)));
185
+ failed.catch(() => { });
186
+ // Send stdin (if any) and close it so commands that read stdin don't hang.
187
+ const CHUNK = 64 * 1024;
188
+ if (stdin)
189
+ for (let i = 0; i < stdin.length; i += CHUNK)
190
+ session.stdIn.write(stdin.slice(i, i + CHUNK));
191
+ session.stdIn.end();
192
+ let timer;
193
+ const timedOut = new Promise((resolve) => {
194
+ timer = setTimeout(() => resolve("timeout"), limitMs);
195
+ timer.unref();
196
+ });
197
+ try {
198
+ const res = await Promise.race([session.waitForCommandResult(), failed, timedOut]);
199
+ if (res === "timeout")
200
+ throw new ProviderError("northflank", `command timed out after ${Math.round((Date.now() - t0) / 1000)}s`);
201
+ return { exitCode: typeof res.exitCode === "number" ? res.exitCode : -1, stdout, stderr };
202
+ }
203
+ catch (err) {
204
+ if (err instanceof ProviderError)
205
+ throw err;
206
+ throw new ProviderError("northflank", err?.message ?? String(err), err?.status);
207
+ }
208
+ finally {
209
+ clearTimeout(timer);
210
+ }
211
+ }
212
+ }
213
+ /** Unwrap a Northflank API response, turning an `error` body into a ProviderError with its HTTP status. */
214
+ async function call(fn) {
215
+ let res;
216
+ try {
217
+ res = await fn();
218
+ }
219
+ catch (err) {
220
+ throw new ProviderError("northflank", err?.message ?? String(err), err?.status);
221
+ }
222
+ if (res.error)
223
+ throw new ProviderError("northflank", res.error.message || `HTTP ${res.error.status}`, res.error.status);
224
+ return res.data;
225
+ }
226
+ /**
227
+ * Northflank deployment plans (vCPU, GB), from
228
+ * https://northflank.com/docs/v1/application/billing/resource-plans-and-quotas. Northflank bills
229
+ * per vCPU-hour and GB-hour, so the cheapest plan that fits is the one with the least of both.
230
+ */
231
+ const PLANS = [
232
+ ["nf-compute-10", 0.1, 0.25],
233
+ ["nf-compute-20", 0.2, 0.5],
234
+ ["nf-compute-50", 0.5, 1],
235
+ ["nf-compute-100-1", 1, 1],
236
+ ["nf-compute-100-2", 1, 2],
237
+ ["nf-compute-100-4", 1, 4],
238
+ ["nf-compute-200", 2, 4],
239
+ ["nf-compute-200-8", 2, 8],
240
+ ["nf-compute-200-16", 2, 16],
241
+ ["nf-compute-400", 4, 8],
242
+ ["nf-compute-400-16", 4, 16],
243
+ ["nf-compute-800-8", 8, 8],
244
+ ["nf-compute-800-16", 8, 16],
245
+ ["nf-compute-800-24", 8, 24],
246
+ ["nf-compute-800-32", 8, 32],
247
+ ["nf-compute-800-40", 8, 40],
248
+ ["nf-compute-1200-24", 12, 24],
249
+ ["nf-compute-1600-32", 16, 32],
250
+ ["nf-compute-2000-40", 20, 40],
251
+ ["nf-compute-32-131072", 32, 128],
252
+ ];
253
+ /** Smallest plan with at least the requested vCPU and memory; the largest plan if none fits. */
254
+ function plan(vcpu, memoryGb) {
255
+ const cost = ([, c, g]) => c * 0.01667 + g * 0.00833;
256
+ const fits = PLANS.filter(([, c, g]) => c >= vcpu && g >= memoryGb).sort((a, b) => cost(a) - cost(b));
257
+ return (fits[0] ?? PLANS[PLANS.length - 1])[0];
258
+ }
259
+ function sleep(ms) {
260
+ return new Promise((r) => setTimeout(r, ms).unref());
261
+ }
@@ -0,0 +1,16 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ /**
3
+ * Runloop devboxes via `@runloop/api-client`. Auth: RUNLOOP_API_KEY. Default Runloop image;
4
+ * set image.runloop to a blueprint name (or a `bpt_` blueprint id) to start from your own.
5
+ */
6
+ export declare class RunloopProvider implements Provider {
7
+ private apiKey;
8
+ readonly id: "runloop";
9
+ private client?;
10
+ constructor(apiKey?: string | undefined);
11
+ configured(): boolean;
12
+ private runloop;
13
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
14
+ /** Reattach; a suspended devbox is resumed. */
15
+ connect(nativeId: string): Promise<ProviderSandbox>;
16
+ }
@@ -0,0 +1,145 @@
1
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
2
+ import { ProviderError } from "../types.js";
3
+ import { optionalImport, toBytes, wrapCommand } from "../util.js";
4
+ /** Longest lifetime Runloop accepts for keep_alive_time_seconds (48 h). */
5
+ const MAX_KEEP_ALIVE_SECONDS = 172_800;
6
+ /**
7
+ * Runloop devboxes via `@runloop/api-client`. Auth: RUNLOOP_API_KEY. Default Runloop image;
8
+ * set image.runloop to a blueprint name (or a `bpt_` blueprint id) to start from your own.
9
+ */
10
+ export class RunloopProvider {
11
+ apiKey;
12
+ id = "runloop";
13
+ client;
14
+ constructor(apiKey = process.env.RUNLOOP_API_KEY) {
15
+ this.apiKey = apiKey;
16
+ }
17
+ configured() {
18
+ return !!this.apiKey;
19
+ }
20
+ async runloop() {
21
+ if (!this.client) {
22
+ const { RunloopSDK } = await optionalImport("@runloop/api-client", "runloop");
23
+ this.client = new RunloopSDK({ bearerToken: this.apiKey });
24
+ }
25
+ return this.client;
26
+ }
27
+ async create(spec) {
28
+ const rl = await this.runloop();
29
+ const { cpu, memory } = size(spec.vcpu ?? DEFAULT_VCPU, spec.memoryGb ?? DEFAULT_MEMORY_GB);
30
+ const image = spec.image?.runloop;
31
+ try {
32
+ const box = await rl.devbox.create({
33
+ ...(image ? (image.startsWith("bpt_") ? { blueprint_id: image } : { blueprint_name: image }) : {}),
34
+ environment_variables: spec.env,
35
+ metadata: spec.metadata,
36
+ launch_parameters: {
37
+ resource_size_request: "CUSTOM_SIZE",
38
+ custom_cpu_cores: cpu,
39
+ custom_gb_memory: memory,
40
+ keep_alive_time_seconds: Math.min(MAX_KEEP_ALIVE_SECONDS, Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60))),
41
+ },
42
+ });
43
+ return new RunloopBox(box);
44
+ }
45
+ catch (err) {
46
+ throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
47
+ }
48
+ }
49
+ /** Reattach; a suspended devbox is resumed. */
50
+ async connect(nativeId) {
51
+ const box = (await this.runloop()).devbox.fromId(nativeId);
52
+ try {
53
+ const { status } = await box.getInfo();
54
+ if (status === "suspending")
55
+ await box.awaitSuspended();
56
+ if (status === "suspending" || status === "suspended")
57
+ await box.resume();
58
+ else if (status === "resuming" || status === "provisioning" || status === "initializing" || status === "queued" || status === "scheduled") {
59
+ await box.awaitRunning();
60
+ }
61
+ else if (status === "shutdown" || status === "failure") {
62
+ throw new ProviderError("runloop", `devbox ${nativeId} is ${status}`);
63
+ }
64
+ return new RunloopBox(box);
65
+ }
66
+ catch (err) {
67
+ if (err instanceof ProviderError)
68
+ throw err;
69
+ throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
70
+ }
71
+ }
72
+ }
73
+ class RunloopBox {
74
+ box;
75
+ nativeId;
76
+ constructor(box) {
77
+ this.box = box;
78
+ this.nativeId = box.id;
79
+ }
80
+ /** Runloop's exec has no cwd/env/timeout, so cwd/env are wrapped into the command and the timeout is enforced here. */
81
+ async exec(cmd, opts = {}) {
82
+ const t0 = Date.now();
83
+ const limit = (opts.timeoutSeconds ?? 60) * 1000;
84
+ let execution;
85
+ try {
86
+ execution = await this.box.cmd.execAsync(wrapCommand(cmd, { cwd: opts.cwd, env: opts.env }), {
87
+ stdout: opts.onStdout,
88
+ stderr: opts.onStderr,
89
+ });
90
+ }
91
+ catch (err) {
92
+ throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
93
+ }
94
+ try {
95
+ const r = await execution.result({ longPoll: { timeoutMs: limit } });
96
+ // Read the output from the result, not the stream, so a dropped stream can't lose output.
97
+ const [stdout, stderr] = await Promise.all([r.stdout(), r.stderr()]);
98
+ return { exitCode: r.exitCode ?? -1, stdout, stderr, durationMs: Date.now() - t0 };
99
+ }
100
+ catch (err) {
101
+ if (Date.now() - t0 >= limit) {
102
+ await execution.kill().catch(() => { });
103
+ throw new ProviderError("runloop", `command timed out after ${limit / 1000}s`);
104
+ }
105
+ throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
106
+ }
107
+ }
108
+ async writeFile(path, content) {
109
+ const { toFile } = await optionalImport("@runloop/api-client", "runloop");
110
+ const name = path.slice(path.lastIndexOf("/") + 1) || "file";
111
+ try {
112
+ await this.box.file.upload({ path, file: await toFile(Buffer.from(toBytes(content)), name) });
113
+ }
114
+ catch (err) {
115
+ throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
116
+ }
117
+ }
118
+ async readFile(path) {
119
+ try {
120
+ const res = await this.box.file.download({ path });
121
+ return new Uint8Array(await res.arrayBuffer());
122
+ }
123
+ catch (err) {
124
+ throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
125
+ }
126
+ }
127
+ /** Suspend and snapshot the disk. router.get() resumes it. */
128
+ async pause() {
129
+ await this.box.suspend();
130
+ await this.box.awaitSuspended();
131
+ }
132
+ async kill() {
133
+ await this.box.shutdown();
134
+ }
135
+ }
136
+ /**
137
+ * Snap a request to Runloop's CUSTOM_SIZE grid: CPU 0.5, 1 or a multiple of 2 (max 16);
138
+ * memory 1 or a multiple of 2 GiB (max 64); memory between 2x and 8x the CPU count.
139
+ */
140
+ function size(vcpu, memoryGb) {
141
+ const cpu = vcpu <= 0.5 ? 0.5 : vcpu <= 1 ? 1 : Math.min(16, 2 * Math.ceil(vcpu / 2));
142
+ const m = Math.min(8 * cpu, Math.max(2 * cpu, memoryGb));
143
+ const memory = Math.min(64, m <= 1 ? 1 : 2 * Math.ceil(m / 2));
144
+ return { cpu, memory };
145
+ }
@@ -0,0 +1,15 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ /**
3
+ * Fly.io Sprites via `@fly/sprites`. A sprite is a persistent machine named by the caller, so the
4
+ * name is the native id. Sprites idle on their own and have no lifetime knob: kill() deletes it.
5
+ */
6
+ export declare class SpritesProvider implements Provider {
7
+ private token;
8
+ readonly id: "sprites";
9
+ private client?;
10
+ constructor(token?: string | undefined);
11
+ configured(): boolean;
12
+ private sprites;
13
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
14
+ connect(nativeId: string): Promise<ProviderSandbox>;
15
+ }