@sandboxrouter/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -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 +35 -0
  8. package/dist/catalog.js +198 -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 +17 -0
  14. package/dist/index.js +17 -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/daytona.d.ts +13 -0
  20. package/dist/providers/daytona.js +121 -0
  21. package/dist/providers/e2b.d.ts +10 -0
  22. package/dist/providers/e2b.js +69 -0
  23. package/dist/providers/maritime.d.ts +38 -0
  24. package/dist/providers/maritime.js +240 -0
  25. package/dist/providers/modal.d.ts +10 -0
  26. package/dist/providers/modal.js +82 -0
  27. package/dist/providers/morph.d.ts +19 -0
  28. package/dist/providers/morph.js +181 -0
  29. package/dist/providers/runloop.d.ts +16 -0
  30. package/dist/providers/runloop.js +145 -0
  31. package/dist/providers/sprites.d.ts +15 -0
  32. package/dist/providers/sprites.js +135 -0
  33. package/dist/providers/tensorlake.d.ts +14 -0
  34. package/dist/providers/tensorlake.js +192 -0
  35. package/dist/providers/vercel.d.ts +12 -0
  36. package/dist/providers/vercel.js +87 -0
  37. package/dist/router.d.ts +65 -0
  38. package/dist/router.js +180 -0
  39. package/dist/server.d.ts +31 -0
  40. package/dist/server.js +257 -0
  41. package/dist/sse.d.ts +6 -0
  42. package/dist/sse.js +33 -0
  43. package/dist/types.d.ts +83 -0
  44. package/dist/types.js +19 -0
  45. package/dist/util.d.ts +11 -0
  46. package/dist/util.js +37 -0
  47. package/package.json +113 -4
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
3
+ import { ProviderError } from "../types.js";
4
+ import { optionalImport, shq, toBytes, wrapCommand } from "../util.js";
5
+ /** Tensorlake has no sandbox-level env, so spec.env lives in this file and every exec sources it. */
6
+ const ENV_FILE = "/tmp/.sandboxrouter.env";
7
+ /**
8
+ * Tensorlake sandboxes via `@tensorlakeai/tensorlake`. Auth: TENSORLAKE_API_KEY.
9
+ * Set image.tensorlake to start from your own sandbox image; spec.gpu maps to a GPU model.
10
+ */
11
+ export class TensorlakeProvider {
12
+ apiKey;
13
+ id = "tensorlake";
14
+ constructor(apiKey = process.env.TENSORLAKE_API_KEY) {
15
+ this.apiKey = apiKey;
16
+ }
17
+ configured() {
18
+ return !!this.apiKey;
19
+ }
20
+ async create(spec) {
21
+ const { Sandbox } = await optionalImport("@tensorlakeai/tensorlake", "tensorlake");
22
+ const image = spec.image?.tensorlake;
23
+ let sbx;
24
+ try {
25
+ sbx = await Sandbox.create({
26
+ apiKey: this.apiKey,
27
+ image,
28
+ cpus: spec.vcpu ?? DEFAULT_VCPU,
29
+ memoryMb: Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024),
30
+ timeoutSecs: Math.round((spec.timeoutMinutes ?? 30) * 60),
31
+ ...(spec.gpu ? { gpuModel: spec.gpu } : {}),
32
+ });
33
+ }
34
+ catch (err) {
35
+ throw fail(err);
36
+ }
37
+ const box = new TensorlakeBox(sbx);
38
+ if (spec.env && Object.keys(spec.env).length > 0) {
39
+ try {
40
+ await box.writeFile(ENV_FILE, wrapCommand(":", { env: spec.env }) + "\n");
41
+ }
42
+ catch (err) {
43
+ await sbx.terminate().catch(() => { });
44
+ throw err;
45
+ }
46
+ }
47
+ return box;
48
+ }
49
+ /** Reattach; a suspended sandbox is resumed. */
50
+ async connect(nativeId) {
51
+ const { Sandbox } = await optionalImport("@tensorlakeai/tensorlake", "tensorlake");
52
+ try {
53
+ const sbx = await Sandbox.connect({ sandboxId: nativeId, apiKey: this.apiKey });
54
+ let status = await sbx.status();
55
+ // resume() is rejected while a suspend is still in flight.
56
+ for (let i = 0; status === "suspending" && i < 300; i++) {
57
+ await sleep(1000);
58
+ status = await sbx.status();
59
+ }
60
+ if (status === "suspended")
61
+ await sbx.resume();
62
+ return new TensorlakeBox(sbx);
63
+ }
64
+ catch (err) {
65
+ throw fail(err);
66
+ }
67
+ }
68
+ }
69
+ class TensorlakeBox {
70
+ sbx;
71
+ nativeId;
72
+ constructor(sbx) {
73
+ this.sbx = sbx;
74
+ this.nativeId = sbx.sandboxId;
75
+ }
76
+ /** `sh -c` so cmd is a shell line; spec.env is sourced first so per-call env wins. */
77
+ shellLine(cmd, opts) {
78
+ return `[ -f ${shq(ENV_FILE)} ] && . ${shq(ENV_FILE)}; ${wrapCommand(cmd, { cwd: opts.cwd, env: opts.env })}`;
79
+ }
80
+ async exec(cmd, opts = {}) {
81
+ if (opts.onStdout || opts.onStderr)
82
+ return this.execStreaming(cmd, opts);
83
+ const t0 = Date.now();
84
+ const limit = opts.timeoutSeconds ?? 60;
85
+ try {
86
+ const r = await this.sbx.run("/bin/sh", { args: ["-c", this.shellLine(cmd, opts)], timeout: limit });
87
+ if (r.timedOut)
88
+ throw new ProviderError("tensorlake", `command timed out after ${limit}s`);
89
+ // A nonzero exit is a result; the SDK only throws for transport/API failures.
90
+ return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, durationMs: Date.now() - t0 };
91
+ }
92
+ catch (err) {
93
+ throw fail(err);
94
+ }
95
+ }
96
+ /** Start the process, then follow its combined output stream until it exits. */
97
+ async execStreaming(cmd, opts) {
98
+ const t0 = Date.now();
99
+ const limit = (opts.timeoutSeconds ?? 60) * 1000;
100
+ const abort = new AbortController();
101
+ const stdout = [];
102
+ const stderr = [];
103
+ let pid;
104
+ const timer = setTimeout(() => abort.abort(), limit);
105
+ timer.unref();
106
+ try {
107
+ ({ pid } = await this.sbx.startProcess("/bin/sh", { args: ["-c", this.shellLine(cmd, opts)] }));
108
+ try {
109
+ for await (const ev of this.sbx.followOutput(pid, { signal: abort.signal })) {
110
+ if (ev.stream === "stderr") {
111
+ stderr.push(ev.line);
112
+ opts.onStderr?.(ev.line + "\n");
113
+ }
114
+ else {
115
+ stdout.push(ev.line);
116
+ opts.onStdout?.(ev.line + "\n");
117
+ }
118
+ }
119
+ }
120
+ catch (err) {
121
+ if (!abort.signal.aborted)
122
+ throw err;
123
+ }
124
+ if (abort.signal.aborted) {
125
+ await this.sbx.killProcess(pid).catch(() => { });
126
+ throw new ProviderError("tensorlake", `command timed out after ${limit / 1000}s`);
127
+ }
128
+ // The stream can close a moment before the daemon records the exit.
129
+ let info = await this.sbx.getProcess(pid);
130
+ for (let i = 0; info.status === "running" && i < 20; i++) {
131
+ await sleep(250);
132
+ info = await this.sbx.getProcess(pid);
133
+ }
134
+ const exitCode = info.exitCode ?? (info.signal != null ? -info.signal : -1);
135
+ return { exitCode, stdout: stdout.join("\n"), stderr: stderr.join("\n"), durationMs: Date.now() - t0 };
136
+ }
137
+ catch (err) {
138
+ throw fail(err);
139
+ }
140
+ finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+ async writeFile(path, content) {
145
+ try {
146
+ await this.sbx.writeFile(path, toBytes(content));
147
+ }
148
+ catch (err) {
149
+ throw fail(err);
150
+ }
151
+ }
152
+ async readFile(path) {
153
+ try {
154
+ return new Uint8Array(await this.sbx.readFile(path));
155
+ }
156
+ catch (err) {
157
+ throw fail(err);
158
+ }
159
+ }
160
+ /**
161
+ * Suspend, keeping memory and disk. Only named sandboxes can suspend, so an unnamed one is
162
+ * named first. router.get() resumes it.
163
+ */
164
+ async pause() {
165
+ try {
166
+ const info = await this.sbx.info();
167
+ if (!info.name)
168
+ await this.sbx.update({ name: `sbr-${randomUUID()}` });
169
+ await this.sbx.suspend();
170
+ }
171
+ catch (err) {
172
+ throw fail(err);
173
+ }
174
+ }
175
+ async kill() {
176
+ try {
177
+ await this.sbx.terminate();
178
+ }
179
+ catch (err) {
180
+ throw fail(err);
181
+ }
182
+ }
183
+ }
184
+ function fail(err) {
185
+ if (err instanceof ProviderError)
186
+ return err;
187
+ const status = typeof err?.statusCode === "number" ? err.statusCode : undefined;
188
+ return new ProviderError("tensorlake", err?.message ?? String(err), status);
189
+ }
190
+ function sleep(ms) {
191
+ return new Promise((r) => setTimeout(r, ms).unref());
192
+ }
@@ -0,0 +1,12 @@
1
+ import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
2
+ /**
3
+ * Vercel Sandbox via `@vercel/sandbox`. Auth: VERCEL_OIDC_TOKEN (from `vercel env pull`), or
4
+ * VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID. Memory is fixed at 2 GB per vCPU.
5
+ */
6
+ export declare class VercelProvider implements Provider {
7
+ readonly id: "vercel";
8
+ configured(): boolean;
9
+ private creds;
10
+ create(spec: SandboxSpec): Promise<ProviderSandbox>;
11
+ connect(nativeId: string): Promise<ProviderSandbox>;
12
+ }
@@ -0,0 +1,87 @@
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
+ /**
5
+ * Vercel Sandbox via `@vercel/sandbox`. Auth: VERCEL_OIDC_TOKEN (from `vercel env pull`), or
6
+ * VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID. Memory is fixed at 2 GB per vCPU.
7
+ */
8
+ export class VercelProvider {
9
+ id = "vercel";
10
+ configured() {
11
+ const e = process.env;
12
+ return !!e.VERCEL_OIDC_TOKEN || (!!e.VERCEL_TOKEN && !!e.VERCEL_TEAM_ID && !!e.VERCEL_PROJECT_ID);
13
+ }
14
+ creds() {
15
+ const e = process.env;
16
+ return e.VERCEL_TOKEN && e.VERCEL_TEAM_ID && e.VERCEL_PROJECT_ID
17
+ ? { token: e.VERCEL_TOKEN, teamId: e.VERCEL_TEAM_ID, projectId: e.VERCEL_PROJECT_ID }
18
+ : {};
19
+ }
20
+ async create(spec) {
21
+ const { Sandbox } = await optionalImport("@vercel/sandbox", "vercel");
22
+ const vcpus = Math.min(8, Math.max(spec.vcpu ?? DEFAULT_VCPU, Math.ceil((spec.memoryGb ?? DEFAULT_MEMORY_GB) / 2)));
23
+ const sbx = await Sandbox.create({
24
+ ...this.creds(),
25
+ runtime: spec.image?.vercel ?? "node22",
26
+ timeout: (spec.timeoutMinutes ?? 30) * 60_000,
27
+ resources: { vcpus },
28
+ env: spec.env,
29
+ tags: spec.metadata,
30
+ });
31
+ return new VercelBox(sbx);
32
+ }
33
+ async connect(nativeId) {
34
+ const { Sandbox } = await optionalImport("@vercel/sandbox", "vercel");
35
+ return new VercelBox(await Sandbox.get({ ...this.creds(), name: nativeId }));
36
+ }
37
+ }
38
+ class VercelBox {
39
+ sbx;
40
+ nativeId;
41
+ constructor(sbx) {
42
+ this.sbx = sbx;
43
+ this.nativeId = sbx.name;
44
+ }
45
+ async exec(cmd, opts = {}) {
46
+ const t0 = Date.now();
47
+ const signal = AbortSignal.timeout((opts.timeoutSeconds ?? 60) * 1000);
48
+ try {
49
+ const params = { cmd: "sh", args: ["-c", wrapCommand(cmd)], cwd: opts.cwd, env: opts.env, signal };
50
+ if (opts.onStdout || opts.onStderr) {
51
+ const running = await this.sbx.runCommand({ ...params, detached: true });
52
+ let stdout = "";
53
+ let stderr = "";
54
+ for await (const line of running.logs({ signal })) {
55
+ if (line.stream === "stdout") {
56
+ stdout += line.data;
57
+ opts.onStdout?.(line.data);
58
+ }
59
+ else {
60
+ stderr += line.data;
61
+ opts.onStderr?.(line.data);
62
+ }
63
+ }
64
+ const done = await running.wait({ signal });
65
+ return { exitCode: done.exitCode, stdout, stderr, durationMs: Date.now() - t0 };
66
+ }
67
+ const r = await this.sbx.runCommand(params);
68
+ const [stdout, stderr] = await Promise.all([r.stdout(), r.stderr()]);
69
+ return { exitCode: r.exitCode, stdout, stderr, durationMs: Date.now() - t0 };
70
+ }
71
+ catch (err) {
72
+ throw new ProviderError("vercel", err?.message ?? String(err));
73
+ }
74
+ }
75
+ async writeFile(path, content) {
76
+ await this.sbx.writeFiles([{ path, content: Buffer.from(toBytes(content)) }]);
77
+ }
78
+ async readFile(path) {
79
+ const buf = await this.sbx.readFileToBuffer({ path });
80
+ if (!buf)
81
+ throw new ProviderError("vercel", `no such file: ${path}`);
82
+ return new Uint8Array(buf);
83
+ }
84
+ async kill() {
85
+ await this.sbx.stop();
86
+ }
87
+ }
@@ -0,0 +1,65 @@
1
+ import { RoutedComputer, type ComputerProvider, type ComputerSpec } from "./computers.js";
2
+ import { type Attempt, type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type RouteOptions, type SandboxSpec } from "./types.js";
3
+ export interface Candidate {
4
+ provider: ProviderId;
5
+ estimatedHourlyUsd: number;
6
+ medianCreateMs?: number;
7
+ /** Why this provider won't be tried, if it won't. */
8
+ skip?: string;
9
+ }
10
+ export interface RouterOptions {
11
+ /** Replace the built-in adapters (tests, custom providers). */
12
+ providers?: Provider[];
13
+ /** Path of the bench results file used by strategy "fastest". */
14
+ benchFile?: string;
15
+ }
16
+ export declare function defaultProviders(): Provider[];
17
+ export declare class Router {
18
+ private providers;
19
+ private benchFile?;
20
+ constructor(opts?: RouterOptions);
21
+ /** Registered and has credentials. False for providers this router doesn't know. */
22
+ isConfigured(id: ProviderId): boolean;
23
+ /** Catalog ids this router has an adapter for, in catalog order. */
24
+ registered(): ProviderId[];
25
+ provider(id: ProviderId): Provider;
26
+ /** The ordered list of providers a create would try, with the reason any are skipped. */
27
+ plan(spec?: SandboxSpec, route?: RouteOptions): Candidate[];
28
+ /** Start a sandbox on the best available provider, falling back down the list on failure. */
29
+ create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
30
+ /** Providers that also sell computers (desktops/phones). */
31
+ computerProviders(): ComputerProvider[];
32
+ /** Get or create a desktop/phone. With `externalUserId`, the same id returns the same computer. */
33
+ computer(spec?: ComputerSpec, opts?: {
34
+ provider?: ProviderId | ProviderId[];
35
+ }): Promise<RoutedComputer>;
36
+ getComputer(id: string): Promise<RoutedComputer>;
37
+ /** Reattach to a sandbox by its routed id (`provider:nativeId`). */
38
+ get(id: string): Promise<RoutedSandbox>;
39
+ }
40
+ export declare class RoutedSandbox {
41
+ readonly provider: ProviderId;
42
+ private box;
43
+ /** Every create attempt, including failed fallbacks. */
44
+ readonly attempts: Attempt[];
45
+ readonly estimatedHourlyUsd: number;
46
+ /** Stable id across processes: `provider:nativeId`. */
47
+ readonly id: string;
48
+ constructor(provider: ProviderId, box: ProviderSandbox,
49
+ /** Every create attempt, including failed fallbacks. */
50
+ attempts: Attempt[], estimatedHourlyUsd: number);
51
+ get nativeId(): string;
52
+ exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
53
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
54
+ readFile(path: string): Promise<Uint8Array>;
55
+ readText(path: string): Promise<string>;
56
+ kill(): Promise<void>;
57
+ /** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
58
+ pause(): Promise<void>;
59
+ get canPause(): boolean;
60
+ [Symbol.asyncDispose](): Promise<void>;
61
+ }
62
+ export declare function parseId(id: string): {
63
+ provider: ProviderId;
64
+ nativeId: string;
65
+ };
package/dist/router.js ADDED
@@ -0,0 +1,180 @@
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 { RouterError, } from "./types.js";
15
+ export function defaultProviders() {
16
+ return [
17
+ new MaritimeProvider(),
18
+ new E2BProvider(),
19
+ new DaytonaProvider(),
20
+ new ModalProvider(),
21
+ new VercelProvider(),
22
+ new MorphProvider(),
23
+ new SpritesProvider(),
24
+ new BlaxelProvider(),
25
+ new TensorlakeProvider(),
26
+ new RunloopProvider(),
27
+ ];
28
+ }
29
+ export class Router {
30
+ providers;
31
+ benchFile;
32
+ constructor(opts = {}) {
33
+ this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
34
+ this.benchFile = opts.benchFile;
35
+ }
36
+ /** Registered and has credentials. False for providers this router doesn't know. */
37
+ isConfigured(id) {
38
+ return this.providers.get(id)?.configured() ?? false;
39
+ }
40
+ /** Catalog ids this router has an adapter for, in catalog order. */
41
+ registered() {
42
+ return CATALOG.map((c) => c.id).filter((id) => this.providers.has(id));
43
+ }
44
+ provider(id) {
45
+ const p = this.providers.get(id);
46
+ if (!p)
47
+ throw new RouterError(`unknown provider: ${id}`);
48
+ return p;
49
+ }
50
+ /** The ordered list of providers a create would try, with the reason any are skipped. */
51
+ plan(spec = {}, route = {}) {
52
+ const explicit = route.provider ? [].concat(route.provider) : null;
53
+ const ids = explicit ?? CATALOG.map((c) => c.id).filter((id) => this.providers.has(id));
54
+ const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
55
+ const list = ids.map((id) => {
56
+ const p = this.providers.get(id);
57
+ const c = {
58
+ provider: id,
59
+ estimatedHourlyUsd: round(hourlyCost(id, spec)),
60
+ medianCreateMs: median(bench[id]?.createMs),
61
+ };
62
+ if (!p)
63
+ c.skip = "unknown provider";
64
+ else
65
+ c.skip = unfit(id, spec, route.require) ?? (p.configured() ? undefined : "not configured");
66
+ return c;
67
+ });
68
+ if (!explicit && route.strategy === "cheapest") {
69
+ list.sort((a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd);
70
+ }
71
+ else if (!explicit && route.strategy === "fastest") {
72
+ list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity));
73
+ }
74
+ return list;
75
+ }
76
+ /** Start a sandbox on the best available provider, falling back down the list on failure. */
77
+ async create(spec = {}, route = {}) {
78
+ const plan = this.plan(spec, route);
79
+ const usable = plan.filter((c) => !c.skip);
80
+ if (usable.length === 0) {
81
+ const why = plan.map((c) => `${c.provider} (${c.skip})`).join(", ");
82
+ throw new RouterError(`no provider can take this sandbox: ${why || "none registered"}`);
83
+ }
84
+ const attempts = [];
85
+ const tries = route.fallback === false ? usable.slice(0, 1) : usable;
86
+ for (const c of tries) {
87
+ const t0 = Date.now();
88
+ try {
89
+ const box = await this.provider(c.provider).create(spec);
90
+ attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
91
+ return new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd);
92
+ }
93
+ catch (err) {
94
+ attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
95
+ }
96
+ }
97
+ throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
98
+ }
99
+ /** Providers that also sell computers (desktops/phones). */
100
+ computerProviders() {
101
+ return [...this.providers.values()].filter((p) => "createComputer" in p);
102
+ }
103
+ /** Get or create a desktop/phone. With `externalUserId`, the same id returns the same computer. */
104
+ computer(spec = {}, opts = {}) {
105
+ const prefer = opts.provider ? [].concat(opts.provider) : undefined;
106
+ return createComputer(this.computerProviders(), spec, prefer);
107
+ }
108
+ async getComputer(id) {
109
+ const { provider, nativeId } = parseId(id);
110
+ const p = this.computerProviders().find((c) => c.id === provider);
111
+ if (!p)
112
+ throw new RouterError(`${provider} does not offer computers`);
113
+ return new RoutedComputer(provider, await p.connectComputer(nativeId));
114
+ }
115
+ /** Reattach to a sandbox by its routed id (`provider:nativeId`). */
116
+ async get(id) {
117
+ const { provider, nativeId } = parseId(id);
118
+ const box = await this.provider(provider).connect(nativeId);
119
+ return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)));
120
+ }
121
+ }
122
+ export class RoutedSandbox {
123
+ provider;
124
+ box;
125
+ attempts;
126
+ estimatedHourlyUsd;
127
+ /** Stable id across processes: `provider:nativeId`. */
128
+ id;
129
+ constructor(provider, box,
130
+ /** Every create attempt, including failed fallbacks. */
131
+ attempts, estimatedHourlyUsd) {
132
+ this.provider = provider;
133
+ this.box = box;
134
+ this.attempts = attempts;
135
+ this.estimatedHourlyUsd = estimatedHourlyUsd;
136
+ this.id = `${provider}:${box.nativeId}`;
137
+ }
138
+ get nativeId() {
139
+ return this.box.nativeId;
140
+ }
141
+ exec(cmd, opts) {
142
+ return this.box.exec(cmd, opts);
143
+ }
144
+ writeFile(path, content) {
145
+ return this.box.writeFile(path, content);
146
+ }
147
+ readFile(path) {
148
+ return this.box.readFile(path);
149
+ }
150
+ async readText(path) {
151
+ return new TextDecoder().decode(await this.box.readFile(path));
152
+ }
153
+ kill() {
154
+ return this.box.kill();
155
+ }
156
+ /** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
157
+ async pause() {
158
+ if (!this.box.pause)
159
+ throw new RouterError(`${this.provider} does not support pause; create with require: ["pause"]`);
160
+ await this.box.pause();
161
+ }
162
+ get canPause() {
163
+ return typeof this.box.pause === "function";
164
+ }
165
+ async [Symbol.asyncDispose]() {
166
+ await this.kill();
167
+ }
168
+ }
169
+ export function parseId(id) {
170
+ const i = id.indexOf(":");
171
+ if (i <= 0)
172
+ throw new RouterError(`bad sandbox id "${id}": expected provider:id`);
173
+ const provider = id.slice(0, i);
174
+ if (!CATALOG.some((c) => c.id === provider))
175
+ throw new RouterError(`unknown provider in id: ${provider}`);
176
+ return { provider, nativeId: id.slice(i + 1) };
177
+ }
178
+ function round(n) {
179
+ return Math.round(n * 10000) / 10000;
180
+ }
@@ -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>;