@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.
- package/LICENSE +21 -0
- package/README.md +293 -2
- package/dist/bench-store.d.ts +12 -0
- package/dist/bench-store.js +23 -0
- package/dist/bench.d.ts +24 -0
- package/dist/bench.js +65 -0
- package/dist/catalog.d.ts +105 -0
- package/dist/catalog.js +320 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +195 -0
- package/dist/computers.d.ts +124 -0
- package/dist/computers.js +66 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/leaderboard.d.ts +39 -0
- package/dist/leaderboard.js +184 -0
- package/dist/providers/blaxel.d.ts +21 -0
- package/dist/providers/blaxel.js +152 -0
- package/dist/providers/boat.d.ts +25 -0
- package/dist/providers/boat.js +258 -0
- package/dist/providers/cloudflare.d.ts +30 -0
- package/dist/providers/cloudflare.js +147 -0
- package/dist/providers/daytona.d.ts +13 -0
- package/dist/providers/daytona.js +121 -0
- package/dist/providers/docker.d.ts +17 -0
- package/dist/providers/docker.js +148 -0
- package/dist/providers/e2b.d.ts +10 -0
- package/dist/providers/e2b.js +69 -0
- package/dist/providers/exedev.d.ts +23 -0
- package/dist/providers/exedev.js +278 -0
- package/dist/providers/hopx.d.ts +15 -0
- package/dist/providers/hopx.js +168 -0
- package/dist/providers/maritime.d.ts +38 -0
- package/dist/providers/maritime.js +240 -0
- package/dist/providers/modal.d.ts +10 -0
- package/dist/providers/modal.js +82 -0
- package/dist/providers/morph.d.ts +19 -0
- package/dist/providers/morph.js +181 -0
- package/dist/providers/northflank.d.ts +21 -0
- package/dist/providers/northflank.js +261 -0
- package/dist/providers/runloop.d.ts +16 -0
- package/dist/providers/runloop.js +145 -0
- package/dist/providers/sprites.d.ts +15 -0
- package/dist/providers/sprites.js +135 -0
- package/dist/providers/tensorlake.d.ts +14 -0
- package/dist/providers/tensorlake.js +192 -0
- package/dist/providers/vercel.d.ts +12 -0
- package/dist/providers/vercel.js +87 -0
- package/dist/router.d.ts +65 -0
- package/dist/router.js +199 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +257 -0
- package/dist/sse.d.ts +6 -0
- package/dist/sse.js +33 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +19 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +37 -0
- package/package.json +127 -4
package/dist/router.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { CATALOG, hourlyCost, unfit } from "./catalog.js";
|
|
2
|
+
import { RoutedComputer, createComputer } from "./computers.js";
|
|
3
|
+
import { loadBench, median } from "./bench-store.js";
|
|
4
|
+
import { DaytonaProvider } from "./providers/daytona.js";
|
|
5
|
+
import { E2BProvider } from "./providers/e2b.js";
|
|
6
|
+
import { MaritimeProvider } from "./providers/maritime.js";
|
|
7
|
+
import { ModalProvider } from "./providers/modal.js";
|
|
8
|
+
import { VercelProvider } from "./providers/vercel.js";
|
|
9
|
+
import { MorphProvider } from "./providers/morph.js";
|
|
10
|
+
import { SpritesProvider } from "./providers/sprites.js";
|
|
11
|
+
import { BlaxelProvider } from "./providers/blaxel.js";
|
|
12
|
+
import { TensorlakeProvider } from "./providers/tensorlake.js";
|
|
13
|
+
import { RunloopProvider } from "./providers/runloop.js";
|
|
14
|
+
import { DockerProvider } from "./providers/docker.js";
|
|
15
|
+
import { HopxProvider } from "./providers/hopx.js";
|
|
16
|
+
import { CloudflareProvider } from "./providers/cloudflare.js";
|
|
17
|
+
import { NorthflankProvider } from "./providers/northflank.js";
|
|
18
|
+
import { BoatProvider } from "./providers/boat.js";
|
|
19
|
+
import { ExeDevProvider } from "./providers/exedev.js";
|
|
20
|
+
import { RouterError, } from "./types.js";
|
|
21
|
+
export function defaultProviders() {
|
|
22
|
+
return [
|
|
23
|
+
new MaritimeProvider(),
|
|
24
|
+
new E2BProvider(),
|
|
25
|
+
new DaytonaProvider(),
|
|
26
|
+
new ModalProvider(),
|
|
27
|
+
new VercelProvider(),
|
|
28
|
+
new MorphProvider(),
|
|
29
|
+
new SpritesProvider(),
|
|
30
|
+
new BlaxelProvider(),
|
|
31
|
+
new TensorlakeProvider(),
|
|
32
|
+
new RunloopProvider(),
|
|
33
|
+
new HopxProvider(),
|
|
34
|
+
new CloudflareProvider(),
|
|
35
|
+
new NorthflankProvider(),
|
|
36
|
+
new BoatProvider(),
|
|
37
|
+
new ExeDevProvider(),
|
|
38
|
+
new DockerProvider(),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
export class Router {
|
|
42
|
+
providers;
|
|
43
|
+
benchFile;
|
|
44
|
+
constructor(opts = {}) {
|
|
45
|
+
this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
|
|
46
|
+
this.benchFile = opts.benchFile;
|
|
47
|
+
}
|
|
48
|
+
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
49
|
+
isConfigured(id) {
|
|
50
|
+
return this.providers.get(id)?.configured() ?? false;
|
|
51
|
+
}
|
|
52
|
+
/** Catalog ids this router has an adapter for, in catalog order. */
|
|
53
|
+
registered() {
|
|
54
|
+
return CATALOG.map((c) => c.id).filter((id) => this.providers.has(id));
|
|
55
|
+
}
|
|
56
|
+
provider(id) {
|
|
57
|
+
const p = this.providers.get(id);
|
|
58
|
+
if (!p)
|
|
59
|
+
throw new RouterError(`unknown provider: ${id}`);
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
/** The ordered list of providers a create would try, with the reason any are skipped. */
|
|
63
|
+
plan(spec = {}, route = {}) {
|
|
64
|
+
const explicit = route.provider ? [].concat(route.provider) : null;
|
|
65
|
+
// Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
|
|
66
|
+
// less than a cloud micro-VM. Name them explicitly to use them.
|
|
67
|
+
const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
|
|
68
|
+
const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
|
|
69
|
+
const list = ids.map((id) => {
|
|
70
|
+
const p = this.providers.get(id);
|
|
71
|
+
const c = {
|
|
72
|
+
provider: id,
|
|
73
|
+
estimatedHourlyUsd: round(hourlyCost(id, spec)),
|
|
74
|
+
medianCreateMs: median(bench[id]?.createMs),
|
|
75
|
+
};
|
|
76
|
+
if (!p)
|
|
77
|
+
c.skip = "unknown provider";
|
|
78
|
+
else
|
|
79
|
+
c.skip = unfit(id, spec, route.require) ?? (p.configured() ? undefined : "not configured");
|
|
80
|
+
return c;
|
|
81
|
+
});
|
|
82
|
+
// Neutral by construction: price (or measured speed) decides, ties break alphabetically by id.
|
|
83
|
+
// An explicit provider list keeps the caller's order.
|
|
84
|
+
if (!explicit) {
|
|
85
|
+
const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
86
|
+
if (route.strategy === "fastest") {
|
|
87
|
+
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
list.sort(byPrice);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return list;
|
|
94
|
+
}
|
|
95
|
+
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
96
|
+
async create(spec = {}, route = {}) {
|
|
97
|
+
const plan = this.plan(spec, route);
|
|
98
|
+
const usable = plan.filter((c) => !c.skip);
|
|
99
|
+
if (usable.length === 0) {
|
|
100
|
+
const why = plan.map((c) => `${c.provider} (${c.skip})`).join(", ");
|
|
101
|
+
throw new RouterError(`no provider can take this sandbox: ${why || "none registered"}`);
|
|
102
|
+
}
|
|
103
|
+
const attempts = [];
|
|
104
|
+
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
105
|
+
for (const c of tries) {
|
|
106
|
+
const t0 = Date.now();
|
|
107
|
+
try {
|
|
108
|
+
const box = await this.provider(c.provider).create(spec);
|
|
109
|
+
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
110
|
+
return new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
117
|
+
}
|
|
118
|
+
/** Providers that also sell computers (desktops/phones). */
|
|
119
|
+
computerProviders() {
|
|
120
|
+
return [...this.providers.values()].filter((p) => "createComputer" in p);
|
|
121
|
+
}
|
|
122
|
+
/** Get or create a desktop/phone. With `externalUserId`, the same id returns the same computer. */
|
|
123
|
+
computer(spec = {}, opts = {}) {
|
|
124
|
+
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
125
|
+
return createComputer(this.computerProviders(), spec, prefer);
|
|
126
|
+
}
|
|
127
|
+
async getComputer(id) {
|
|
128
|
+
const { provider, nativeId } = parseId(id);
|
|
129
|
+
const p = this.computerProviders().find((c) => c.id === provider);
|
|
130
|
+
if (!p)
|
|
131
|
+
throw new RouterError(`${provider} does not offer computers`);
|
|
132
|
+
return new RoutedComputer(provider, await p.connectComputer(nativeId));
|
|
133
|
+
}
|
|
134
|
+
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
135
|
+
async get(id) {
|
|
136
|
+
const { provider, nativeId } = parseId(id);
|
|
137
|
+
const box = await this.provider(provider).connect(nativeId);
|
|
138
|
+
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export class RoutedSandbox {
|
|
142
|
+
provider;
|
|
143
|
+
box;
|
|
144
|
+
attempts;
|
|
145
|
+
estimatedHourlyUsd;
|
|
146
|
+
/** Stable id across processes: `provider:nativeId`. */
|
|
147
|
+
id;
|
|
148
|
+
constructor(provider, box,
|
|
149
|
+
/** Every create attempt, including failed fallbacks. */
|
|
150
|
+
attempts, estimatedHourlyUsd) {
|
|
151
|
+
this.provider = provider;
|
|
152
|
+
this.box = box;
|
|
153
|
+
this.attempts = attempts;
|
|
154
|
+
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
155
|
+
this.id = `${provider}:${box.nativeId}`;
|
|
156
|
+
}
|
|
157
|
+
get nativeId() {
|
|
158
|
+
return this.box.nativeId;
|
|
159
|
+
}
|
|
160
|
+
exec(cmd, opts) {
|
|
161
|
+
return this.box.exec(cmd, opts);
|
|
162
|
+
}
|
|
163
|
+
writeFile(path, content) {
|
|
164
|
+
return this.box.writeFile(path, content);
|
|
165
|
+
}
|
|
166
|
+
readFile(path) {
|
|
167
|
+
return this.box.readFile(path);
|
|
168
|
+
}
|
|
169
|
+
async readText(path) {
|
|
170
|
+
return new TextDecoder().decode(await this.box.readFile(path));
|
|
171
|
+
}
|
|
172
|
+
kill() {
|
|
173
|
+
return this.box.kill();
|
|
174
|
+
}
|
|
175
|
+
/** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
|
|
176
|
+
async pause() {
|
|
177
|
+
if (!this.box.pause)
|
|
178
|
+
throw new RouterError(`${this.provider} does not support pause; create with require: ["pause"]`);
|
|
179
|
+
await this.box.pause();
|
|
180
|
+
}
|
|
181
|
+
get canPause() {
|
|
182
|
+
return typeof this.box.pause === "function";
|
|
183
|
+
}
|
|
184
|
+
async [Symbol.asyncDispose]() {
|
|
185
|
+
await this.kill();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function parseId(id) {
|
|
189
|
+
const i = id.indexOf(":");
|
|
190
|
+
if (i <= 0)
|
|
191
|
+
throw new RouterError(`bad sandbox id "${id}": expected provider:id`);
|
|
192
|
+
const provider = id.slice(0, i);
|
|
193
|
+
if (!CATALOG.some((c) => c.id === provider))
|
|
194
|
+
throw new RouterError(`unknown provider in id: ${provider}`);
|
|
195
|
+
return { provider, nativeId: id.slice(i + 1) };
|
|
196
|
+
}
|
|
197
|
+
function round(n) {
|
|
198
|
+
return Math.round(n * 10000) / 10000;
|
|
199
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { Router } from "./router.js";
|
|
3
|
+
/**
|
|
4
|
+
* OpenRouter-style HTTP API in front of every provider. One token for your clients; provider keys
|
|
5
|
+
* stay on this host.
|
|
6
|
+
*
|
|
7
|
+
* GET /v1/providers
|
|
8
|
+
* POST /v1/route dry run: which providers would be tried, in order
|
|
9
|
+
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require }
|
|
10
|
+
* POST /v1/sandboxes/:id/exec { cmd, cwd, env, timeoutSeconds, stream }
|
|
11
|
+
* stream: true answers text/event-stream: `stdout`/`stderr` events (JSON string chunks),
|
|
12
|
+
* then one `result` event (the ExecResult), or an `error` event
|
|
13
|
+
* POST /v1/sandboxes/:id/pause keeps the disk; the next call resumes it
|
|
14
|
+
* PUT /v1/sandboxes/:id/files?path= raw body
|
|
15
|
+
* GET /v1/sandboxes/:id/files?path= raw body
|
|
16
|
+
* DELETE /v1/sandboxes/:id
|
|
17
|
+
*
|
|
18
|
+
* POST /v1/computers { os, externalUserId, name, screen, arch, provider }
|
|
19
|
+
* GET /v1/computers/:id
|
|
20
|
+
* POST /v1/computers/:id/actions one action, or { actions: [...] }
|
|
21
|
+
* GET /v1/computers/:id/screenshot image bytes
|
|
22
|
+
* POST /v1/computers/:id/exec { command, timeoutSeconds }
|
|
23
|
+
* PUT /v1/computers/:id/files?path= raw body
|
|
24
|
+
* GET /v1/computers/:id/files?path= raw body
|
|
25
|
+
* POST /v1/computers/:id/viewer { mode: "watch" | "control", ttlSeconds, reason }
|
|
26
|
+
* POST /v1/computers/:id/sleep
|
|
27
|
+
* DELETE /v1/computers/:id
|
|
28
|
+
*/
|
|
29
|
+
export declare function createApp(router?: Router, token?: string | undefined,
|
|
30
|
+
/** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
|
|
31
|
+
corsOrigins?: string[]): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { CATALOG, hourlyCost } from "./catalog.js";
|
|
3
|
+
import { Router } from "./router.js";
|
|
4
|
+
import { ProviderError, RouterError } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* OpenRouter-style HTTP API in front of every provider. One token for your clients; provider keys
|
|
7
|
+
* stay on this host.
|
|
8
|
+
*
|
|
9
|
+
* GET /v1/providers
|
|
10
|
+
* POST /v1/route dry run: which providers would be tried, in order
|
|
11
|
+
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require }
|
|
12
|
+
* POST /v1/sandboxes/:id/exec { cmd, cwd, env, timeoutSeconds, stream }
|
|
13
|
+
* stream: true answers text/event-stream: `stdout`/`stderr` events (JSON string chunks),
|
|
14
|
+
* then one `result` event (the ExecResult), or an `error` event
|
|
15
|
+
* POST /v1/sandboxes/:id/pause keeps the disk; the next call resumes it
|
|
16
|
+
* PUT /v1/sandboxes/:id/files?path= raw body
|
|
17
|
+
* GET /v1/sandboxes/:id/files?path= raw body
|
|
18
|
+
* DELETE /v1/sandboxes/:id
|
|
19
|
+
*
|
|
20
|
+
* POST /v1/computers { os, externalUserId, name, screen, arch, provider }
|
|
21
|
+
* GET /v1/computers/:id
|
|
22
|
+
* POST /v1/computers/:id/actions one action, or { actions: [...] }
|
|
23
|
+
* GET /v1/computers/:id/screenshot image bytes
|
|
24
|
+
* POST /v1/computers/:id/exec { command, timeoutSeconds }
|
|
25
|
+
* PUT /v1/computers/:id/files?path= raw body
|
|
26
|
+
* GET /v1/computers/:id/files?path= raw body
|
|
27
|
+
* POST /v1/computers/:id/viewer { mode: "watch" | "control", ttlSeconds, reason }
|
|
28
|
+
* POST /v1/computers/:id/sleep
|
|
29
|
+
* DELETE /v1/computers/:id
|
|
30
|
+
*/
|
|
31
|
+
export function createApp(router = new Router(), token = process.env.SANDBOXROUTER_TOKEN,
|
|
32
|
+
/** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
|
|
33
|
+
corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
34
|
+
const live = new Map();
|
|
35
|
+
const cors = (req, res) => {
|
|
36
|
+
const origin = req.headers.origin;
|
|
37
|
+
if (!origin || !(corsOrigins.includes("*") || corsOrigins.includes(origin)))
|
|
38
|
+
return false;
|
|
39
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
40
|
+
res.setHeader("Vary", "Origin");
|
|
41
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept");
|
|
42
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
43
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
44
|
+
// Chrome asks before a public HTTPS page (the hosted Playground) may call a server on localhost.
|
|
45
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
46
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
const handle = async (req, res) => {
|
|
51
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
52
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
53
|
+
const allowed = cors(req, res);
|
|
54
|
+
if (req.method === "OPTIONS") {
|
|
55
|
+
res.writeHead(allowed ? 204 : 403);
|
|
56
|
+
return res.end();
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname === "/health")
|
|
59
|
+
return json(res, 200, { ok: true });
|
|
60
|
+
if (token && req.headers.authorization !== `Bearer ${token}`)
|
|
61
|
+
return json(res, 401, { error: "unauthorized" });
|
|
62
|
+
if (parts[0] !== "v1")
|
|
63
|
+
return json(res, 404, { error: "not found" });
|
|
64
|
+
const box = async (id) => live.get(id) ?? router.get(id);
|
|
65
|
+
// GET /v1/providers
|
|
66
|
+
if (req.method === "GET" && parts[1] === "providers" && parts.length === 2) {
|
|
67
|
+
return json(res, 200, {
|
|
68
|
+
providers: CATALOG.filter((c) => router.registered().includes(c.id)).map((c) => ({
|
|
69
|
+
...c,
|
|
70
|
+
configured: router.isConfigured(c.id),
|
|
71
|
+
estimatedHourlyUsd: hourlyCost(c.id),
|
|
72
|
+
})),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// POST /v1/route
|
|
76
|
+
if (req.method === "POST" && parts[1] === "route" && parts.length === 2) {
|
|
77
|
+
const { spec, route } = splitBody(await readJson(req));
|
|
78
|
+
return json(res, 200, { candidates: router.plan(spec, route) });
|
|
79
|
+
}
|
|
80
|
+
if (parts[1] === "computers")
|
|
81
|
+
return computers(req, res, url, parts);
|
|
82
|
+
if (parts[1] !== "sandboxes")
|
|
83
|
+
return json(res, 404, { error: "not found" });
|
|
84
|
+
// POST /v1/sandboxes
|
|
85
|
+
if (req.method === "POST" && parts.length === 2) {
|
|
86
|
+
const { spec, route } = splitBody(await readJson(req));
|
|
87
|
+
const sbx = await router.create(spec, route);
|
|
88
|
+
live.set(sbx.id, sbx);
|
|
89
|
+
return json(res, 201, {
|
|
90
|
+
id: sbx.id,
|
|
91
|
+
provider: sbx.provider,
|
|
92
|
+
estimatedHourlyUsd: sbx.estimatedHourlyUsd,
|
|
93
|
+
attempts: sbx.attempts,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
97
|
+
if (!id)
|
|
98
|
+
return json(res, 404, { error: "not found" });
|
|
99
|
+
// POST /v1/sandboxes/:id/exec
|
|
100
|
+
if (req.method === "POST" && parts[3] === "exec") {
|
|
101
|
+
const body = await readJson(req);
|
|
102
|
+
if (typeof body.cmd !== "string" || !body.cmd)
|
|
103
|
+
return json(res, 400, { error: "cmd (string) is required" });
|
|
104
|
+
const sbx = await box(id);
|
|
105
|
+
const opts = { cwd: body.cwd, env: body.env, timeoutSeconds: body.timeoutSeconds };
|
|
106
|
+
if (body.stream !== true)
|
|
107
|
+
return json(res, 200, await sbx.exec(body.cmd, opts));
|
|
108
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
109
|
+
const emit = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
110
|
+
try {
|
|
111
|
+
const r = await sbx.exec(body.cmd, { ...opts, onStdout: (c) => emit("stdout", c), onStderr: (c) => emit("stderr", c) });
|
|
112
|
+
emit("result", r);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
emit("error", { error: err.message });
|
|
116
|
+
}
|
|
117
|
+
return res.end();
|
|
118
|
+
}
|
|
119
|
+
// POST /v1/sandboxes/:id/pause
|
|
120
|
+
if (req.method === "POST" && parts[3] === "pause" && parts.length === 4) {
|
|
121
|
+
await (await box(id)).pause();
|
|
122
|
+
// Drop the cached handle so the next call reattaches through router.get(), which resumes.
|
|
123
|
+
live.delete(id);
|
|
124
|
+
return json(res, 200, { id, paused: true });
|
|
125
|
+
}
|
|
126
|
+
// /v1/sandboxes/:id/files?path=
|
|
127
|
+
if (parts[3] === "files") {
|
|
128
|
+
const path = url.searchParams.get("path");
|
|
129
|
+
if (!path)
|
|
130
|
+
return json(res, 400, { error: "path query parameter is required" });
|
|
131
|
+
if (req.method === "PUT") {
|
|
132
|
+
await (await box(id)).writeFile(path, await readRaw(req));
|
|
133
|
+
return json(res, 200, { ok: true, path });
|
|
134
|
+
}
|
|
135
|
+
if (req.method === "GET") {
|
|
136
|
+
const data = await (await box(id)).readFile(path);
|
|
137
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": data.byteLength });
|
|
138
|
+
return res.end(Buffer.from(data));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// DELETE /v1/sandboxes/:id
|
|
142
|
+
if (req.method === "DELETE" && parts.length === 3) {
|
|
143
|
+
await (await box(id)).kill();
|
|
144
|
+
live.delete(id);
|
|
145
|
+
return json(res, 200, { id, killed: true });
|
|
146
|
+
}
|
|
147
|
+
return json(res, 404, { error: "not found" });
|
|
148
|
+
};
|
|
149
|
+
const computers = async (req, res, url, parts) => {
|
|
150
|
+
if (req.method === "POST" && parts.length === 2) {
|
|
151
|
+
const { provider, ...spec } = await readJson(req);
|
|
152
|
+
const c = await router.computer(spec, { provider });
|
|
153
|
+
return json(res, 201, { ...(await c.info()), id: c.id, nativeId: c.nativeId, provider: c.provider });
|
|
154
|
+
}
|
|
155
|
+
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
156
|
+
if (!id)
|
|
157
|
+
return json(res, 404, { error: "not found" });
|
|
158
|
+
const c = await router.getComputer(id);
|
|
159
|
+
const sub = parts[3];
|
|
160
|
+
if (req.method === "GET" && !sub)
|
|
161
|
+
return json(res, 200, { ...(await c.info()), id, nativeId: c.nativeId });
|
|
162
|
+
if (req.method === "DELETE" && !sub) {
|
|
163
|
+
await c.delete();
|
|
164
|
+
return json(res, 200, { id, deleted: true });
|
|
165
|
+
}
|
|
166
|
+
if (req.method === "POST" && sub === "actions") {
|
|
167
|
+
const body = await readJson(req);
|
|
168
|
+
return json(res, 200, Array.isArray(body.actions) ? { results: await c.actBatch(body.actions) } : await c.act(body));
|
|
169
|
+
}
|
|
170
|
+
if (req.method === "GET" && sub === "screenshot") {
|
|
171
|
+
const format = url.searchParams.get("format") === "jpeg" ? "jpeg" : "png";
|
|
172
|
+
const img = await c.screenshot({ format, quality: Number(url.searchParams.get("quality")) || undefined });
|
|
173
|
+
res.writeHead(200, { "Content-Type": `image/${format}`, "Content-Length": img.byteLength });
|
|
174
|
+
return res.end(Buffer.from(img));
|
|
175
|
+
}
|
|
176
|
+
if (req.method === "POST" && sub === "exec") {
|
|
177
|
+
const body = await readJson(req);
|
|
178
|
+
if (typeof body.command !== "string" || !body.command)
|
|
179
|
+
return json(res, 400, { error: "command (string) is required" });
|
|
180
|
+
return json(res, 200, await c.exec(body.command, body.timeoutSeconds));
|
|
181
|
+
}
|
|
182
|
+
if (sub === "files") {
|
|
183
|
+
const path = url.searchParams.get("path");
|
|
184
|
+
if (!path)
|
|
185
|
+
return json(res, 400, { error: "path query parameter is required" });
|
|
186
|
+
if (req.method === "PUT") {
|
|
187
|
+
await c.writeFile(path, await readRaw(req));
|
|
188
|
+
return json(res, 200, { ok: true, path });
|
|
189
|
+
}
|
|
190
|
+
if (req.method === "GET") {
|
|
191
|
+
const data = await c.readFile(path);
|
|
192
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": data.byteLength });
|
|
193
|
+
return res.end(Buffer.from(data));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (req.method === "POST" && sub === "viewer") {
|
|
197
|
+
const body = await readJson(req);
|
|
198
|
+
return json(res, 200, await c.viewer(body.mode, { ttlSeconds: body.ttlSeconds, reason: body.reason }));
|
|
199
|
+
}
|
|
200
|
+
if (req.method === "POST" && sub === "sleep") {
|
|
201
|
+
await c.sleep();
|
|
202
|
+
return json(res, 200, { id, sleeping: true });
|
|
203
|
+
}
|
|
204
|
+
return json(res, 404, { error: "not found" });
|
|
205
|
+
};
|
|
206
|
+
return createServer((req, res) => {
|
|
207
|
+
handle(req, res).catch((err) => {
|
|
208
|
+
const status = err instanceof HttpError ? err.status
|
|
209
|
+
: err instanceof RouterError ? 502
|
|
210
|
+
: err instanceof ProviderError && err.status ? err.status
|
|
211
|
+
: 500;
|
|
212
|
+
json(res, status, { error: err.message, attempts: err.attempts });
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
class HttpError extends Error {
|
|
217
|
+
status;
|
|
218
|
+
constructor(status, message) {
|
|
219
|
+
super(message);
|
|
220
|
+
this.status = status;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const ROUTE_KEYS = ["provider", "strategy", "fallback", "require"];
|
|
224
|
+
function splitBody(body) {
|
|
225
|
+
const route = {};
|
|
226
|
+
const spec = {};
|
|
227
|
+
for (const [k, v] of Object.entries(body))
|
|
228
|
+
(ROUTE_KEYS.includes(k) ? route : spec)[k] = v;
|
|
229
|
+
return { spec: spec, route: route };
|
|
230
|
+
}
|
|
231
|
+
const MAX_BODY = 100 * 1024 * 1024;
|
|
232
|
+
async function readRaw(req) {
|
|
233
|
+
const chunks = [];
|
|
234
|
+
let size = 0;
|
|
235
|
+
for await (const c of req) {
|
|
236
|
+
size += c.length;
|
|
237
|
+
if (size > MAX_BODY)
|
|
238
|
+
throw new HttpError(413, "request body too large");
|
|
239
|
+
chunks.push(c);
|
|
240
|
+
}
|
|
241
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
242
|
+
}
|
|
243
|
+
async function readJson(req) {
|
|
244
|
+
const raw = Buffer.from(await readRaw(req)).toString("utf8");
|
|
245
|
+
if (!raw)
|
|
246
|
+
return {};
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(raw);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
throw new HttpError(400, "invalid JSON body");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function json(res, status, body) {
|
|
255
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
256
|
+
res.end(JSON.stringify(body));
|
|
257
|
+
}
|
package/dist/sse.d.ts
ADDED
package/dist/sse.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Minimal text/event-stream reader for a fetch Response. */
|
|
2
|
+
export async function* readSSE(res) {
|
|
3
|
+
if (!res.body)
|
|
4
|
+
return;
|
|
5
|
+
const reader = res.body.getReader();
|
|
6
|
+
const decoder = new TextDecoder();
|
|
7
|
+
let buffer = "";
|
|
8
|
+
let event = "message";
|
|
9
|
+
let data = [];
|
|
10
|
+
for (;;) {
|
|
11
|
+
const { done, value } = await reader.read();
|
|
12
|
+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
13
|
+
if (done)
|
|
14
|
+
buffer += "\n\n";
|
|
15
|
+
let nl;
|
|
16
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
17
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
18
|
+
buffer = buffer.slice(nl + 1);
|
|
19
|
+
if (line === "") {
|
|
20
|
+
if (data.length)
|
|
21
|
+
yield { event, data: data.join("\n") };
|
|
22
|
+
event = "message";
|
|
23
|
+
data = [];
|
|
24
|
+
}
|
|
25
|
+
else if (line.startsWith("event:"))
|
|
26
|
+
event = line.slice(6).trim();
|
|
27
|
+
else if (line.startsWith("data:"))
|
|
28
|
+
data.push(line.slice(5).replace(/^ /, ""));
|
|
29
|
+
}
|
|
30
|
+
if (done)
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type ProviderId = "maritime" | "e2b" | "daytona" | "modal" | "vercel" | "morph" | "sprites" | "blaxel" | "tensorlake" | "runloop" | "hopx" | "cloudflare" | "northflank" | "boat" | "exedev" | "docker";
|
|
2
|
+
/** Things a caller can require of a provider. The router skips providers that lack one. */
|
|
3
|
+
export type Capability = "gpu" | "pause" | "custom-image";
|
|
4
|
+
/** What the caller wants. Every field is optional; providers map it to their own knobs. */
|
|
5
|
+
export interface SandboxSpec {
|
|
6
|
+
/** vCPUs (default 2). */
|
|
7
|
+
vcpu?: number;
|
|
8
|
+
/** Memory in GB (default 4). */
|
|
9
|
+
memoryGb?: number;
|
|
10
|
+
/** GPU type, e.g. "T4", "A100". Only providers with the "gpu" capability accept it. */
|
|
11
|
+
gpu?: string;
|
|
12
|
+
/** Env vars set for every command. */
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
/** Hard lifetime in minutes (default 30). */
|
|
15
|
+
timeoutMinutes?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Provider-specific image/template, keyed by provider, e.g.
|
|
18
|
+
* { maritime: "python", e2b: "code-interpreter-v1", modal: "python:3.13-slim" }.
|
|
19
|
+
*/
|
|
20
|
+
image?: Partial<Record<ProviderId, string>>;
|
|
21
|
+
/** Free-form tags stored on the sandbox where the provider supports it. */
|
|
22
|
+
metadata?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* "cheapest" (and "default", the same thing): lowest list price for the spec, ties alphabetical.
|
|
26
|
+
* "fastest": lowest median cold start you measured with `bench`, then cheapest.
|
|
27
|
+
*/
|
|
28
|
+
export type Strategy = "default" | "cheapest" | "fastest";
|
|
29
|
+
export interface RouteOptions {
|
|
30
|
+
/** Pin to one provider, or give an ordered list to try. Omit to let the router choose. */
|
|
31
|
+
provider?: ProviderId | ProviderId[];
|
|
32
|
+
/** How to order candidates when no explicit list is given. Default: cheapest. */
|
|
33
|
+
strategy?: Strategy;
|
|
34
|
+
/** Try the next candidate if a create fails (default true). */
|
|
35
|
+
fallback?: boolean;
|
|
36
|
+
/** Capabilities every candidate must have. */
|
|
37
|
+
require?: Capability[];
|
|
38
|
+
}
|
|
39
|
+
export interface ExecOptions {
|
|
40
|
+
cwd?: string;
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
/** Per-command timeout in seconds (default 60). */
|
|
43
|
+
timeoutSeconds?: number;
|
|
44
|
+
/** Called with each chunk of stdout as it arrives. The full output is still returned. */
|
|
45
|
+
onStdout?: (chunk: string) => void;
|
|
46
|
+
/** Called with each chunk of stderr as it arrives. */
|
|
47
|
+
onStderr?: (chunk: string) => void;
|
|
48
|
+
}
|
|
49
|
+
export interface ExecResult {
|
|
50
|
+
exitCode: number;
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
durationMs: number;
|
|
54
|
+
}
|
|
55
|
+
/** A live sandbox as one provider sees it. Adapters implement this. */
|
|
56
|
+
export interface ProviderSandbox {
|
|
57
|
+
readonly nativeId: string;
|
|
58
|
+
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
59
|
+
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
60
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
61
|
+
kill(): Promise<void>;
|
|
62
|
+
/** Present only on adapters with the "pause" capability. */
|
|
63
|
+
pause?(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
export interface Provider {
|
|
66
|
+
readonly id: ProviderId;
|
|
67
|
+
/** True when the credentials this adapter needs are present. */
|
|
68
|
+
configured(): boolean;
|
|
69
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
70
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
71
|
+
}
|
|
72
|
+
export interface Attempt {
|
|
73
|
+
provider: ProviderId;
|
|
74
|
+
ok: boolean;
|
|
75
|
+
ms: number;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
export declare class RouterError extends Error {
|
|
79
|
+
readonly attempts: Attempt[];
|
|
80
|
+
constructor(message: string, attempts?: Attempt[]);
|
|
81
|
+
}
|
|
82
|
+
/** Thrown by adapters for HTTP failures so the router can report status codes. */
|
|
83
|
+
export declare class ProviderError extends Error {
|
|
84
|
+
readonly provider: ProviderId;
|
|
85
|
+
readonly status?: number | undefined;
|
|
86
|
+
constructor(provider: ProviderId, message: string, status?: number | undefined);
|
|
87
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class RouterError extends Error {
|
|
2
|
+
attempts;
|
|
3
|
+
constructor(message, attempts = []) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.attempts = attempts;
|
|
6
|
+
this.name = "RouterError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Thrown by adapters for HTTP failures so the router can report status codes. */
|
|
10
|
+
export class ProviderError extends Error {
|
|
11
|
+
provider;
|
|
12
|
+
status;
|
|
13
|
+
constructor(provider, message, status) {
|
|
14
|
+
super(`${provider}: ${message}`);
|
|
15
|
+
this.provider = provider;
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.name = "ProviderError";
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Single-quote a string for POSIX sh. */
|
|
2
|
+
export declare function shq(s: string): string;
|
|
3
|
+
/** Build `cd dir && VAR=x cmd` for providers whose exec API lacks cwd/env. */
|
|
4
|
+
export declare function wrapCommand(cmd: string, opts?: {
|
|
5
|
+
cwd?: string;
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function toBytes(content: string | Uint8Array): Uint8Array;
|
|
9
|
+
export declare function dirname(path: string): string;
|
|
10
|
+
/** Load an optional peer dependency with a helpful error. */
|
|
11
|
+
export declare function optionalImport<T>(pkg: string, provider: string): Promise<T>;
|