@sandboxrouter/sdk 0.4.0 → 0.6.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/README.md +3 -2
- package/dist/cli.js +7 -5
- package/dist/client.d.ts +42 -14
- package/dist/client.js +39 -16
- package/dist/computers.d.ts +47 -4
- package/dist/computers.js +130 -17
- package/dist/gc.js +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/ledger.d.ts +11 -2
- package/dist/router.d.ts +40 -11
- package/dist/router.js +79 -60
- package/dist/server.d.ts +37 -7
- package/dist/server.js +195 -50
- package/dist/types.d.ts +28 -4
- package/dist/types.js +3 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.js +23 -1
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +66 -0
- package/package.json +1 -1
package/dist/router.d.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
import { RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
1
|
+
import { type ComputerRoute, RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
2
2
|
import { Ledger } from "./ledger.js";
|
|
3
3
|
import { type Attempt, type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type RouteOptions, type SandboxSpec } from "./types.js";
|
|
4
|
+
/** vCPUs and memory a sandbox gets. */
|
|
5
|
+
export interface Size {
|
|
6
|
+
vcpu: number;
|
|
7
|
+
memoryGb: number;
|
|
8
|
+
}
|
|
4
9
|
export interface Candidate {
|
|
5
10
|
provider: ProviderId;
|
|
6
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* What the sandbox is expected to cost, USD/hour: the provider's list price for this size, times what
|
|
13
|
+
* it really billed against list price where we measured that (`billedVsList`). The default strategy
|
|
14
|
+
* ranks by this, and a service reselling sandboxes should charge from it.
|
|
15
|
+
*/
|
|
7
16
|
estimatedHourlyUsd: number;
|
|
17
|
+
/** The provider's published list price for this size, USD/hour. */
|
|
18
|
+
listHourlyUsd: number;
|
|
19
|
+
/** Billed / list-price ratio behind `estimatedHourlyUsd`, when measured (`BILLED_VS_LIST`). */
|
|
20
|
+
billedVsList?: number;
|
|
8
21
|
/**
|
|
9
|
-
*
|
|
10
|
-
* (
|
|
22
|
+
* The size the sandbox gets: the provider's fixed default image size where it has one, else what was
|
|
23
|
+
* asked for (2 vCPU / 4 GB when nothing was). Some providers round up to their next size.
|
|
11
24
|
*/
|
|
12
|
-
|
|
25
|
+
size: Size;
|
|
13
26
|
/** Median create time: this router's own recent creates when it has a few, else your `bench` file. */
|
|
14
27
|
medianCreateMs?: number;
|
|
15
28
|
/** Passed the real-account check (as of this release, or `RouterOptions.verified`). */
|
|
@@ -48,7 +61,7 @@ export interface RouterOptions {
|
|
|
48
61
|
* costs money. Default false.
|
|
49
62
|
*/
|
|
50
63
|
requireVerified?: boolean;
|
|
51
|
-
/** Billed / list-price ratio per provider,
|
|
64
|
+
/** Billed / list-price ratio per provider, behind `estimatedHourlyUsd`. Default: the ratios in `BILLED_VS_LIST`, from saved cost runs. */
|
|
52
65
|
billedVsList?: Partial<Record<ProviderId, number>>;
|
|
53
66
|
}
|
|
54
67
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
@@ -92,25 +105,37 @@ export declare class Router {
|
|
|
92
105
|
provider?: ComputerProviderId | ComputerProviderId[];
|
|
93
106
|
}): import("./computers.js").ComputerCandidate[];
|
|
94
107
|
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}): Promise<RoutedComputer>;
|
|
108
|
+
/** Start a desktop or phone, routed like a sandbox (see createComputer). */
|
|
109
|
+
computer(spec?: ComputerSpec, route?: ComputerRoute): Promise<RoutedComputer>;
|
|
98
110
|
getComputer(id: string): Promise<RoutedComputer>;
|
|
99
111
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
100
112
|
get(id: string): Promise<RoutedSandbox>;
|
|
101
113
|
}
|
|
114
|
+
/** What a sandbox costs and its size, as the router priced it when it was created. */
|
|
115
|
+
export interface SandboxPrice {
|
|
116
|
+
/** Expected cost, USD/hour: list price x what the provider really billed against it, where measured. */
|
|
117
|
+
estimatedHourlyUsd: number;
|
|
118
|
+
/** The provider's published list price for this size, USD/hour. */
|
|
119
|
+
listHourlyUsd: number;
|
|
120
|
+
size: Size;
|
|
121
|
+
}
|
|
102
122
|
export declare class RoutedSandbox {
|
|
103
123
|
readonly provider: ProviderId;
|
|
104
124
|
private box;
|
|
105
125
|
/** Every create attempt, including failed fallbacks. */
|
|
106
126
|
readonly attempts: Attempt[];
|
|
107
|
-
readonly estimatedHourlyUsd: number;
|
|
108
127
|
private ledger?;
|
|
109
128
|
/** Stable id across processes: `provider:nativeId`. */
|
|
110
129
|
readonly id: string;
|
|
130
|
+
/** Expected cost, USD/hour (see `Candidate.estimatedHourlyUsd`). Undefined for a handle from `get()` this machine didn't create. */
|
|
131
|
+
readonly estimatedHourlyUsd?: number;
|
|
132
|
+
/** The provider's list price for this size, USD/hour. */
|
|
133
|
+
readonly listHourlyUsd?: number;
|
|
134
|
+
/** The size it was created at (see `Candidate.size`). */
|
|
135
|
+
readonly size?: Size;
|
|
111
136
|
constructor(provider: ProviderId, box: ProviderSandbox,
|
|
112
137
|
/** Every create attempt, including failed fallbacks. */
|
|
113
|
-
attempts: Attempt[],
|
|
138
|
+
attempts: Attempt[], price?: SandboxPrice, ledger?: Ledger | undefined);
|
|
114
139
|
get nativeId(): string;
|
|
115
140
|
/**
|
|
116
141
|
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
@@ -141,5 +166,9 @@ export declare function parseId(id: string): {
|
|
|
141
166
|
export declare function balanced(list: Candidate[]): Candidate[];
|
|
142
167
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
143
168
|
export declare const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
169
|
+
/** Seconds a whole create may take over every provider it tries (RouteOptions.deadlineSeconds). */
|
|
170
|
+
export declare const DEFAULT_CREATE_DEADLINE_S = 240;
|
|
144
171
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
145
172
|
export declare const EXEC_GRACE_S = 30;
|
|
173
|
+
/** The size a provider gives for a spec: its fixed default image size, else what was asked for. */
|
|
174
|
+
export declare function sizeOf(id: ProviderId, spec?: SandboxSpec): Size;
|
package/dist/router.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { CATALOG, accessLabel, hourlyCost, unfit } from "./catalog.js";
|
|
1
|
+
import { CATALOG, DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, fixedSize, hourlyCost, unfit } from "./catalog.js";
|
|
2
2
|
import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
4
|
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
|
-
import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, SETUP_ERRORS, errorStatus, timeoutError, withDeadline } from "./util.js";
|
|
6
6
|
import { BILLED_VS_LIST } from "./billing.js";
|
|
7
7
|
import { FAILING, VERIFIED } from "./verified.js";
|
|
8
|
+
import { validate } from "./validate.js";
|
|
8
9
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
9
10
|
import { E2BProvider } from "./providers/e2b.js";
|
|
10
11
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -128,11 +129,12 @@ export class Router {
|
|
|
128
129
|
provider(id) {
|
|
129
130
|
const p = this.providers.get(id);
|
|
130
131
|
if (!p)
|
|
131
|
-
throw new RouterError(`unknown provider: ${id}
|
|
132
|
+
throw new RouterError(`unknown provider: ${id}`, [], "bad_request");
|
|
132
133
|
return p;
|
|
133
134
|
}
|
|
134
135
|
/** The ordered list of providers a create would try, with the reason any are skipped. */
|
|
135
136
|
plan(spec = {}, route = {}) {
|
|
137
|
+
validate(spec, route);
|
|
136
138
|
const explicit = route.provider ? [].concat(route.provider) : null;
|
|
137
139
|
// Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
|
|
138
140
|
// less than a cloud micro-VM. Name them explicitly to use them.
|
|
@@ -142,15 +144,19 @@ export class Router {
|
|
|
142
144
|
const bench = speed ? loadBench(this.benchFile) : {};
|
|
143
145
|
const list = ids.map((id) => {
|
|
144
146
|
// An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
|
|
145
|
-
if (!CATALOG.some((c) => c.id === id))
|
|
146
|
-
return { provider: id, estimatedHourlyUsd: 0,
|
|
147
|
+
if (!CATALOG.some((c) => c.id === id)) {
|
|
148
|
+
return { provider: id, estimatedHourlyUsd: 0, listHourlyUsd: 0, size: { vcpu: 0, memoryGb: 0 }, verified: false, failing: false, skip: "unknown provider" };
|
|
149
|
+
}
|
|
147
150
|
const p = this.providers.get(id);
|
|
148
151
|
const list = round(hourlyCost(id, spec));
|
|
152
|
+
const ratio = this.billed[id];
|
|
149
153
|
const live = this.live.get(id);
|
|
150
154
|
const c = {
|
|
151
155
|
provider: id,
|
|
152
|
-
estimatedHourlyUsd: list,
|
|
153
|
-
|
|
156
|
+
estimatedHourlyUsd: round(list * (ratio ?? 1)),
|
|
157
|
+
listHourlyUsd: list,
|
|
158
|
+
...(ratio !== undefined ? { billedVsList: ratio } : {}),
|
|
159
|
+
size: sizeOf(id, spec),
|
|
154
160
|
medianCreateMs: speed ? (live && live.length >= LIVE_MIN_SAMPLES ? median(live) : median(bench[id]?.createMs)) : undefined,
|
|
155
161
|
verified: this.verified.has(id),
|
|
156
162
|
failing: this.failing.has(id),
|
|
@@ -170,15 +176,16 @@ export class Router {
|
|
|
170
176
|
// depends on who the provider is, only on its price, what it really billed, how fast it started for
|
|
171
177
|
// this router and whether its last real-account check failed. An explicit list keeps the caller's order.
|
|
172
178
|
if (!explicit) {
|
|
173
|
-
const
|
|
179
|
+
const byCost = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
180
|
+
const byList = (a, b) => a.listHourlyUsd - b.listHourlyUsd || a.provider.localeCompare(b.provider);
|
|
174
181
|
if (strategy === "fastest") {
|
|
175
|
-
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) ||
|
|
182
|
+
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b));
|
|
176
183
|
}
|
|
177
184
|
else if (strategy === "cheapest") {
|
|
178
|
-
list.sort(
|
|
185
|
+
list.sort(byList);
|
|
179
186
|
}
|
|
180
187
|
else if (strategy === "verified") {
|
|
181
|
-
list.sort((a, b) => Number(b.verified) - Number(a.verified) ||
|
|
188
|
+
list.sort((a, b) => Number(b.verified) - Number(a.verified) || byCost(a, b));
|
|
182
189
|
}
|
|
183
190
|
else {
|
|
184
191
|
return balanced(list);
|
|
@@ -219,41 +226,52 @@ export class Router {
|
|
|
219
226
|
const plan = this.plan(spec, route);
|
|
220
227
|
const usable = plan.filter((c) => !c.skip);
|
|
221
228
|
if (usable.length === 0)
|
|
222
|
-
throw new RouterError(this.whyNone(plan, !!route.provider));
|
|
229
|
+
throw new RouterError(this.whyNone(plan, !!route.provider), [], "no_provider");
|
|
230
|
+
// Pinned with no fallback: the first provider named, or nothing. Never a different one.
|
|
231
|
+
if (route.provider && route.fallback === false && plan[0].skip) {
|
|
232
|
+
throw new RouterError(`${plan[0].provider} can't take this sandbox: ${plan[0].skip}`, [], "no_provider");
|
|
233
|
+
}
|
|
223
234
|
const attempts = [];
|
|
224
235
|
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
225
236
|
const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
237
|
+
const deadline = Date.now() + (route.deadlineSeconds ?? DEFAULT_CREATE_DEADLINE_S) * 1000;
|
|
226
238
|
for (const c of tries) {
|
|
239
|
+
const left = deadline - Date.now();
|
|
240
|
+
if (left <= 0) {
|
|
241
|
+
throw new RouterError(`create ran out of time (deadlineSeconds) after trying ${attempts.map((a) => a.provider).join(", ")}: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "timeout");
|
|
242
|
+
}
|
|
227
243
|
const t0 = Date.now();
|
|
228
244
|
const pending = this.provider(c.provider).create(spec);
|
|
245
|
+
const waitS = Math.min(limitS, left / 1000);
|
|
246
|
+
const price = { estimatedHourlyUsd: c.estimatedHourlyUsd, listHourlyUsd: c.listHourlyUsd, size: c.size };
|
|
229
247
|
let timedOut = false;
|
|
230
248
|
try {
|
|
231
|
-
const box = await withDeadline(pending,
|
|
249
|
+
const box = await withDeadline(pending, waitS * 1000, () => {
|
|
232
250
|
timedOut = true;
|
|
233
|
-
return new ProviderError(c.provider, `create timed out after ${
|
|
251
|
+
return new ProviderError(c.provider, `create timed out after ${Number(waitS.toFixed(1))}s`);
|
|
234
252
|
});
|
|
235
253
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
236
254
|
this.observe(c.provider, Date.now() - t0);
|
|
237
|
-
const sbx = new RoutedSandbox(c.provider, box, attempts,
|
|
238
|
-
this.record(sbx,
|
|
255
|
+
const sbx = new RoutedSandbox(c.provider, box, attempts, price, this.ledger);
|
|
256
|
+
this.record(sbx, price, spec.timeoutMinutes);
|
|
239
257
|
return sbx;
|
|
240
258
|
}
|
|
241
259
|
catch (err) {
|
|
242
260
|
// A create that answers after we gave up still made a sandbox: record it, then kill it,
|
|
243
261
|
// so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
|
|
244
262
|
if (timedOut)
|
|
245
|
-
pending.then((late) => this.discard(c.provider, late,
|
|
263
|
+
pending.then((late) => this.discard(c.provider, late, price, spec.timeoutMinutes), () => { });
|
|
246
264
|
const status = errorStatus(err);
|
|
247
265
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message, ...(status ? { status } : {}) });
|
|
248
266
|
const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
|
|
249
267
|
if (route.fallback !== "any" && setup) {
|
|
250
268
|
const msg = err.message.replace(/[.\s]+$/, "");
|
|
251
269
|
throw new RouterError(`${msg.startsWith(c.provider) ? msg : `${c.provider}: ${msg}`}. This looks like a setup problem with ${c.provider} (key, image, request or a missing package), ` +
|
|
252
|
-
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
270
|
+
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts, "setup");
|
|
253
271
|
}
|
|
254
272
|
}
|
|
255
273
|
}
|
|
256
|
-
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
274
|
+
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "all_failed");
|
|
257
275
|
}
|
|
258
276
|
/** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
|
|
259
277
|
observe(id, ms) {
|
|
@@ -263,19 +281,21 @@ export class Router {
|
|
|
263
281
|
xs.shift();
|
|
264
282
|
this.live.set(id, xs);
|
|
265
283
|
}
|
|
266
|
-
record(sbx,
|
|
284
|
+
record(sbx, price, timeoutMinutes) {
|
|
267
285
|
const now = Date.now();
|
|
268
286
|
this.ledger?.open({
|
|
269
287
|
id: sbx.id,
|
|
270
288
|
provider: sbx.provider,
|
|
271
289
|
createdAt: new Date(now).toISOString(),
|
|
272
290
|
expiresAt: new Date(now + (timeoutMinutes ?? 30) * 60_000).toISOString(),
|
|
273
|
-
usdHour,
|
|
291
|
+
usdHour: price.estimatedHourlyUsd,
|
|
292
|
+
listUsdHour: price.listHourlyUsd,
|
|
293
|
+
size: price.size,
|
|
274
294
|
});
|
|
275
295
|
}
|
|
276
|
-
discard(provider, box,
|
|
277
|
-
const sbx = new RoutedSandbox(provider, box, [],
|
|
278
|
-
this.record(sbx,
|
|
296
|
+
discard(provider, box, price, timeoutMinutes) {
|
|
297
|
+
const sbx = new RoutedSandbox(provider, box, [], price, this.ledger);
|
|
298
|
+
this.record(sbx, price, timeoutMinutes);
|
|
279
299
|
return sbx.kill().catch(() => { });
|
|
280
300
|
}
|
|
281
301
|
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
@@ -289,44 +309,57 @@ export class Router {
|
|
|
289
309
|
return planComputer(this.computerProviders(), os, prefer);
|
|
290
310
|
}
|
|
291
311
|
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
312
|
+
/** Start a desktop or phone, routed like a sandbox (see createComputer). */
|
|
313
|
+
computer(spec = {}, route = {}) {
|
|
314
|
+
const prefer = route.provider ? [].concat(route.provider) : undefined;
|
|
315
|
+
return createComputer(this.computerProviders(), spec, prefer, { ...route, ledger: this.ledger });
|
|
295
316
|
}
|
|
296
317
|
async getComputer(id) {
|
|
297
318
|
const i = id.indexOf(":");
|
|
298
319
|
if (i <= 0)
|
|
299
|
-
throw new RouterError(`bad computer id "${id}": expected provider:id
|
|
320
|
+
throw new RouterError(`bad computer id "${id}": expected provider:id`, [], "bad_request");
|
|
300
321
|
const provider = id.slice(0, i);
|
|
301
322
|
const p = this.computerProviders().find((c) => c.id === provider);
|
|
302
323
|
if (!p)
|
|
303
|
-
throw new RouterError(`${provider} does not offer computers
|
|
304
|
-
|
|
324
|
+
throw new RouterError(`${provider} does not offer computers`, [], "bad_request");
|
|
325
|
+
const e = this.ledger?.list().find((x) => x.id === id);
|
|
326
|
+
return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)), [], e?.usdHour, this.ledger);
|
|
305
327
|
}
|
|
306
328
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
307
329
|
async get(id) {
|
|
308
330
|
const { provider, nativeId } = parseId(id);
|
|
309
331
|
const box = await this.provider(provider).connect(nativeId);
|
|
310
|
-
|
|
332
|
+
// The price and size come from this machine's ledger when it started the sandbox; otherwise
|
|
333
|
+
// they're unknown (a guess at the default size would be wrong for anything bigger).
|
|
334
|
+
const e = this.ledger?.list().find((x) => x.id === id);
|
|
335
|
+
const price = e && e.listUsdHour !== undefined && e.size ? { estimatedHourlyUsd: e.usdHour, listHourlyUsd: e.listUsdHour, size: e.size } : undefined;
|
|
336
|
+
return new RoutedSandbox(provider, box, [], price, this.ledger);
|
|
311
337
|
}
|
|
312
338
|
}
|
|
313
339
|
export class RoutedSandbox {
|
|
314
340
|
provider;
|
|
315
341
|
box;
|
|
316
342
|
attempts;
|
|
317
|
-
estimatedHourlyUsd;
|
|
318
343
|
ledger;
|
|
319
344
|
/** Stable id across processes: `provider:nativeId`. */
|
|
320
345
|
id;
|
|
346
|
+
/** Expected cost, USD/hour (see `Candidate.estimatedHourlyUsd`). Undefined for a handle from `get()` this machine didn't create. */
|
|
347
|
+
estimatedHourlyUsd;
|
|
348
|
+
/** The provider's list price for this size, USD/hour. */
|
|
349
|
+
listHourlyUsd;
|
|
350
|
+
/** The size it was created at (see `Candidate.size`). */
|
|
351
|
+
size;
|
|
321
352
|
constructor(provider, box,
|
|
322
353
|
/** Every create attempt, including failed fallbacks. */
|
|
323
|
-
attempts,
|
|
354
|
+
attempts, price, ledger) {
|
|
324
355
|
this.provider = provider;
|
|
325
356
|
this.box = box;
|
|
326
357
|
this.attempts = attempts;
|
|
327
|
-
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
328
358
|
this.ledger = ledger;
|
|
329
359
|
this.id = `${provider}:${box.nativeId}`;
|
|
360
|
+
this.estimatedHourlyUsd = price?.estimatedHourlyUsd;
|
|
361
|
+
this.listHourlyUsd = price?.listHourlyUsd;
|
|
362
|
+
this.size = price?.size;
|
|
330
363
|
}
|
|
331
364
|
get nativeId() {
|
|
332
365
|
return this.box.nativeId;
|
|
@@ -357,7 +390,7 @@ export class RoutedSandbox {
|
|
|
357
390
|
/** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
|
|
358
391
|
async pause() {
|
|
359
392
|
if (!this.box.pause)
|
|
360
|
-
throw new RouterError(`${this.provider} does not support pause; create with require: ["pause"]
|
|
393
|
+
throw new RouterError(`${this.provider} does not support pause; create with require: ["pause"]`, [], "unsupported");
|
|
361
394
|
await this.box.pause();
|
|
362
395
|
}
|
|
363
396
|
get canPause() {
|
|
@@ -370,10 +403,10 @@ export class RoutedSandbox {
|
|
|
370
403
|
export function parseId(id) {
|
|
371
404
|
const i = id.indexOf(":");
|
|
372
405
|
if (i <= 0)
|
|
373
|
-
throw new RouterError(`bad sandbox id "${id}": expected provider:id
|
|
406
|
+
throw new RouterError(`bad sandbox id "${id}": expected provider:id`, [], "bad_request");
|
|
374
407
|
const provider = id.slice(0, i);
|
|
375
408
|
if (!CATALOG.some((c) => c.id === provider))
|
|
376
|
-
throw new RouterError(`unknown provider in id: ${provider}
|
|
409
|
+
throw new RouterError(`unknown provider in id: ${provider}`, [], "bad_request");
|
|
377
410
|
return { provider, nativeId: id.slice(i + 1) };
|
|
378
411
|
}
|
|
379
412
|
function round(n) {
|
|
@@ -386,14 +419,15 @@ function round(n) {
|
|
|
386
419
|
* median create time; unknown speed after known), then cheapest, then alphabetical.
|
|
387
420
|
*/
|
|
388
421
|
export function balanced(list) {
|
|
389
|
-
const byCost = (a, b) => a.
|
|
422
|
+
const byCost = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
390
423
|
const bySpeed = (a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b);
|
|
391
424
|
const order = (group) => {
|
|
392
425
|
const rest = [...group].sort(byCost);
|
|
393
426
|
const out = [];
|
|
394
427
|
while (rest.length) {
|
|
395
|
-
const limit = rest[0].
|
|
396
|
-
|
|
428
|
+
const limit = rest[0].estimatedHourlyUsd * (1 + PRICE_TIE);
|
|
429
|
+
// Always take at least the cheapest, so the loop ends whatever the prices are.
|
|
430
|
+
const tie = rest.filter((c, i) => i === 0 || c.estimatedHourlyUsd <= limit);
|
|
397
431
|
out.push(...tie.sort(bySpeed));
|
|
398
432
|
rest.splice(0, tie.length);
|
|
399
433
|
}
|
|
@@ -403,26 +437,11 @@ export function balanced(list) {
|
|
|
403
437
|
}
|
|
404
438
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
405
439
|
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
409
|
-
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
410
|
-
*/
|
|
411
|
-
const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
|
|
440
|
+
/** Seconds a whole create may take over every provider it tries (RouteOptions.deadlineSeconds). */
|
|
441
|
+
export const DEFAULT_CREATE_DEADLINE_S = 240;
|
|
412
442
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
413
443
|
export const EXEC_GRACE_S = 30;
|
|
414
|
-
/**
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const expired = new Promise((_, reject) => {
|
|
418
|
-
timer = setTimeout(() => reject(onExpire()), ms);
|
|
419
|
-
timer.unref();
|
|
420
|
-
});
|
|
421
|
-
try {
|
|
422
|
-
return await Promise.race([work, expired]);
|
|
423
|
-
}
|
|
424
|
-
finally {
|
|
425
|
-
clearTimeout(timer);
|
|
426
|
-
work.catch(() => { });
|
|
427
|
-
}
|
|
444
|
+
/** The size a provider gives for a spec: its fixed default image size, else what was asked for. */
|
|
445
|
+
export function sizeOf(id, spec = {}) {
|
|
446
|
+
return fixedSize(id, spec) ?? { vcpu: spec.vcpu ?? DEFAULT_VCPU, memoryGb: spec.memoryGb ?? DEFAULT_MEMORY_GB };
|
|
428
447
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { Router, type RoutedSandbox } from "./router.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type ComputerSpec, type RoutedComputer } from "./computers.js";
|
|
4
|
+
import { type RouteOptions, type RouterErrorCode, type SandboxSpec } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* Hooks for running this API for many users (accounts, billing, per-user sandboxes). With none set,
|
|
6
7
|
* the server is single-user: one SANDBOXROUTER_TOKEN, every sandbox reachable by anyone holding it.
|
|
@@ -10,16 +11,41 @@ export interface ServerHooks {
|
|
|
10
11
|
authorize?(req: IncomingMessage): Promise<unknown | null>;
|
|
11
12
|
/** Routes outside /v1 (sign-in, billing webhooks, pages), checked before auth. Return true if handled. */
|
|
12
13
|
extra?(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
|
|
13
|
-
/** Before a sandbox starts. Throw HttpError to refuse (402 out of credit, 429 too many); may edit spec. */
|
|
14
|
+
/** Before a sandbox starts. Throw HttpError to refuse (402 out of credit, 429 too many); may edit spec and route. */
|
|
14
15
|
beforeCreate?(caller: unknown, spec: SandboxSpec, route: RouteOptions): Promise<void>;
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* After it started. Return fields to add to the create response (e.g. what the caller is charged). If
|
|
18
|
+
* this throws, the sandbox is killed and the error returned. Not called when the caller hung up before
|
|
19
|
+
* the sandbox was ready: that sandbox is killed instead.
|
|
20
|
+
*/
|
|
21
|
+
afterCreate?(caller: unknown, sbx: RoutedSandbox, spec: SandboxSpec): Promise<Record<string, unknown> | void>;
|
|
22
|
+
/** A create that passed beforeCreate ended without a sandbox for the caller. Release whatever beforeCreate held. */
|
|
23
|
+
createFailed?(caller: unknown): Promise<void> | void;
|
|
17
24
|
/** May this caller use this sandbox? No means 404. */
|
|
18
25
|
owns?(caller: unknown, id: string): Promise<boolean>;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
/**
|
|
27
|
+
* After DELETE. `error` is set when the provider's kill failed (the sandbox may still be running): the
|
|
28
|
+
* server answers with the error, but the caller asked for it gone, so stop charging them for it. A kill
|
|
29
|
+
* of a sandbox the provider no longer has counts as done.
|
|
30
|
+
*/
|
|
31
|
+
afterKill?(caller: unknown, id: string, outcome?: {
|
|
32
|
+
error?: Error;
|
|
33
|
+
}): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* /v1/computers. Default: on for a single-user server, off when `authorize` is set. When on with
|
|
36
|
+
* `authorize`, `owns` guards computer ids too, and the computer hooks below run.
|
|
37
|
+
*/
|
|
22
38
|
computers?: boolean;
|
|
39
|
+
/** Before a computer starts. Throw HttpError to refuse; may edit spec. */
|
|
40
|
+
beforeCreateComputer?(caller: unknown, spec: ComputerSpec): Promise<void>;
|
|
41
|
+
/** After a computer started. Return fields to add to the answer. If this throws, the computer is deleted. */
|
|
42
|
+
afterCreateComputer?(caller: unknown, computer: RoutedComputer): Promise<Record<string, unknown> | void>;
|
|
43
|
+
/** After DELETE /v1/computers/:id; `error` is set when the provider's delete failed. */
|
|
44
|
+
afterDeleteComputer?(caller: unknown, id: string, outcome?: {
|
|
45
|
+
error?: Error;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
/** Milliseconds a sandbox whose caller hung up (having sent an Idempotency-Key) waits for a retry to collect it. Default 120000. */
|
|
48
|
+
orphanGraceMs?: number;
|
|
23
49
|
/** false turns off pause. */
|
|
24
50
|
pause?: boolean;
|
|
25
51
|
}
|
|
@@ -56,3 +82,7 @@ export declare class HttpError extends Error {
|
|
|
56
82
|
readonly status: number;
|
|
57
83
|
constructor(status: number, message: string);
|
|
58
84
|
}
|
|
85
|
+
/** How long a sandbox whose caller hung up (with an Idempotency-Key) waits for a retry to collect it. */
|
|
86
|
+
export declare const ORPHAN_GRACE_MS = 120000;
|
|
87
|
+
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
88
|
+
export declare const ERROR_STATUS: Record<RouterErrorCode, number>;
|