@sandboxrouter/sdk 0.1.0 → 0.3.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 +271 -62
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +232 -3
- package/dist/catalog.js +569 -14
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +149 -12
- package/dist/computers.d.ts +55 -13
- package/dist/computers.js +132 -6
- package/dist/conformance.d.ts +46 -0
- package/dist/conformance.js +65 -0
- package/dist/cost.d.ts +67 -0
- package/dist/cost.js +153 -0
- package/dist/gc.d.ts +29 -0
- package/dist/gc.js +54 -0
- package/dist/index.d.ts +34 -2
- package/dist/index.js +34 -2
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +5 -4
- package/dist/ledger.d.ts +38 -0
- package/dist/ledger.js +89 -0
- package/dist/providers/agent37.d.ts +40 -0
- package/dist/providers/agent37.js +224 -0
- package/dist/providers/agentcore.d.ts +71 -0
- package/dist/providers/agentcore.js +446 -0
- package/dist/providers/alibaba.d.ts +18 -0
- package/dist/providers/alibaba.js +106 -0
- package/dist/providers/azure.d.ts +43 -0
- package/dist/providers/azure.js +330 -0
- package/dist/providers/beam.d.ts +11 -0
- package/dist/providers/beam.js +179 -0
- package/dist/providers/blaxel.d.ts +3 -1
- package/dist/providers/blaxel.js +32 -22
- package/dist/providers/boat.d.ts +25 -0
- package/dist/providers/boat.js +263 -0
- package/dist/providers/cloudflare.d.ts +30 -0
- package/dist/providers/cloudflare.js +152 -0
- package/dist/providers/codesandbox.d.ts +59 -0
- package/dist/providers/codesandbox.js +287 -0
- package/dist/providers/daytona.d.ts +25 -4
- package/dist/providers/daytona.js +449 -28
- package/dist/providers/dedalus.d.ts +12 -0
- package/dist/providers/dedalus.js +244 -0
- package/dist/providers/deno.d.ts +50 -0
- package/dist/providers/deno.js +170 -0
- package/dist/providers/docker.d.ts +17 -0
- package/dist/providers/docker.js +154 -0
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.d.ts +23 -0
- package/dist/providers/exedev.js +290 -0
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.d.ts +15 -0
- package/dist/providers/hopx.js +177 -0
- package/dist/providers/instabox.d.ts +76 -0
- package/dist/providers/instabox.js +360 -0
- package/dist/providers/koyeb.d.ts +71 -0
- package/dist/providers/koyeb.js +379 -0
- package/dist/providers/lambda.d.ts +51 -0
- package/dist/providers/lambda.js +467 -0
- package/dist/providers/langsmith.d.ts +16 -0
- package/dist/providers/langsmith.js +149 -0
- package/dist/providers/limrun.d.ts +77 -0
- package/dist/providers/limrun.js +626 -0
- package/dist/providers/maritime.d.ts +5 -1
- package/dist/providers/maritime.js +39 -8
- package/dist/providers/mobilerun.d.ts +44 -0
- package/dist/providers/mobilerun.js +417 -0
- package/dist/providers/modal.js +12 -4
- package/dist/providers/morph.js +12 -4
- package/dist/providers/northflank.d.ts +21 -0
- package/dist/providers/northflank.js +259 -0
- package/dist/providers/novita.d.ts +12 -0
- package/dist/providers/novita.js +81 -0
- package/dist/providers/openscale.d.ts +17 -0
- package/dist/providers/openscale.js +87 -0
- package/dist/providers/orgo.d.ts +45 -0
- package/dist/providers/orgo.js +414 -0
- package/dist/providers/sail.d.ts +32 -0
- package/dist/providers/sail.js +372 -0
- package/dist/providers/superserve.d.ts +25 -0
- package/dist/providers/superserve.js +337 -0
- package/dist/providers/tensorlake.js +17 -14
- package/dist/providers/upstash.d.ts +30 -0
- package/dist/providers/upstash.js +258 -0
- package/dist/providers/vercel.js +14 -3
- package/dist/providers/workspaces.d.ts +60 -0
- package/dist/providers/workspaces.js +662 -0
- package/dist/router.d.ts +36 -5
- package/dist/router.js +174 -25
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +18 -6
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +106 -17
package/dist/router.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { RoutedComputer, type ComputerProvider, type ComputerSpec } from "./computers.js";
|
|
1
|
+
import { RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
2
|
+
import { Ledger } from "./ledger.js";
|
|
2
3
|
import { type Attempt, type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type RouteOptions, type SandboxSpec } from "./types.js";
|
|
3
4
|
export interface Candidate {
|
|
4
5
|
provider: ProviderId;
|
|
@@ -12,11 +13,24 @@ export interface RouterOptions {
|
|
|
12
13
|
providers?: Provider[];
|
|
13
14
|
/** Path of the bench results file used by strategy "fastest". */
|
|
14
15
|
benchFile?: string;
|
|
16
|
+
/** Desktop-only computer providers (sandbox providers that also sell computers are found automatically). */
|
|
17
|
+
computers?: ComputerProvider[];
|
|
18
|
+
/**
|
|
19
|
+
* Record created sandboxes so leaks can be found and killed (`sandboxrouter ls` / `gc`).
|
|
20
|
+
* Default: ~/.sandboxrouter/sandboxes.jsonl (or $SANDBOXROUTER_HOME); a path for another file;
|
|
21
|
+
* false to keep no record. SANDBOXROUTER_LEDGER=off also turns it off.
|
|
22
|
+
*/
|
|
23
|
+
ledger?: string | false;
|
|
15
24
|
}
|
|
25
|
+
/** Desktop-only providers. Filled in as adapters are added. */
|
|
26
|
+
export declare function defaultComputerProviders(): ComputerProvider[];
|
|
16
27
|
export declare function defaultProviders(): Provider[];
|
|
17
28
|
export declare class Router {
|
|
18
29
|
private providers;
|
|
19
30
|
private benchFile?;
|
|
31
|
+
private computerOnly;
|
|
32
|
+
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
33
|
+
readonly ledger?: Ledger;
|
|
20
34
|
constructor(opts?: RouterOptions);
|
|
21
35
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
22
36
|
isConfigured(id: ProviderId): boolean;
|
|
@@ -27,11 +41,17 @@ export declare class Router {
|
|
|
27
41
|
plan(spec?: SandboxSpec, route?: RouteOptions): Candidate[];
|
|
28
42
|
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
29
43
|
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
|
|
30
|
-
|
|
44
|
+
private record;
|
|
45
|
+
private discard;
|
|
46
|
+
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
31
47
|
computerProviders(): ComputerProvider[];
|
|
32
|
-
/**
|
|
48
|
+
/** The order a computer create would try for this OS. */
|
|
49
|
+
planComputer(os?: ComputerOs, opts?: {
|
|
50
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
51
|
+
}): import("./computers.js").ComputerCandidate[];
|
|
52
|
+
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
33
53
|
computer(spec?: ComputerSpec, opts?: {
|
|
34
|
-
provider?:
|
|
54
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
35
55
|
}): Promise<RoutedComputer>;
|
|
36
56
|
getComputer(id: string): Promise<RoutedComputer>;
|
|
37
57
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
@@ -43,12 +63,19 @@ export declare class RoutedSandbox {
|
|
|
43
63
|
/** Every create attempt, including failed fallbacks. */
|
|
44
64
|
readonly attempts: Attempt[];
|
|
45
65
|
readonly estimatedHourlyUsd: number;
|
|
66
|
+
private ledger?;
|
|
46
67
|
/** Stable id across processes: `provider:nativeId`. */
|
|
47
68
|
readonly id: string;
|
|
48
69
|
constructor(provider: ProviderId, box: ProviderSandbox,
|
|
49
70
|
/** Every create attempt, including failed fallbacks. */
|
|
50
|
-
attempts: Attempt[], estimatedHourlyUsd: number);
|
|
71
|
+
attempts: Attempt[], estimatedHourlyUsd: number, ledger?: Ledger | undefined);
|
|
51
72
|
get nativeId(): string;
|
|
73
|
+
/**
|
|
74
|
+
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
75
|
+
* `ProviderError("<provider>: command timed out after Ns")`. Should an adapter fail to notice its
|
|
76
|
+
* own deadline, the router gives up `EXEC_GRACE_S` later with the same error, so callers can
|
|
77
|
+
* always rely on the timeout being bounded and the message being the same on every provider.
|
|
78
|
+
*/
|
|
52
79
|
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
53
80
|
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
54
81
|
readFile(path: string): Promise<Uint8Array>;
|
|
@@ -63,3 +90,7 @@ export declare function parseId(id: string): {
|
|
|
63
90
|
provider: ProviderId;
|
|
64
91
|
nativeId: string;
|
|
65
92
|
};
|
|
93
|
+
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
94
|
+
export declare const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
95
|
+
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
96
|
+
export declare const EXEC_GRACE_S = 30;
|
package/dist/router.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { CATALOG, hourlyCost, unfit } from "./catalog.js";
|
|
2
|
-
import { RoutedComputer, createComputer } from "./computers.js";
|
|
1
|
+
import { CATALOG, accessLabel, hourlyCost, unfit } from "./catalog.js";
|
|
2
|
+
import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
|
+
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, timeoutError } from "./util.js";
|
|
4
6
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
5
7
|
import { E2BProvider } from "./providers/e2b.js";
|
|
6
8
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -11,7 +13,39 @@ import { SpritesProvider } from "./providers/sprites.js";
|
|
|
11
13
|
import { BlaxelProvider } from "./providers/blaxel.js";
|
|
12
14
|
import { TensorlakeProvider } from "./providers/tensorlake.js";
|
|
13
15
|
import { RunloopProvider } from "./providers/runloop.js";
|
|
14
|
-
import {
|
|
16
|
+
import { DockerProvider } from "./providers/docker.js";
|
|
17
|
+
import { HopxProvider } from "./providers/hopx.js";
|
|
18
|
+
import { CloudflareProvider } from "./providers/cloudflare.js";
|
|
19
|
+
import { NorthflankProvider } from "./providers/northflank.js";
|
|
20
|
+
import { BoatProvider } from "./providers/boat.js";
|
|
21
|
+
import { ExeDevProvider } from "./providers/exedev.js";
|
|
22
|
+
import { AgentCoreProvider } from "./providers/agentcore.js";
|
|
23
|
+
import { AzureSessionsProvider } from "./providers/azure.js";
|
|
24
|
+
import { LambdaMicrovmProvider } from "./providers/lambda.js";
|
|
25
|
+
import { AlibabaProvider } from "./providers/alibaba.js";
|
|
26
|
+
import { BeamProvider } from "./providers/beam.js";
|
|
27
|
+
import { CodeSandboxProvider } from "./providers/codesandbox.js";
|
|
28
|
+
import { DenoProvider } from "./providers/deno.js";
|
|
29
|
+
import { FreestyleProvider } from "./providers/freestyle.js";
|
|
30
|
+
import { KoyebProvider } from "./providers/koyeb.js";
|
|
31
|
+
import { DedalusProvider } from "./providers/dedalus.js";
|
|
32
|
+
import { Agent37Provider } from "./providers/agent37.js";
|
|
33
|
+
import { OpenScaleProvider } from "./providers/openscale.js";
|
|
34
|
+
import { SuperserveProvider } from "./providers/superserve.js";
|
|
35
|
+
import { SailProvider } from "./providers/sail.js";
|
|
36
|
+
import { InstaboxProvider } from "./providers/instabox.js";
|
|
37
|
+
import { NovitaProvider } from "./providers/novita.js";
|
|
38
|
+
import { LangSmithProvider } from "./providers/langsmith.js";
|
|
39
|
+
import { UpstashProvider } from "./providers/upstash.js";
|
|
40
|
+
import { OrgoProvider } from "./providers/orgo.js";
|
|
41
|
+
import { MobilerunProvider } from "./providers/mobilerun.js";
|
|
42
|
+
import { LimrunProvider } from "./providers/limrun.js";
|
|
43
|
+
import { WorkspacesAgentProvider } from "./providers/workspaces.js";
|
|
44
|
+
import { ProviderError, RouterError, } from "./types.js";
|
|
45
|
+
/** Desktop-only providers. Filled in as adapters are added. */
|
|
46
|
+
export function defaultComputerProviders() {
|
|
47
|
+
return [new OrgoProvider(), new MobilerunProvider(), new LimrunProvider(), new WorkspacesAgentProvider()];
|
|
48
|
+
}
|
|
15
49
|
export function defaultProviders() {
|
|
16
50
|
return [
|
|
17
51
|
new MaritimeProvider(),
|
|
@@ -24,14 +58,45 @@ export function defaultProviders() {
|
|
|
24
58
|
new BlaxelProvider(),
|
|
25
59
|
new TensorlakeProvider(),
|
|
26
60
|
new RunloopProvider(),
|
|
61
|
+
new HopxProvider(),
|
|
62
|
+
new CloudflareProvider(),
|
|
63
|
+
new NorthflankProvider(),
|
|
64
|
+
new BoatProvider(),
|
|
65
|
+
new ExeDevProvider(),
|
|
66
|
+
new AgentCoreProvider(),
|
|
67
|
+
new AzureSessionsProvider(),
|
|
68
|
+
new LambdaMicrovmProvider(),
|
|
69
|
+
new Agent37Provider(),
|
|
70
|
+
new AlibabaProvider(),
|
|
71
|
+
new BeamProvider(),
|
|
72
|
+
new CodeSandboxProvider(),
|
|
73
|
+
new DedalusProvider(),
|
|
74
|
+
new DenoProvider(),
|
|
75
|
+
new FreestyleProvider(),
|
|
76
|
+
new InstaboxProvider(),
|
|
77
|
+
new KoyebProvider(),
|
|
78
|
+
new NovitaProvider(),
|
|
79
|
+
new LangSmithProvider(),
|
|
80
|
+
new OpenScaleProvider(),
|
|
81
|
+
new SuperserveProvider(),
|
|
82
|
+
new SailProvider(),
|
|
83
|
+
new UpstashProvider(),
|
|
84
|
+
new DockerProvider(),
|
|
27
85
|
];
|
|
28
86
|
}
|
|
29
87
|
export class Router {
|
|
30
88
|
providers;
|
|
31
89
|
benchFile;
|
|
90
|
+
computerOnly;
|
|
91
|
+
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
92
|
+
ledger;
|
|
32
93
|
constructor(opts = {}) {
|
|
94
|
+
const file = opts.ledger === false ? null : (opts.ledger ?? defaultLedgerFile());
|
|
95
|
+
if (file)
|
|
96
|
+
this.ledger = new Ledger(file);
|
|
33
97
|
this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
|
|
34
98
|
this.benchFile = opts.benchFile;
|
|
99
|
+
this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
|
|
35
100
|
}
|
|
36
101
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
37
102
|
isConfigured(id) {
|
|
@@ -50,9 +115,14 @@ export class Router {
|
|
|
50
115
|
/** The ordered list of providers a create would try, with the reason any are skipped. */
|
|
51
116
|
plan(spec = {}, route = {}) {
|
|
52
117
|
const explicit = route.provider ? [].concat(route.provider) : null;
|
|
53
|
-
|
|
118
|
+
// Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
|
|
119
|
+
// less than a cloud micro-VM. Name them explicitly to use them.
|
|
120
|
+
const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
|
|
54
121
|
const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
|
|
55
122
|
const list = ids.map((id) => {
|
|
123
|
+
// An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
|
|
124
|
+
if (!CATALOG.some((c) => c.id === id))
|
|
125
|
+
return { provider: id, estimatedHourlyUsd: 0, skip: "unknown provider" };
|
|
56
126
|
const p = this.providers.get(id);
|
|
57
127
|
const c = {
|
|
58
128
|
provider: id,
|
|
@@ -60,16 +130,23 @@ export class Router {
|
|
|
60
130
|
medianCreateMs: median(bench[id]?.createMs),
|
|
61
131
|
};
|
|
62
132
|
if (!p)
|
|
63
|
-
c.skip = "
|
|
64
|
-
else
|
|
65
|
-
|
|
133
|
+
c.skip = "no adapter registered";
|
|
134
|
+
else {
|
|
135
|
+
const gated = accessLabel(id);
|
|
136
|
+
c.skip = unfit(id, spec, route.require) ?? (p.configured() ? undefined : gated ? `not configured; ${gated}` : "not configured");
|
|
137
|
+
}
|
|
66
138
|
return c;
|
|
67
139
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
140
|
+
// Neutral by construction: price (or measured speed) decides, ties break alphabetically by id.
|
|
141
|
+
// An explicit provider list keeps the caller's order.
|
|
142
|
+
if (!explicit) {
|
|
143
|
+
const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
144
|
+
if (route.strategy === "fastest") {
|
|
145
|
+
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
list.sort(byPrice);
|
|
149
|
+
}
|
|
73
150
|
}
|
|
74
151
|
return list;
|
|
75
152
|
}
|
|
@@ -83,40 +160,81 @@ export class Router {
|
|
|
83
160
|
}
|
|
84
161
|
const attempts = [];
|
|
85
162
|
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
163
|
+
const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
86
164
|
for (const c of tries) {
|
|
87
165
|
const t0 = Date.now();
|
|
166
|
+
const pending = this.provider(c.provider).create(spec);
|
|
167
|
+
let timedOut = false;
|
|
88
168
|
try {
|
|
89
|
-
const box = await
|
|
169
|
+
const box = await withDeadline(pending, limitS * 1000, () => {
|
|
170
|
+
timedOut = true;
|
|
171
|
+
return new ProviderError(c.provider, `create timed out after ${limitS}s`);
|
|
172
|
+
});
|
|
90
173
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
91
|
-
|
|
174
|
+
const sbx = new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd, this.ledger);
|
|
175
|
+
this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
|
|
176
|
+
return sbx;
|
|
92
177
|
}
|
|
93
178
|
catch (err) {
|
|
179
|
+
// A create that answers after we gave up still made a sandbox: record it, then kill it,
|
|
180
|
+
// so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
|
|
181
|
+
if (timedOut)
|
|
182
|
+
pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
|
|
94
183
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
184
|
+
const status = err instanceof ProviderError ? err.status : undefined;
|
|
185
|
+
if (route.fallback !== "any" && status !== undefined && SETUP_ERRORS.has(status)) {
|
|
186
|
+
throw new RouterError(`${err.message.replace(/[.\s]+$/, "")}. This looks like a setup problem with ${c.provider} (key, billing, image or request), ` +
|
|
187
|
+
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
188
|
+
}
|
|
95
189
|
}
|
|
96
190
|
}
|
|
97
191
|
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
98
192
|
}
|
|
99
|
-
|
|
193
|
+
record(sbx, usdHour, timeoutMinutes) {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
this.ledger?.open({
|
|
196
|
+
id: sbx.id,
|
|
197
|
+
provider: sbx.provider,
|
|
198
|
+
createdAt: new Date(now).toISOString(),
|
|
199
|
+
expiresAt: new Date(now + (timeoutMinutes ?? 30) * 60_000).toISOString(),
|
|
200
|
+
usdHour,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
discard(provider, box, usdHour, timeoutMinutes) {
|
|
204
|
+
const sbx = new RoutedSandbox(provider, box, [], usdHour, this.ledger);
|
|
205
|
+
this.record(sbx, usdHour, timeoutMinutes);
|
|
206
|
+
return sbx.kill().catch(() => { });
|
|
207
|
+
}
|
|
208
|
+
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
100
209
|
computerProviders() {
|
|
101
|
-
|
|
210
|
+
const fromSandboxes = [...this.providers.values()].filter((p) => "createComputer" in p);
|
|
211
|
+
return [...fromSandboxes, ...this.computerOnly];
|
|
212
|
+
}
|
|
213
|
+
/** The order a computer create would try for this OS. */
|
|
214
|
+
planComputer(os = "linux", opts = {}) {
|
|
215
|
+
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
216
|
+
return planComputer(this.computerProviders(), os, prefer);
|
|
102
217
|
}
|
|
103
|
-
/**
|
|
218
|
+
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
104
219
|
computer(spec = {}, opts = {}) {
|
|
105
220
|
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
106
221
|
return createComputer(this.computerProviders(), spec, prefer);
|
|
107
222
|
}
|
|
108
223
|
async getComputer(id) {
|
|
109
|
-
const
|
|
224
|
+
const i = id.indexOf(":");
|
|
225
|
+
if (i <= 0)
|
|
226
|
+
throw new RouterError(`bad computer id "${id}": expected provider:id`);
|
|
227
|
+
const provider = id.slice(0, i);
|
|
110
228
|
const p = this.computerProviders().find((c) => c.id === provider);
|
|
111
229
|
if (!p)
|
|
112
230
|
throw new RouterError(`${provider} does not offer computers`);
|
|
113
|
-
return new RoutedComputer(provider, await p.connectComputer(
|
|
231
|
+
return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)));
|
|
114
232
|
}
|
|
115
233
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
116
234
|
async get(id) {
|
|
117
235
|
const { provider, nativeId } = parseId(id);
|
|
118
236
|
const box = await this.provider(provider).connect(nativeId);
|
|
119
|
-
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)));
|
|
237
|
+
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)), this.ledger);
|
|
120
238
|
}
|
|
121
239
|
}
|
|
122
240
|
export class RoutedSandbox {
|
|
@@ -124,22 +242,31 @@ export class RoutedSandbox {
|
|
|
124
242
|
box;
|
|
125
243
|
attempts;
|
|
126
244
|
estimatedHourlyUsd;
|
|
245
|
+
ledger;
|
|
127
246
|
/** Stable id across processes: `provider:nativeId`. */
|
|
128
247
|
id;
|
|
129
248
|
constructor(provider, box,
|
|
130
249
|
/** Every create attempt, including failed fallbacks. */
|
|
131
|
-
attempts, estimatedHourlyUsd) {
|
|
250
|
+
attempts, estimatedHourlyUsd, ledger) {
|
|
132
251
|
this.provider = provider;
|
|
133
252
|
this.box = box;
|
|
134
253
|
this.attempts = attempts;
|
|
135
254
|
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
255
|
+
this.ledger = ledger;
|
|
136
256
|
this.id = `${provider}:${box.nativeId}`;
|
|
137
257
|
}
|
|
138
258
|
get nativeId() {
|
|
139
259
|
return this.box.nativeId;
|
|
140
260
|
}
|
|
141
|
-
|
|
142
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
263
|
+
* `ProviderError("<provider>: command timed out after Ns")`. Should an adapter fail to notice its
|
|
264
|
+
* own deadline, the router gives up `EXEC_GRACE_S` later with the same error, so callers can
|
|
265
|
+
* always rely on the timeout being bounded and the message being the same on every provider.
|
|
266
|
+
*/
|
|
267
|
+
exec(cmd, opts = {}) {
|
|
268
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
269
|
+
return withDeadline(this.box.exec(cmd, opts), (seconds + EXEC_GRACE_S) * 1000, () => timeoutError(this.provider, seconds));
|
|
143
270
|
}
|
|
144
271
|
writeFile(path, content) {
|
|
145
272
|
return this.box.writeFile(path, content);
|
|
@@ -150,8 +277,9 @@ export class RoutedSandbox {
|
|
|
150
277
|
async readText(path) {
|
|
151
278
|
return new TextDecoder().decode(await this.box.readFile(path));
|
|
152
279
|
}
|
|
153
|
-
kill() {
|
|
154
|
-
|
|
280
|
+
async kill() {
|
|
281
|
+
await this.box.kill();
|
|
282
|
+
this.ledger?.close(this.id);
|
|
155
283
|
}
|
|
156
284
|
/** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
|
|
157
285
|
async pause() {
|
|
@@ -178,3 +306,24 @@ export function parseId(id) {
|
|
|
178
306
|
function round(n) {
|
|
179
307
|
return Math.round(n * 10000) / 10000;
|
|
180
308
|
}
|
|
309
|
+
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
310
|
+
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
311
|
+
/** HTTP statuses that mean the request or the account is wrong, not that the provider is down. */
|
|
312
|
+
const SETUP_ERRORS = new Set([400, 401, 402, 403, 404, 422]);
|
|
313
|
+
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
314
|
+
export const EXEC_GRACE_S = 30;
|
|
315
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
316
|
+
async function withDeadline(work, ms, onExpire) {
|
|
317
|
+
let timer;
|
|
318
|
+
const expired = new Promise((_, reject) => {
|
|
319
|
+
timer = setTimeout(() => reject(onExpire()), ms);
|
|
320
|
+
timer.unref();
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
return await Promise.race([work, expired]);
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
work.catch(() => { });
|
|
328
|
+
}
|
|
329
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Router } from "./router.js";
|
|
|
6
6
|
*
|
|
7
7
|
* GET /v1/providers
|
|
8
8
|
* POST /v1/route dry run: which providers would be tried, in order
|
|
9
|
-
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require }
|
|
9
|
+
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require, createTimeoutSeconds }
|
|
10
10
|
* POST /v1/sandboxes/:id/exec { cmd, cwd, env, timeoutSeconds, stream }
|
|
11
11
|
* stream: true answers text/event-stream: `stdout`/`stderr` events (JSON string chunks),
|
|
12
12
|
* then one `result` event (the ExecResult), or an `error` event
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
import { CATALOG, hourlyCost } from "./catalog.js";
|
|
3
4
|
import { Router } from "./router.js";
|
|
@@ -8,7 +9,7 @@ import { ProviderError, RouterError } from "./types.js";
|
|
|
8
9
|
*
|
|
9
10
|
* GET /v1/providers
|
|
10
11
|
* POST /v1/route dry run: which providers would be tried, in order
|
|
11
|
-
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require }
|
|
12
|
+
* POST /v1/sandboxes { vcpu, memoryGb, ..., provider, strategy, fallback, require, createTimeoutSeconds }
|
|
12
13
|
* POST /v1/sandboxes/:id/exec { cmd, cwd, env, timeoutSeconds, stream }
|
|
13
14
|
* stream: true answers text/event-stream: `stdout`/`stderr` events (JSON string chunks),
|
|
14
15
|
* then one `result` event (the ExecResult), or an `error` event
|
|
@@ -31,6 +32,19 @@ import { ProviderError, RouterError } from "./types.js";
|
|
|
31
32
|
export function createApp(router = new Router(), token = process.env.SANDBOXROUTER_TOKEN,
|
|
32
33
|
/** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
|
|
33
34
|
corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
35
|
+
// With CORS on, any page open in a browser on this machine can call the server (Chrome even
|
|
36
|
+
// lets a public page reach localhost, which the Private-Network header below allows), so it
|
|
37
|
+
// would start sandboxes on these provider keys. A token is required for that.
|
|
38
|
+
if (corsOrigins.length && !token) {
|
|
39
|
+
throw new Error("CORS is on but SANDBOXROUTER_TOKEN is not set: browser pages could start sandboxes on your keys. Set a token, or turn CORS off.");
|
|
40
|
+
}
|
|
41
|
+
const expected = token ? Buffer.from(`Bearer ${token}`) : undefined;
|
|
42
|
+
const authorized = (req) => {
|
|
43
|
+
if (!expected)
|
|
44
|
+
return true;
|
|
45
|
+
const got = Buffer.from(req.headers.authorization ?? "");
|
|
46
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
47
|
+
};
|
|
34
48
|
const live = new Map();
|
|
35
49
|
const cors = (req, res) => {
|
|
36
50
|
const origin = req.headers.origin;
|
|
@@ -57,7 +71,7 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
57
71
|
}
|
|
58
72
|
if (url.pathname === "/health")
|
|
59
73
|
return json(res, 200, { ok: true });
|
|
60
|
-
if (
|
|
74
|
+
if (!authorized(req))
|
|
61
75
|
return json(res, 401, { error: "unauthorized" });
|
|
62
76
|
if (parts[0] !== "v1")
|
|
63
77
|
return json(res, 404, { error: "not found" });
|
|
@@ -220,12 +234,17 @@ class HttpError extends Error {
|
|
|
220
234
|
this.status = status;
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
|
-
const ROUTE_KEYS = ["provider", "strategy", "fallback", "require"];
|
|
237
|
+
const ROUTE_KEYS = ["provider", "strategy", "fallback", "require", "createTimeoutSeconds"];
|
|
224
238
|
function splitBody(body) {
|
|
225
239
|
const route = {};
|
|
226
240
|
const spec = {};
|
|
227
241
|
for (const [k, v] of Object.entries(body))
|
|
228
242
|
(ROUTE_KEYS.includes(k) ? route : spec)[k] = v;
|
|
243
|
+
// A provider id the catalog does not know is the client's mistake, not a routing failure.
|
|
244
|
+
const ids = route.provider === undefined ? [] : [].concat(route.provider);
|
|
245
|
+
const unknown = ids.filter((id) => !CATALOG.some((c) => c.id === id));
|
|
246
|
+
if (unknown.length)
|
|
247
|
+
throw new HttpError(400, `unknown provider: ${unknown.join(", ")}`);
|
|
229
248
|
return { spec: spec, route: route };
|
|
230
249
|
}
|
|
231
250
|
const MAX_BODY = 100 * 1024 * 1024;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ProviderId = "maritime" | "e2b" | "daytona" | "modal" | "vercel" | "morph" | "sprites" | "blaxel" | "tensorlake" | "runloop";
|
|
1
|
+
export type ProviderId = "maritime" | "e2b" | "daytona" | "modal" | "vercel" | "morph" | "sprites" | "blaxel" | "tensorlake" | "runloop" | "hopx" | "cloudflare" | "northflank" | "boat" | "exedev" | "docker" | "novita" | "langsmith" | "alibaba" | "upstash" | "koyeb" | "freestyle" | "deno" | "beam" | "codesandbox" | "azure" | "agentcore" | "lambda" | "dedalus" | "agent37" | "superserve" | "instabox" | "openscale" | "sail";
|
|
2
2
|
/** Things a caller can require of a provider. The router skips providers that lack one. */
|
|
3
3
|
export type Capability = "gpu" | "pause" | "custom-image";
|
|
4
4
|
/** What the caller wants. Every field is optional; providers map it to their own knobs. */
|
|
@@ -21,14 +21,26 @@ export interface SandboxSpec {
|
|
|
21
21
|
/** Free-form tags stored on the sandbox where the provider supports it. */
|
|
22
22
|
metadata?: Record<string, string>;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* "cheapest" (and "default", the same thing): lowest list price for the spec, ties alphabetical.
|
|
26
|
+
* "fastest": lowest median cold start you measured with `bench`, then cheapest.
|
|
27
|
+
*/
|
|
24
28
|
export type Strategy = "default" | "cheapest" | "fastest";
|
|
25
29
|
export interface RouteOptions {
|
|
26
30
|
/** Pin to one provider, or give an ordered list to try. Omit to let the router choose. */
|
|
27
31
|
provider?: ProviderId | ProviderId[];
|
|
28
|
-
/** How to order candidates when no explicit list is given. Default:
|
|
32
|
+
/** How to order candidates when no explicit list is given. Default: cheapest. */
|
|
29
33
|
strategy?: Strategy;
|
|
30
|
-
/**
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* What to do when a create fails. `true` (default): try the next candidate when the provider
|
|
36
|
+
* looks unavailable (timeout, network error, 429, 5xx, or an error without a status), but stop on
|
|
37
|
+
* a setup error (400, 401, 402, 403, 404, 422: a bad key, no billing, a bad image or request), so
|
|
38
|
+
* a mistake isn't hidden by quietly moving the work to another, maybe pricier, provider.
|
|
39
|
+
* `"any"`: try the next candidate on every error. `false`: only the first candidate.
|
|
40
|
+
*/
|
|
41
|
+
fallback?: boolean | "any";
|
|
42
|
+
/** Seconds to wait for each provider's create before moving on (default 120). */
|
|
43
|
+
createTimeoutSeconds?: number;
|
|
32
44
|
/** Capabilities every candidate must have. */
|
|
33
45
|
require?: Capability[];
|
|
34
46
|
}
|
|
@@ -77,7 +89,7 @@ export declare class RouterError extends Error {
|
|
|
77
89
|
}
|
|
78
90
|
/** Thrown by adapters for HTTP failures so the router can report status codes. */
|
|
79
91
|
export declare class ProviderError extends Error {
|
|
80
|
-
readonly provider: ProviderId;
|
|
92
|
+
readonly provider: ProviderId | (string & {});
|
|
81
93
|
readonly status?: number | undefined;
|
|
82
|
-
constructor(provider: ProviderId, message: string, status?: number | undefined);
|
|
94
|
+
constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
|
|
83
95
|
}
|
package/dist/util.d.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
+
import { ProviderError, type ProviderId } from "./types.js";
|
|
2
|
+
/** Per-command timeout when ExecOptions.timeoutSeconds is not given. */
|
|
3
|
+
export declare const DEFAULT_EXEC_TIMEOUT_S = 60;
|
|
1
4
|
/** Single-quote a string for POSIX sh. */
|
|
2
5
|
export declare function shq(s: string): string;
|
|
3
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Build `cd dir && export VAR=x && { cmd }` for providers whose exec API lacks cwd/env.
|
|
8
|
+
*
|
|
9
|
+
* Every step is chained with `&&`, so a missing cwd stops the command from running somewhere
|
|
10
|
+
* else, and the shell exits with cd's status instead. The command itself is grouped so its own
|
|
11
|
+
* `;` or `||` can't escape the chain; the newline before `}` keeps a trailing `#` comment in the
|
|
12
|
+
* command from swallowing the brace.
|
|
13
|
+
*/
|
|
4
14
|
export declare function wrapCommand(cmd: string, opts?: {
|
|
5
15
|
cwd?: string;
|
|
6
16
|
env?: Record<string, string>;
|
|
7
17
|
}): string;
|
|
18
|
+
/** The one error every adapter throws when a command outlives ExecOptions.timeoutSeconds. */
|
|
19
|
+
export declare function timeoutError(provider: ProviderId | (string & {}), seconds: number): ProviderError;
|
|
20
|
+
/**
|
|
21
|
+
* True when an exit code is the one left behind by a timeout kill and the command really ran
|
|
22
|
+
* for the whole timeout: 124 from coreutils `timeout`, 137 after SIGKILL, 143 after SIGTERM, or a
|
|
23
|
+
* negative code (how some SDKs report a signal death, and what adapters use for "no exit code").
|
|
24
|
+
* The elapsed check keeps a command that itself exits 124 quickly from being reported as a timeout.
|
|
25
|
+
*/
|
|
26
|
+
export declare function killedByTimeout(exitCode: number, elapsedMs: number, seconds: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Shell text that runs `cmd` and kills it after `seconds`, for exec APIs that cannot stop a
|
|
29
|
+
* running command themselves. Prefers coreutils `timeout`, which stops the whole process group
|
|
30
|
+
* (exit 124, then SIGKILL 5 s later, exit 137). When the image has none, a plain-sh watchdog
|
|
31
|
+
* kills the command's shell and, where `pkill` exists, its children (exit 137). Callers detect
|
|
32
|
+
* the kill with `killedByTimeout`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function guardTimeout(cmd: string, seconds: number): string;
|
|
8
35
|
export declare function toBytes(content: string | Uint8Array): Uint8Array;
|
|
9
36
|
export declare function dirname(path: string): string;
|
|
10
37
|
/** Load an optional peer dependency with a helpful error. */
|
package/dist/util.js
CHANGED
|
@@ -1,19 +1,66 @@
|
|
|
1
|
+
import { ProviderError } from "./types.js";
|
|
2
|
+
/** Per-command timeout when ExecOptions.timeoutSeconds is not given. */
|
|
3
|
+
export const DEFAULT_EXEC_TIMEOUT_S = 60;
|
|
1
4
|
/** Single-quote a string for POSIX sh. */
|
|
2
5
|
export function shq(s) {
|
|
3
6
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
4
7
|
}
|
|
5
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Build `cd dir && export VAR=x && { cmd }` for providers whose exec API lacks cwd/env.
|
|
10
|
+
*
|
|
11
|
+
* Every step is chained with `&&`, so a missing cwd stops the command from running somewhere
|
|
12
|
+
* else, and the shell exits with cd's status instead. The command itself is grouped so its own
|
|
13
|
+
* `;` or `||` can't escape the chain; the newline before `}` keeps a trailing `#` comment in the
|
|
14
|
+
* command from swallowing the brace.
|
|
15
|
+
*/
|
|
6
16
|
export function wrapCommand(cmd, opts = {}) {
|
|
7
|
-
const
|
|
17
|
+
const prelude = [];
|
|
8
18
|
if (opts.cwd)
|
|
9
|
-
|
|
19
|
+
prelude.push(`cd ${shq(opts.cwd)}`);
|
|
10
20
|
for (const [k, v] of Object.entries(opts.env ?? {})) {
|
|
11
21
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
|
|
12
22
|
throw new Error(`invalid env var name: ${k}`);
|
|
13
|
-
|
|
23
|
+
prelude.push(`export ${k}=${shq(v)}`);
|
|
14
24
|
}
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
if (prelude.length === 0)
|
|
26
|
+
return cmd;
|
|
27
|
+
return `${prelude.join(" && ")} && {\n${cmd}\n}`;
|
|
28
|
+
}
|
|
29
|
+
/** The one error every adapter throws when a command outlives ExecOptions.timeoutSeconds. */
|
|
30
|
+
export function timeoutError(provider, seconds) {
|
|
31
|
+
return new ProviderError(provider, `command timed out after ${seconds}s`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* True when an exit code is the one left behind by a timeout kill and the command really ran
|
|
35
|
+
* for the whole timeout: 124 from coreutils `timeout`, 137 after SIGKILL, 143 after SIGTERM, or a
|
|
36
|
+
* negative code (how some SDKs report a signal death, and what adapters use for "no exit code").
|
|
37
|
+
* The elapsed check keeps a command that itself exits 124 quickly from being reported as a timeout.
|
|
38
|
+
*/
|
|
39
|
+
export function killedByTimeout(exitCode, elapsedMs, seconds) {
|
|
40
|
+
const killed = exitCode === 124 || exitCode === 137 || exitCode === 143 || exitCode < 0;
|
|
41
|
+
return killed && elapsedMs >= seconds * 1000 - TIMEOUT_SLACK_MS;
|
|
42
|
+
}
|
|
43
|
+
/** Clocks on the two sides of an exec call differ by a little; a kill this close to the deadline still counts. */
|
|
44
|
+
const TIMEOUT_SLACK_MS = 250;
|
|
45
|
+
/**
|
|
46
|
+
* Shell text that runs `cmd` and kills it after `seconds`, for exec APIs that cannot stop a
|
|
47
|
+
* running command themselves. Prefers coreutils `timeout`, which stops the whole process group
|
|
48
|
+
* (exit 124, then SIGKILL 5 s later, exit 137). When the image has none, a plain-sh watchdog
|
|
49
|
+
* kills the command's shell and, where `pkill` exists, its children (exit 137). Callers detect
|
|
50
|
+
* the kill with `killedByTimeout`.
|
|
51
|
+
*/
|
|
52
|
+
export function guardTimeout(cmd, seconds) {
|
|
53
|
+
const n = Math.max(1, Math.ceil(seconds));
|
|
54
|
+
const inner = shq(cmd);
|
|
55
|
+
return [
|
|
56
|
+
`if command -v timeout >/dev/null 2>&1; then exec timeout -k 5 ${n} sh -c ${inner}; fi`,
|
|
57
|
+
`sh -c ${inner} & p=$!`,
|
|
58
|
+
// Freeze the shell first so it can't run its next command while its children are being killed.
|
|
59
|
+
`( sleep ${n}; kill -STOP $p; command -v pkill >/dev/null 2>&1 && pkill -KILL -P $p .; kill -9 $p ) >/dev/null 2>&1 & w=$!`,
|
|
60
|
+
`wait $p; c=$?`,
|
|
61
|
+
`kill $w >/dev/null 2>&1`,
|
|
62
|
+
`exit $c`,
|
|
63
|
+
].join("\n");
|
|
17
64
|
}
|
|
18
65
|
export function toBytes(content) {
|
|
19
66
|
return typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
@@ -29,7 +76,9 @@ export async function optionalImport(pkg, provider) {
|
|
|
29
76
|
}
|
|
30
77
|
catch (err) {
|
|
31
78
|
const code = err.code;
|
|
32
|
-
|
|
79
|
+
// Bundlers and test runners (Vite, esbuild) report a missing package without Node's error code.
|
|
80
|
+
const missing = /Cannot find (module|package)|Could not resolve|Failed to (resolve|load url)/.test(String(err?.message));
|
|
81
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || missing) {
|
|
33
82
|
throw new Error(`${provider} needs the "${pkg}" package: npm i ${pkg}`);
|
|
34
83
|
}
|
|
35
84
|
throw err;
|