@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { RouterError } from "./types.js";
|
|
2
|
+
export class RoutedComputer {
|
|
3
|
+
provider;
|
|
4
|
+
c;
|
|
5
|
+
id;
|
|
6
|
+
constructor(provider, c) {
|
|
7
|
+
this.provider = provider;
|
|
8
|
+
this.c = c;
|
|
9
|
+
this.id = `${provider}:${c.nativeId}`;
|
|
10
|
+
}
|
|
11
|
+
get nativeId() {
|
|
12
|
+
return this.c.nativeId;
|
|
13
|
+
}
|
|
14
|
+
info() {
|
|
15
|
+
return this.c.info();
|
|
16
|
+
}
|
|
17
|
+
act(action) {
|
|
18
|
+
return this.c.act(action);
|
|
19
|
+
}
|
|
20
|
+
actBatch(actions) {
|
|
21
|
+
if (actions.length > 50)
|
|
22
|
+
throw new RouterError("at most 50 actions per batch");
|
|
23
|
+
return this.c.actBatch(actions);
|
|
24
|
+
}
|
|
25
|
+
screenshot(opts) {
|
|
26
|
+
return this.c.screenshot(opts);
|
|
27
|
+
}
|
|
28
|
+
exec(command, timeoutSeconds) {
|
|
29
|
+
return this.c.exec(command, timeoutSeconds);
|
|
30
|
+
}
|
|
31
|
+
readFile(path) {
|
|
32
|
+
return this.c.readFile(path);
|
|
33
|
+
}
|
|
34
|
+
writeFile(path, content) {
|
|
35
|
+
return this.c.writeFile(path, content);
|
|
36
|
+
}
|
|
37
|
+
viewer(mode, opts) {
|
|
38
|
+
return this.c.viewer(mode, opts);
|
|
39
|
+
}
|
|
40
|
+
sleep() {
|
|
41
|
+
return this.c.sleep();
|
|
42
|
+
}
|
|
43
|
+
delete() {
|
|
44
|
+
return this.c.delete();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Pick a computer provider that offers this OS and has credentials, in the given order. */
|
|
48
|
+
export async function createComputer(providers, spec = {}, prefer) {
|
|
49
|
+
const os = spec.os ?? "linux";
|
|
50
|
+
const ordered = prefer ? prefer.map((id) => providers.find((p) => p.id === id)).filter((p) => !!p) : providers;
|
|
51
|
+
const usable = ordered.filter((p) => p.computerOs.includes(os) && p.configured());
|
|
52
|
+
if (usable.length === 0) {
|
|
53
|
+
const offering = providers.filter((p) => p.computerOs.includes(os)).map((p) => p.id);
|
|
54
|
+
throw new RouterError(offering.length ? `no configured provider for a ${os} computer (offered by: ${offering.join(", ")})` : `no provider offers a ${os} computer`);
|
|
55
|
+
}
|
|
56
|
+
const errors = [];
|
|
57
|
+
for (const p of usable) {
|
|
58
|
+
try {
|
|
59
|
+
return new RoutedComputer(p.id, await p.createComputer({ ...spec, os }));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
errors.push(`${p.id}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
throw new RouterError(`all computer providers failed: ${errors.join(" | ")}`);
|
|
66
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { Router, RoutedSandbox, defaultProviders, parseId, type Candidate, type RouterOptions } from "./router.js";
|
|
2
|
+
export { CATALOG, hourlyCost, unfit, info, type ProviderInfo } from "./catalog.js";
|
|
3
|
+
export { createApp } from "./server.js";
|
|
4
|
+
export { RoutedComputer, createComputer, type ActionResult, type ComputerAction, type ComputerActionName, type ComputerInfo, type ComputerOs, type ComputerProvider, type ComputerSpec, type ProviderComputer, } from "./computers.js";
|
|
5
|
+
export { bench, benchMarkdown, type BenchRow } from "./bench.js";
|
|
6
|
+
export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
|
|
7
|
+
export { MaritimeProvider } from "./providers/maritime.js";
|
|
8
|
+
export { E2BProvider } from "./providers/e2b.js";
|
|
9
|
+
export { DaytonaProvider } from "./providers/daytona.js";
|
|
10
|
+
export { ModalProvider } from "./providers/modal.js";
|
|
11
|
+
export { VercelProvider } from "./providers/vercel.js";
|
|
12
|
+
export { MorphProvider } from "./providers/morph.js";
|
|
13
|
+
export { SpritesProvider } from "./providers/sprites.js";
|
|
14
|
+
export { BlaxelProvider } from "./providers/blaxel.js";
|
|
15
|
+
export { TensorlakeProvider } from "./providers/tensorlake.js";
|
|
16
|
+
export { RunloopProvider } from "./providers/runloop.js";
|
|
17
|
+
export { DockerProvider } from "./providers/docker.js";
|
|
18
|
+
export { HopxProvider } from "./providers/hopx.js";
|
|
19
|
+
export { CloudflareProvider } from "./providers/cloudflare.js";
|
|
20
|
+
export { NorthflankProvider } from "./providers/northflank.js";
|
|
21
|
+
export { BoatProvider } from "./providers/boat.js";
|
|
22
|
+
export { ExeDevProvider } from "./providers/exedev.js";
|
|
23
|
+
export * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { Router, RoutedSandbox, defaultProviders, parseId } from "./router.js";
|
|
2
|
+
export { CATALOG, hourlyCost, unfit, info } from "./catalog.js";
|
|
3
|
+
export { createApp } from "./server.js";
|
|
4
|
+
export { RoutedComputer, createComputer, } from "./computers.js";
|
|
5
|
+
export { bench, benchMarkdown } from "./bench.js";
|
|
6
|
+
export { aggregate, loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
7
|
+
export { MaritimeProvider } from "./providers/maritime.js";
|
|
8
|
+
export { E2BProvider } from "./providers/e2b.js";
|
|
9
|
+
export { DaytonaProvider } from "./providers/daytona.js";
|
|
10
|
+
export { ModalProvider } from "./providers/modal.js";
|
|
11
|
+
export { VercelProvider } from "./providers/vercel.js";
|
|
12
|
+
export { MorphProvider } from "./providers/morph.js";
|
|
13
|
+
export { SpritesProvider } from "./providers/sprites.js";
|
|
14
|
+
export { BlaxelProvider } from "./providers/blaxel.js";
|
|
15
|
+
export { TensorlakeProvider } from "./providers/tensorlake.js";
|
|
16
|
+
export { RunloopProvider } from "./providers/runloop.js";
|
|
17
|
+
export { DockerProvider } from "./providers/docker.js";
|
|
18
|
+
export { HopxProvider } from "./providers/hopx.js";
|
|
19
|
+
export { CloudflareProvider } from "./providers/cloudflare.js";
|
|
20
|
+
export { NorthflankProvider } from "./providers/northflank.js";
|
|
21
|
+
export { BoatProvider } from "./providers/boat.js";
|
|
22
|
+
export { ExeDevProvider } from "./providers/exedev.js";
|
|
23
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { BenchRow } from "./bench.js";
|
|
2
|
+
import type { ProviderId, SandboxSpec } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* A shareable bench result. People submit these as PRs to results/, and `sandboxrouter
|
|
5
|
+
* leaderboard` renders every submission into one static page. No server, nothing to trust but
|
|
6
|
+
* the files in the repo.
|
|
7
|
+
*/
|
|
8
|
+
export interface BenchReport {
|
|
9
|
+
tool: "sandboxrouter";
|
|
10
|
+
version: string;
|
|
11
|
+
date: string;
|
|
12
|
+
/** Free text from the submitter, e.g. "us-east-1 c7g.large" or "Berlin, home fiber". */
|
|
13
|
+
label?: string;
|
|
14
|
+
runs: number;
|
|
15
|
+
spec: SandboxSpec;
|
|
16
|
+
node: string;
|
|
17
|
+
platform: string;
|
|
18
|
+
rows: BenchRow[];
|
|
19
|
+
}
|
|
20
|
+
export declare function makeReport(rows: BenchRow[], opts: {
|
|
21
|
+
version: string;
|
|
22
|
+
runs: number;
|
|
23
|
+
spec: SandboxSpec;
|
|
24
|
+
label?: string;
|
|
25
|
+
}): BenchReport;
|
|
26
|
+
export declare function loadReports(dir: string): BenchReport[];
|
|
27
|
+
export interface LeaderRow {
|
|
28
|
+
provider: ProviderId;
|
|
29
|
+
name: string;
|
|
30
|
+
submissions: number;
|
|
31
|
+
medianCreateMs?: number;
|
|
32
|
+
medianExecMs?: number;
|
|
33
|
+
successRate: number;
|
|
34
|
+
hourlyUsd: number;
|
|
35
|
+
}
|
|
36
|
+
/** Median of each submission's medians, so one heavy submitter can't skew a provider. */
|
|
37
|
+
export declare function aggregate(reports: BenchReport[]): LeaderRow[];
|
|
38
|
+
/** One self-contained HTML page: a sorted table with an inline bar per measure, each on its own scale. */
|
|
39
|
+
export declare function renderLeaderboard(reports: BenchReport[]): string;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CATALOG, hourlyCost } from "./catalog.js";
|
|
4
|
+
import { median } from "./bench-store.js";
|
|
5
|
+
export function makeReport(rows, opts) {
|
|
6
|
+
return {
|
|
7
|
+
tool: "sandboxrouter",
|
|
8
|
+
version: opts.version,
|
|
9
|
+
date: new Date().toISOString(),
|
|
10
|
+
label: opts.label,
|
|
11
|
+
runs: opts.runs,
|
|
12
|
+
// Only the shape matters for comparison; never ship env or metadata.
|
|
13
|
+
spec: { vcpu: opts.spec.vcpu, memoryGb: opts.spec.memoryGb },
|
|
14
|
+
node: process.version,
|
|
15
|
+
platform: `${process.platform}-${process.arch}`,
|
|
16
|
+
rows: rows.map(({ lastError: _drop, ...r }) => r),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function loadReports(dir) {
|
|
20
|
+
let files;
|
|
21
|
+
try {
|
|
22
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const f of files.sort()) {
|
|
29
|
+
try {
|
|
30
|
+
const r = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
31
|
+
if (r?.tool === "sandboxrouter" && Array.isArray(r.rows))
|
|
32
|
+
out.push(r);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* skip unreadable files */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const KNOWN = new Set(CATALOG.map((c) => c.id));
|
|
41
|
+
/** Median of each submission's medians, so one heavy submitter can't skew a provider. */
|
|
42
|
+
export function aggregate(reports) {
|
|
43
|
+
const by = new Map();
|
|
44
|
+
for (const rep of reports)
|
|
45
|
+
for (const r of rep.rows)
|
|
46
|
+
if (KNOWN.has(r.provider))
|
|
47
|
+
by.set(r.provider, [...(by.get(r.provider) ?? []), r]);
|
|
48
|
+
const rows = [...by.entries()].map(([id, rs]) => {
|
|
49
|
+
const runs = rs.reduce((n, r) => n + r.runs, 0);
|
|
50
|
+
const ok = rs.reduce((n, r) => n + (r.runs - r.failures), 0);
|
|
51
|
+
return {
|
|
52
|
+
provider: id,
|
|
53
|
+
name: CATALOG.find((c) => c.id === id).name,
|
|
54
|
+
submissions: rs.length,
|
|
55
|
+
medianCreateMs: median(rs.map((r) => r.medianCreateMs).filter((n) => typeof n === "number")),
|
|
56
|
+
medianExecMs: median(rs.map((r) => r.medianExecMs).filter((n) => typeof n === "number")),
|
|
57
|
+
successRate: runs ? ok / runs : 0,
|
|
58
|
+
hourlyUsd: hourlyCost(id),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return rows.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity));
|
|
62
|
+
}
|
|
63
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
64
|
+
function bar(value, max, text, tip) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return `<td class="num muted">n/a</td>`;
|
|
67
|
+
const pct = max > 0 ? Math.max(1.5, (value / max) * 100) : 0;
|
|
68
|
+
return `<td class="barcell" tabindex="0" data-tip="${esc(tip)}" aria-label="${esc(tip)}">
|
|
69
|
+
<div class="track"><div class="bar" style="width:${pct.toFixed(1)}%"></div></div><span class="val">${esc(text)}</span></td>`;
|
|
70
|
+
}
|
|
71
|
+
/** One self-contained HTML page: a sorted table with an inline bar per measure, each on its own scale. */
|
|
72
|
+
export function renderLeaderboard(reports) {
|
|
73
|
+
const rows = aggregate(reports);
|
|
74
|
+
const maxCreate = Math.max(0, ...rows.map((r) => r.medianCreateMs ?? 0));
|
|
75
|
+
const maxPrice = Math.max(0, ...rows.map((r) => r.hourlyUsd));
|
|
76
|
+
const dates = reports.map((r) => r.date).sort();
|
|
77
|
+
const span = dates.length ? `${dates[0].slice(0, 10)} to ${dates.at(-1).slice(0, 10)}` : "no submissions yet";
|
|
78
|
+
const body = rows
|
|
79
|
+
.map((r, i) => `<tr>
|
|
80
|
+
<td class="num rank">${i + 1}</td>
|
|
81
|
+
<th scope="row">${esc(r.name)}</th>
|
|
82
|
+
${bar(r.medianCreateMs, maxCreate, r.medianCreateMs === undefined ? "" : `${r.medianCreateMs.toLocaleString("en-US")} ms`, `${r.name}: median cold start ${r.medianCreateMs} ms across ${r.submissions} submission${r.submissions === 1 ? "" : "s"}`)}
|
|
83
|
+
<td class="num">${r.medianExecMs === undefined ? "n/a" : `${r.medianExecMs} ms`}</td>
|
|
84
|
+
${bar(r.hourlyUsd, maxPrice, `$${r.hourlyUsd.toFixed(3)}`, `${r.name}: $${r.hourlyUsd.toFixed(4)} per hour at list price, 2 vCPU / 4 GB`)}
|
|
85
|
+
<td class="num">${(r.successRate * 100).toFixed(0)}%</td>
|
|
86
|
+
<td class="num muted">${r.submissions}</td>
|
|
87
|
+
</tr>`)
|
|
88
|
+
.join("\n");
|
|
89
|
+
const subs = reports
|
|
90
|
+
.map((r) => `<li>${esc(r.date.slice(0, 10))} · ${esc(r.label ?? "unlabeled")} · ${esc(r.platform)} · ${r.runs} runs · ${r.rows.length} providers</li>`)
|
|
91
|
+
.join("\n");
|
|
92
|
+
return `<!DOCTYPE html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8">
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
97
|
+
<title>sandboxrouter leaderboard</title>
|
|
98
|
+
<style>
|
|
99
|
+
.viz-root {
|
|
100
|
+
color-scheme: light;
|
|
101
|
+
--page: #f9f9f7; --surface-1: #fcfcfb; --text-primary: #0b0b0b; --text-secondary: #52514e;
|
|
102
|
+
--text-muted: #898781; --grid: #e1e0d9; --baseline: #c3c2b7; --border: rgba(11,11,11,0.10);
|
|
103
|
+
--series-1: #2a78d6;
|
|
104
|
+
}
|
|
105
|
+
@media (prefers-color-scheme: dark) {
|
|
106
|
+
:root:where(:not([data-theme="light"])) .viz-root {
|
|
107
|
+
color-scheme: dark;
|
|
108
|
+
--page: #0d0d0d; --surface-1: #1a1a19; --text-primary: #f0efec; --text-secondary: #c3c2b7;
|
|
109
|
+
--text-muted: #898781; --grid: #2c2c2a; --baseline: #383835; --border: rgba(255,255,255,0.10);
|
|
110
|
+
--series-1: #3987e5;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
:root[data-theme="dark"] .viz-root {
|
|
114
|
+
color-scheme: dark;
|
|
115
|
+
--page: #0d0d0d; --surface-1: #1a1a19; --text-primary: #f0efec; --text-secondary: #c3c2b7;
|
|
116
|
+
--text-muted: #898781; --grid: #2c2c2a; --baseline: #383835; --border: rgba(255,255,255,0.10);
|
|
117
|
+
--series-1: #3987e5;
|
|
118
|
+
}
|
|
119
|
+
* { box-sizing: border-box; }
|
|
120
|
+
body { margin: 0; }
|
|
121
|
+
.viz-root { background: var(--page); color: var(--text-primary); min-height: 100vh;
|
|
122
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 48px 24px; }
|
|
123
|
+
main { max-width: 1040px; margin: 0 auto; }
|
|
124
|
+
h1 { font-size: 28px; margin: 0 0 4px; letter-spacing: -0.01em; }
|
|
125
|
+
.sub { color: var(--text-secondary); margin: 0 0 24px; }
|
|
126
|
+
.card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 12px; padding: 8px 16px; overflow-x: auto; }
|
|
127
|
+
table { width: 100%; border-collapse: collapse; }
|
|
128
|
+
caption { text-align: left; color: var(--text-muted); font-size: 13px; padding: 12px 0 4px; caption-side: bottom; }
|
|
129
|
+
th, td { padding: 10px 8px; border-bottom: 1px solid var(--grid); text-align: left; white-space: nowrap; }
|
|
130
|
+
thead th { color: var(--text-muted); font-weight: 500; font-size: 13px; border-bottom-color: var(--baseline); }
|
|
131
|
+
tbody tr:last-child > * { border-bottom: 0; }
|
|
132
|
+
tbody th { font-weight: 600; }
|
|
133
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
134
|
+
.rank, .muted { color: var(--text-muted); }
|
|
135
|
+
.barcell { min-width: 220px; position: relative; outline: none; }
|
|
136
|
+
.barcell { display: table-cell; }
|
|
137
|
+
.track { display: inline-block; vertical-align: middle; width: 140px; height: 12px; border-left: 1px solid var(--baseline); }
|
|
138
|
+
.bar { height: 12px; background: var(--series-1); border-radius: 0 4px 4px 0; }
|
|
139
|
+
.val { display: inline-block; vertical-align: middle; margin-left: 8px; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
|
140
|
+
.barcell:hover .bar, .barcell:focus .bar { filter: brightness(1.12); }
|
|
141
|
+
#tip { position: fixed; pointer-events: none; background: var(--surface-1); color: var(--text-primary);
|
|
142
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 13px;
|
|
143
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.12); opacity: 0; transition: opacity 80ms; max-width: 320px; white-space: normal; }
|
|
144
|
+
.notes { color: var(--text-secondary); font-size: 14px; margin-top: 24px; }
|
|
145
|
+
.notes ul { padding-left: 18px; color: var(--text-muted); }
|
|
146
|
+
a { color: inherit; }
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body class="viz-root">
|
|
150
|
+
<main>
|
|
151
|
+
<h1>Sandbox cold starts</h1>
|
|
152
|
+
<p class="sub">Time from <code>create()</code> to a usable sandbox, measured by <a href="https://github.com/gigialc/sandboxrouter">sandboxrouter bench</a>. ${reports.length} submission${reports.length === 1 ? "" : "s"}, ${esc(span)}.</p>
|
|
153
|
+
<div class="card">
|
|
154
|
+
<table>
|
|
155
|
+
<thead><tr>
|
|
156
|
+
<th>#</th><th>Provider</th><th>Cold start (median)</th><th>Exec round trip</th>
|
|
157
|
+
<th>List price, 2 vCPU / 4 GB</th><th>Success</th><th>Submissions</th>
|
|
158
|
+
</tr></thead>
|
|
159
|
+
<tbody>
|
|
160
|
+
${body || `<tr><td colspan="7" class="muted">No results yet. Run <code>npx @sandboxrouter/sdk bench --share</code> and open a PR.</td></tr>`}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="notes">
|
|
165
|
+
<p>Sorted by cold start. Each bar column has its own scale. Cold start and exec are the median of each submission's medians, so one submitter can't dominate a provider. Latency includes the network between the submitter and each provider. Prices are list prices from each provider's pricing page (sources in <code>src/catalog.ts</code>); some bill active CPU only, so idle work costs less.</p>
|
|
166
|
+
<p>Submissions:</p>
|
|
167
|
+
<ul>${subs}</ul>
|
|
168
|
+
</div>
|
|
169
|
+
</main>
|
|
170
|
+
<div id="tip" role="tooltip"></div>
|
|
171
|
+
<script>
|
|
172
|
+
const tip = document.getElementById("tip");
|
|
173
|
+
const show = (el, x, y) => { tip.textContent = el.dataset.tip; tip.style.left = (x + 14) + "px"; tip.style.top = (y + 14) + "px"; tip.style.opacity = 1; };
|
|
174
|
+
for (const el of document.querySelectorAll("[data-tip]")) {
|
|
175
|
+
el.addEventListener("mousemove", (e) => show(el, e.clientX, e.clientY));
|
|
176
|
+
el.addEventListener("mouseleave", () => (tip.style.opacity = 0));
|
|
177
|
+
el.addEventListener("focus", () => { const r = el.getBoundingClientRect(); show(el, r.left, r.bottom - 8); });
|
|
178
|
+
el.addEventListener("blur", () => (tip.style.opacity = 0));
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Blaxel sandboxes via `@blaxel/core`. Auth: BL_API_KEY + BL_WORKSPACE (or `bl login`).
|
|
4
|
+
* Region: BL_REGION, else us-pdx-1. Blaxel sizes by memory only and gives 1 vCPU per 2 GB,
|
|
5
|
+
* so memory is max(memoryGb, 2 × vcpu) GB. Idle sandboxes drop to standby on their own and
|
|
6
|
+
* wake on the next call; pause() archives explicitly.
|
|
7
|
+
*/
|
|
8
|
+
export declare class BlaxelProvider implements Provider {
|
|
9
|
+
private apiKey;
|
|
10
|
+
private workspace;
|
|
11
|
+
private region;
|
|
12
|
+
readonly id: "blaxel";
|
|
13
|
+
private ready?;
|
|
14
|
+
constructor(apiKey?: string | undefined, workspace?: string | undefined, region?: string);
|
|
15
|
+
configured(): boolean;
|
|
16
|
+
/** Load the SDK once and point its (global) settings at our credentials. */
|
|
17
|
+
private sdk;
|
|
18
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
19
|
+
/** Reattach by sandbox name; an archived (paused) sandbox is unarchived first. */
|
|
20
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
2
|
+
import { ProviderError } from "../types.js";
|
|
3
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
4
|
+
/** Blaxel's public base image (the SDK's own default). Set image.blaxel for another image or a `bl deploy` template. */
|
|
5
|
+
const DEFAULT_IMAGE = "blaxel/base-image:latest";
|
|
6
|
+
/** Blaxel rejects a max-age ttl under 5 minutes. */
|
|
7
|
+
const MIN_TTL_MINUTES = 5;
|
|
8
|
+
/**
|
|
9
|
+
* Blaxel sandboxes via `@blaxel/core`. Auth: BL_API_KEY + BL_WORKSPACE (or `bl login`).
|
|
10
|
+
* Region: BL_REGION, else us-pdx-1. Blaxel sizes by memory only and gives 1 vCPU per 2 GB,
|
|
11
|
+
* so memory is max(memoryGb, 2 × vcpu) GB. Idle sandboxes drop to standby on their own and
|
|
12
|
+
* wake on the next call; pause() archives explicitly.
|
|
13
|
+
*/
|
|
14
|
+
export class BlaxelProvider {
|
|
15
|
+
apiKey;
|
|
16
|
+
workspace;
|
|
17
|
+
region;
|
|
18
|
+
id = "blaxel";
|
|
19
|
+
ready;
|
|
20
|
+
constructor(apiKey = process.env.BL_API_KEY, workspace = process.env.BL_WORKSPACE, region = process.env.BL_REGION ?? "us-pdx-1") {
|
|
21
|
+
this.apiKey = apiKey;
|
|
22
|
+
this.workspace = workspace;
|
|
23
|
+
this.region = region;
|
|
24
|
+
}
|
|
25
|
+
configured() {
|
|
26
|
+
return !!this.apiKey && !!this.workspace;
|
|
27
|
+
}
|
|
28
|
+
/** Load the SDK once and point its (global) settings at our credentials. */
|
|
29
|
+
sdk() {
|
|
30
|
+
this.ready ??= optionalImport("@blaxel/core", "blaxel").then((m) => {
|
|
31
|
+
if (this.apiKey && this.workspace)
|
|
32
|
+
m.initialize({ apiKey: this.apiKey, workspace: this.workspace });
|
|
33
|
+
return m;
|
|
34
|
+
});
|
|
35
|
+
return this.ready;
|
|
36
|
+
}
|
|
37
|
+
async create(spec) {
|
|
38
|
+
const { SandboxInstance } = await this.sdk();
|
|
39
|
+
const memoryGb = Math.max(spec.memoryGb ?? DEFAULT_MEMORY_GB, (spec.vcpu ?? DEFAULT_VCPU) * 2);
|
|
40
|
+
const ttlMinutes = Math.max(MIN_TTL_MINUTES, Math.round(spec.timeoutMinutes ?? 30));
|
|
41
|
+
try {
|
|
42
|
+
const sbx = await SandboxInstance.create({
|
|
43
|
+
image: spec.image?.blaxel ?? DEFAULT_IMAGE,
|
|
44
|
+
memory: Math.round(memoryGb * 1024),
|
|
45
|
+
region: this.region,
|
|
46
|
+
envs: Object.entries(spec.env ?? {}).map(([name, value]) => ({ name, value })),
|
|
47
|
+
labels: spec.metadata,
|
|
48
|
+
// Max-age from creation, not an idle timeout.
|
|
49
|
+
ttl: `${ttlMinutes}m`,
|
|
50
|
+
});
|
|
51
|
+
return new BlaxelBox(sbx);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw wrap(err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Reattach by sandbox name; an archived (paused) sandbox is unarchived first. */
|
|
58
|
+
async connect(nativeId) {
|
|
59
|
+
const { SandboxInstance } = await this.sdk();
|
|
60
|
+
try {
|
|
61
|
+
let sbx = await SandboxInstance.get(nativeId);
|
|
62
|
+
if (sbx.status === "ARCHIVED" || sbx.status === "ARCHIVING")
|
|
63
|
+
sbx = await SandboxInstance.unarchive(nativeId);
|
|
64
|
+
return new BlaxelBox(sbx);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw wrap(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
class BlaxelBox {
|
|
72
|
+
sbx;
|
|
73
|
+
nativeId;
|
|
74
|
+
constructor(sbx) {
|
|
75
|
+
this.sbx = sbx;
|
|
76
|
+
const name = sbx.metadata.name;
|
|
77
|
+
if (!name)
|
|
78
|
+
throw new ProviderError("blaxel", "sandbox has no name");
|
|
79
|
+
this.nativeId = name;
|
|
80
|
+
}
|
|
81
|
+
async exec(cmd, opts = {}) {
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
const timeoutSeconds = opts.timeoutSeconds ?? 60;
|
|
84
|
+
let r;
|
|
85
|
+
try {
|
|
86
|
+
// With onStdout/onStderr the SDK streams logs from the same request as the command runs.
|
|
87
|
+
r = await this.sbx.process.exec({
|
|
88
|
+
command: cmd,
|
|
89
|
+
workingDir: opts.cwd,
|
|
90
|
+
env: opts.env,
|
|
91
|
+
timeout: timeoutSeconds,
|
|
92
|
+
waitForCompletion: true,
|
|
93
|
+
onStdout: opts.onStdout,
|
|
94
|
+
onStderr: opts.onStderr,
|
|
95
|
+
});
|
|
96
|
+
if (r.status === "running")
|
|
97
|
+
r = await this.sbx.process.wait(r.pid, { maxWait: timeoutSeconds * 1000 });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw wrap(err);
|
|
101
|
+
}
|
|
102
|
+
if (r.status === "killed" || r.status === "stopped") {
|
|
103
|
+
throw new ProviderError("blaxel", `command ${r.status} (timeout ${timeoutSeconds}s?)`);
|
|
104
|
+
}
|
|
105
|
+
// A nonzero exit is a result (status "failed"), not an error.
|
|
106
|
+
return { exitCode: r.exitCode, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
|
|
107
|
+
}
|
|
108
|
+
async writeFile(path, content) {
|
|
109
|
+
try {
|
|
110
|
+
await this.sbx.fs.writeBinary(path, toBytes(content));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw wrap(err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async readFile(path) {
|
|
117
|
+
try {
|
|
118
|
+
return new Uint8Array(await (await this.sbx.fs.readBinary(path)).arrayBuffer());
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
throw wrap(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Archive: the filesystem is kept, memory and processes are lost. The max-age ttl is cleared
|
|
126
|
+
* first so the archive survives until kill(). router.get() unarchives it.
|
|
127
|
+
*/
|
|
128
|
+
async pause() {
|
|
129
|
+
const { SandboxInstance } = await optionalImport("@blaxel/core", "blaxel");
|
|
130
|
+
try {
|
|
131
|
+
await SandboxInstance.updateTtl(this.nativeId, null);
|
|
132
|
+
await this.sbx.archive();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
throw wrap(err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async kill() {
|
|
139
|
+
try {
|
|
140
|
+
await this.sbx.delete();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw wrap(err);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function wrap(err) {
|
|
148
|
+
if (err instanceof ProviderError)
|
|
149
|
+
return err;
|
|
150
|
+
const status = typeof err?.status === "number" ? err.status : err?.response?.status;
|
|
151
|
+
return new ProviderError("blaxel", err?.message ?? String(err), status);
|
|
152
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
export declare class BoatProvider implements Provider {
|
|
3
|
+
private apiKey;
|
|
4
|
+
private baseUrl;
|
|
5
|
+
readonly id: "boat";
|
|
6
|
+
constructor(apiKey?: string | undefined, baseUrl?: string);
|
|
7
|
+
configured(): boolean;
|
|
8
|
+
/** @internal */
|
|
9
|
+
request(method: string, path: string, init?: {
|
|
10
|
+
json?: unknown;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
}): Promise<Response>;
|
|
13
|
+
/** @internal */
|
|
14
|
+
json<T>(method: string, path: string, json?: unknown, headers?: Record<string, string>): Promise<T>;
|
|
15
|
+
/** @internal Poll until the sandbox can take commands. Resumes it first when it is stopped. */
|
|
16
|
+
ready(id: string, timeoutMs?: number): Promise<BoatSandboxObject>;
|
|
17
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
18
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
19
|
+
}
|
|
20
|
+
interface BoatSandboxObject {
|
|
21
|
+
id: string;
|
|
22
|
+
state: string;
|
|
23
|
+
error?: string | null;
|
|
24
|
+
}
|
|
25
|
+
export {};
|