@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/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
- /** List price for the spec, USD/hour. What the sandbox is expected to cost at the provider's published rates. */
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
- * List price x what the provider really billed against list price, where that was measured
10
- * (`BILLED_VS_LIST`, or `RouterOptions.billedVsList`); the list price otherwise. The default strategy ranks by this.
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
- effectiveHourlyUsd: number;
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, for the default ranking. Default: `BILLED_VS_LIST` from saved cost runs. */
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
- computer(spec?: ComputerSpec, opts?: {
96
- provider?: ComputerProviderId | ComputerProviderId[];
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[], estimatedHourlyUsd: number, ledger?: Ledger | undefined);
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, effectiveHourlyUsd: 0, verified: false, failing: false, skip: "unknown provider" };
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
- effectiveHourlyUsd: round(list * (this.billed[id] ?? 1)),
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 byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
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) || byPrice(a, b));
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(byPrice);
185
+ list.sort(byList);
179
186
  }
180
187
  else if (strategy === "verified") {
181
- list.sort((a, b) => Number(b.verified) - Number(a.verified) || byPrice(a, b));
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, limitS * 1000, () => {
249
+ const box = await withDeadline(pending, waitS * 1000, () => {
232
250
  timedOut = true;
233
- return new ProviderError(c.provider, `create timed out after ${limitS}s`);
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, c.estimatedHourlyUsd, this.ledger);
238
- this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
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, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
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, usdHour, timeoutMinutes) {
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, usdHour, timeoutMinutes) {
277
- const sbx = new RoutedSandbox(provider, box, [], usdHour, this.ledger);
278
- this.record(sbx, usdHour, timeoutMinutes);
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
- computer(spec = {}, opts = {}) {
293
- const prefer = opts.provider ? [].concat(opts.provider) : undefined;
294
- return createComputer(this.computerProviders(), spec, prefer);
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
- return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)));
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
- return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)), this.ledger);
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, estimatedHourlyUsd, ledger) {
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.effectiveHourlyUsd - b.effectiveHourlyUsd || a.provider.localeCompare(b.provider);
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].effectiveHourlyUsd * (1 + PRICE_TIE);
396
- const tie = rest.filter((c) => c.effectiveHourlyUsd <= limit);
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
- * HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
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
- /** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
415
- async function withDeadline(work, ms, onExpire) {
416
- let timer;
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 RouteOptions, type SandboxSpec } from "./types.js";
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
- /** After it started. If this throws, the sandbox is killed and the error returned. */
16
- afterCreate?(caller: unknown, sbx: RoutedSandbox, spec: SandboxSpec): Promise<void>;
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
- /** After DELETE killed it. */
20
- afterKill?(caller: unknown, id: string): Promise<void>;
21
- /** false turns off /v1/computers. */
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>;