@sandboxrouter/sdk 0.2.0 → 0.3.1
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 +229 -50
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +450 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +171 -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 +28 -2
- package/dist/index.js +28 -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.js +8 -3
- package/dist/providers/cloudflare.js +8 -3
- 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.js +11 -5
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.js +17 -5
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.js +27 -18
- 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.js +11 -13
- 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 +41 -5
- package/dist/router.js +179 -23
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +13 -5
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +81 -5
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;
|
|
@@ -25,13 +39,24 @@ export declare class Router {
|
|
|
25
39
|
provider(id: ProviderId): Provider;
|
|
26
40
|
/** The ordered list of providers a create would try, with the reason any are skipped. */
|
|
27
41
|
plan(spec?: SandboxSpec, route?: RouteOptions): Candidate[];
|
|
42
|
+
/**
|
|
43
|
+
* Why a create has nowhere to go, leading with what the caller can fix: no keys at all, or why the
|
|
44
|
+
* providers they do have keys for were skipped.
|
|
45
|
+
*/
|
|
46
|
+
private whyNone;
|
|
28
47
|
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
29
48
|
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
|
|
30
|
-
|
|
49
|
+
private record;
|
|
50
|
+
private discard;
|
|
51
|
+
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
31
52
|
computerProviders(): ComputerProvider[];
|
|
32
|
-
/**
|
|
53
|
+
/** The order a computer create would try for this OS. */
|
|
54
|
+
planComputer(os?: ComputerOs, opts?: {
|
|
55
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
56
|
+
}): import("./computers.js").ComputerCandidate[];
|
|
57
|
+
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
33
58
|
computer(spec?: ComputerSpec, opts?: {
|
|
34
|
-
provider?:
|
|
59
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
35
60
|
}): Promise<RoutedComputer>;
|
|
36
61
|
getComputer(id: string): Promise<RoutedComputer>;
|
|
37
62
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
@@ -43,12 +68,19 @@ export declare class RoutedSandbox {
|
|
|
43
68
|
/** Every create attempt, including failed fallbacks. */
|
|
44
69
|
readonly attempts: Attempt[];
|
|
45
70
|
readonly estimatedHourlyUsd: number;
|
|
71
|
+
private ledger?;
|
|
46
72
|
/** Stable id across processes: `provider:nativeId`. */
|
|
47
73
|
readonly id: string;
|
|
48
74
|
constructor(provider: ProviderId, box: ProviderSandbox,
|
|
49
75
|
/** Every create attempt, including failed fallbacks. */
|
|
50
|
-
attempts: Attempt[], estimatedHourlyUsd: number);
|
|
76
|
+
attempts: Attempt[], estimatedHourlyUsd: number, ledger?: Ledger | undefined);
|
|
51
77
|
get nativeId(): string;
|
|
78
|
+
/**
|
|
79
|
+
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
80
|
+
* `ProviderError("<provider>: command timed out after Ns")`. Should an adapter fail to notice its
|
|
81
|
+
* own deadline, the router gives up `EXEC_GRACE_S` later with the same error, so callers can
|
|
82
|
+
* always rely on the timeout being bounded and the message being the same on every provider.
|
|
83
|
+
*/
|
|
52
84
|
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
53
85
|
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
54
86
|
readFile(path: string): Promise<Uint8Array>;
|
|
@@ -63,3 +95,7 @@ export declare function parseId(id: string): {
|
|
|
63
95
|
provider: ProviderId;
|
|
64
96
|
nativeId: string;
|
|
65
97
|
};
|
|
98
|
+
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
99
|
+
export declare const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
100
|
+
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
101
|
+
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";
|
|
@@ -17,7 +19,33 @@ import { CloudflareProvider } from "./providers/cloudflare.js";
|
|
|
17
19
|
import { NorthflankProvider } from "./providers/northflank.js";
|
|
18
20
|
import { BoatProvider } from "./providers/boat.js";
|
|
19
21
|
import { ExeDevProvider } from "./providers/exedev.js";
|
|
20
|
-
import {
|
|
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
|
+
}
|
|
21
49
|
export function defaultProviders() {
|
|
22
50
|
return [
|
|
23
51
|
new MaritimeProvider(),
|
|
@@ -35,15 +63,40 @@ export function defaultProviders() {
|
|
|
35
63
|
new NorthflankProvider(),
|
|
36
64
|
new BoatProvider(),
|
|
37
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(),
|
|
38
84
|
new DockerProvider(),
|
|
39
85
|
];
|
|
40
86
|
}
|
|
41
87
|
export class Router {
|
|
42
88
|
providers;
|
|
43
89
|
benchFile;
|
|
90
|
+
computerOnly;
|
|
91
|
+
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
92
|
+
ledger;
|
|
44
93
|
constructor(opts = {}) {
|
|
94
|
+
const file = opts.ledger === false ? null : (opts.ledger ?? defaultLedgerFile());
|
|
95
|
+
if (file)
|
|
96
|
+
this.ledger = new Ledger(file);
|
|
45
97
|
this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
|
|
46
98
|
this.benchFile = opts.benchFile;
|
|
99
|
+
this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
|
|
47
100
|
}
|
|
48
101
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
49
102
|
isConfigured(id) {
|
|
@@ -67,6 +120,9 @@ export class Router {
|
|
|
67
120
|
const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
|
|
68
121
|
const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
|
|
69
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" };
|
|
70
126
|
const p = this.providers.get(id);
|
|
71
127
|
const c = {
|
|
72
128
|
provider: id,
|
|
@@ -74,9 +130,11 @@ export class Router {
|
|
|
74
130
|
medianCreateMs: median(bench[id]?.createMs),
|
|
75
131
|
};
|
|
76
132
|
if (!p)
|
|
77
|
-
c.skip = "
|
|
78
|
-
else
|
|
79
|
-
|
|
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
|
+
}
|
|
80
138
|
return c;
|
|
81
139
|
});
|
|
82
140
|
// Neutral by construction: price (or measured speed) decides, ties break alphabetically by id.
|
|
@@ -92,50 +150,117 @@ export class Router {
|
|
|
92
150
|
}
|
|
93
151
|
return list;
|
|
94
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Why a create has nowhere to go, leading with what the caller can fix: no keys at all, or why the
|
|
155
|
+
* providers they do have keys for were skipped.
|
|
156
|
+
*/
|
|
157
|
+
whyNone(plan, explicit) {
|
|
158
|
+
if (plan.length === 0)
|
|
159
|
+
return "no provider can take this sandbox: none registered";
|
|
160
|
+
// Providers the caller named: say what each one needs.
|
|
161
|
+
if (explicit) {
|
|
162
|
+
const why = plan.map((c) => {
|
|
163
|
+
const vars = CATALOG.find((x) => x.id === c.provider)?.credentials.map((g) => g.join(" + ")).join(" or ");
|
|
164
|
+
return `${c.provider} (${c.skip}${vars && c.skip?.startsWith("not configured") ? `: set ${vars}` : ""})`;
|
|
165
|
+
});
|
|
166
|
+
return `no provider can take this sandbox: ${why.join(", ")}`;
|
|
167
|
+
}
|
|
168
|
+
const keyed = plan.filter((c) => this.isConfigured(c.provider));
|
|
169
|
+
if (keyed.length === 0)
|
|
170
|
+
return ("no provider keys found: set the API key for at least one provider (the README's Env table lists each provider's variables); " +
|
|
171
|
+
"`npx @sandboxrouter/sdk providers` shows which are set");
|
|
172
|
+
const rest = plan.length - keyed.length;
|
|
173
|
+
const small = keyed.filter((c) => c.skip?.startsWith("default image"));
|
|
174
|
+
return (`none of the providers you have keys for can take this sandbox: ${keyed.map((c) => `${c.provider} (${c.skip})`).join(", ")}` +
|
|
175
|
+
(rest ? `; ${rest} other provider${rest === 1 ? " has" : "s have"} no key set` : "") +
|
|
176
|
+
(small.length
|
|
177
|
+
? `. To use ${small.map((c) => c.provider).join(" or ")}, leave out vcpu and memoryGb to get the default image's size, ` +
|
|
178
|
+
`or pass image: { ${small[0].provider}: "<template>" } with a template built at the size you need`
|
|
179
|
+
: ""));
|
|
180
|
+
}
|
|
95
181
|
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
96
182
|
async create(spec = {}, route = {}) {
|
|
97
183
|
const plan = this.plan(spec, route);
|
|
98
184
|
const usable = plan.filter((c) => !c.skip);
|
|
99
|
-
if (usable.length === 0)
|
|
100
|
-
|
|
101
|
-
throw new RouterError(`no provider can take this sandbox: ${why || "none registered"}`);
|
|
102
|
-
}
|
|
185
|
+
if (usable.length === 0)
|
|
186
|
+
throw new RouterError(this.whyNone(plan, !!route.provider));
|
|
103
187
|
const attempts = [];
|
|
104
188
|
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
189
|
+
const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
105
190
|
for (const c of tries) {
|
|
106
191
|
const t0 = Date.now();
|
|
192
|
+
const pending = this.provider(c.provider).create(spec);
|
|
193
|
+
let timedOut = false;
|
|
107
194
|
try {
|
|
108
|
-
const box = await
|
|
195
|
+
const box = await withDeadline(pending, limitS * 1000, () => {
|
|
196
|
+
timedOut = true;
|
|
197
|
+
return new ProviderError(c.provider, `create timed out after ${limitS}s`);
|
|
198
|
+
});
|
|
109
199
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
110
|
-
|
|
200
|
+
const sbx = new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd, this.ledger);
|
|
201
|
+
this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
|
|
202
|
+
return sbx;
|
|
111
203
|
}
|
|
112
204
|
catch (err) {
|
|
205
|
+
// A create that answers after we gave up still made a sandbox: record it, then kill it,
|
|
206
|
+
// so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
|
|
207
|
+
if (timedOut)
|
|
208
|
+
pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
|
|
113
209
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
210
|
+
const status = err instanceof ProviderError ? err.status : undefined;
|
|
211
|
+
if (route.fallback !== "any" && status !== undefined && SETUP_ERRORS.has(status)) {
|
|
212
|
+
throw new RouterError(`${err.message.replace(/[.\s]+$/, "")}. This looks like a setup problem with ${c.provider} (key, billing, image or request), ` +
|
|
213
|
+
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
214
|
+
}
|
|
114
215
|
}
|
|
115
216
|
}
|
|
116
217
|
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
117
218
|
}
|
|
118
|
-
|
|
219
|
+
record(sbx, usdHour, timeoutMinutes) {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
this.ledger?.open({
|
|
222
|
+
id: sbx.id,
|
|
223
|
+
provider: sbx.provider,
|
|
224
|
+
createdAt: new Date(now).toISOString(),
|
|
225
|
+
expiresAt: new Date(now + (timeoutMinutes ?? 30) * 60_000).toISOString(),
|
|
226
|
+
usdHour,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
discard(provider, box, usdHour, timeoutMinutes) {
|
|
230
|
+
const sbx = new RoutedSandbox(provider, box, [], usdHour, this.ledger);
|
|
231
|
+
this.record(sbx, usdHour, timeoutMinutes);
|
|
232
|
+
return sbx.kill().catch(() => { });
|
|
233
|
+
}
|
|
234
|
+
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
119
235
|
computerProviders() {
|
|
120
|
-
|
|
236
|
+
const fromSandboxes = [...this.providers.values()].filter((p) => "createComputer" in p);
|
|
237
|
+
return [...fromSandboxes, ...this.computerOnly];
|
|
238
|
+
}
|
|
239
|
+
/** The order a computer create would try for this OS. */
|
|
240
|
+
planComputer(os = "linux", opts = {}) {
|
|
241
|
+
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
242
|
+
return planComputer(this.computerProviders(), os, prefer);
|
|
121
243
|
}
|
|
122
|
-
/**
|
|
244
|
+
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
123
245
|
computer(spec = {}, opts = {}) {
|
|
124
246
|
const prefer = opts.provider ? [].concat(opts.provider) : undefined;
|
|
125
247
|
return createComputer(this.computerProviders(), spec, prefer);
|
|
126
248
|
}
|
|
127
249
|
async getComputer(id) {
|
|
128
|
-
const
|
|
250
|
+
const i = id.indexOf(":");
|
|
251
|
+
if (i <= 0)
|
|
252
|
+
throw new RouterError(`bad computer id "${id}": expected provider:id`);
|
|
253
|
+
const provider = id.slice(0, i);
|
|
129
254
|
const p = this.computerProviders().find((c) => c.id === provider);
|
|
130
255
|
if (!p)
|
|
131
256
|
throw new RouterError(`${provider} does not offer computers`);
|
|
132
|
-
return new RoutedComputer(provider, await p.connectComputer(
|
|
257
|
+
return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)));
|
|
133
258
|
}
|
|
134
259
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
135
260
|
async get(id) {
|
|
136
261
|
const { provider, nativeId } = parseId(id);
|
|
137
262
|
const box = await this.provider(provider).connect(nativeId);
|
|
138
|
-
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)));
|
|
263
|
+
return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)), this.ledger);
|
|
139
264
|
}
|
|
140
265
|
}
|
|
141
266
|
export class RoutedSandbox {
|
|
@@ -143,22 +268,31 @@ export class RoutedSandbox {
|
|
|
143
268
|
box;
|
|
144
269
|
attempts;
|
|
145
270
|
estimatedHourlyUsd;
|
|
271
|
+
ledger;
|
|
146
272
|
/** Stable id across processes: `provider:nativeId`. */
|
|
147
273
|
id;
|
|
148
274
|
constructor(provider, box,
|
|
149
275
|
/** Every create attempt, including failed fallbacks. */
|
|
150
|
-
attempts, estimatedHourlyUsd) {
|
|
276
|
+
attempts, estimatedHourlyUsd, ledger) {
|
|
151
277
|
this.provider = provider;
|
|
152
278
|
this.box = box;
|
|
153
279
|
this.attempts = attempts;
|
|
154
280
|
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
281
|
+
this.ledger = ledger;
|
|
155
282
|
this.id = `${provider}:${box.nativeId}`;
|
|
156
283
|
}
|
|
157
284
|
get nativeId() {
|
|
158
285
|
return this.box.nativeId;
|
|
159
286
|
}
|
|
160
|
-
|
|
161
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
289
|
+
* `ProviderError("<provider>: command timed out after Ns")`. Should an adapter fail to notice its
|
|
290
|
+
* own deadline, the router gives up `EXEC_GRACE_S` later with the same error, so callers can
|
|
291
|
+
* always rely on the timeout being bounded and the message being the same on every provider.
|
|
292
|
+
*/
|
|
293
|
+
exec(cmd, opts = {}) {
|
|
294
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
295
|
+
return withDeadline(this.box.exec(cmd, opts), (seconds + EXEC_GRACE_S) * 1000, () => timeoutError(this.provider, seconds));
|
|
162
296
|
}
|
|
163
297
|
writeFile(path, content) {
|
|
164
298
|
return this.box.writeFile(path, content);
|
|
@@ -169,8 +303,9 @@ export class RoutedSandbox {
|
|
|
169
303
|
async readText(path) {
|
|
170
304
|
return new TextDecoder().decode(await this.box.readFile(path));
|
|
171
305
|
}
|
|
172
|
-
kill() {
|
|
173
|
-
|
|
306
|
+
async kill() {
|
|
307
|
+
await this.box.kill();
|
|
308
|
+
this.ledger?.close(this.id);
|
|
174
309
|
}
|
|
175
310
|
/** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
|
|
176
311
|
async pause() {
|
|
@@ -197,3 +332,24 @@ export function parseId(id) {
|
|
|
197
332
|
function round(n) {
|
|
198
333
|
return Math.round(n * 10000) / 10000;
|
|
199
334
|
}
|
|
335
|
+
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
336
|
+
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
337
|
+
/** HTTP statuses that mean the request or the account is wrong, not that the provider is down. */
|
|
338
|
+
const SETUP_ERRORS = new Set([400, 401, 402, 403, 404, 422]);
|
|
339
|
+
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
340
|
+
export const EXEC_GRACE_S = 30;
|
|
341
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
342
|
+
async function withDeadline(work, ms, onExpire) {
|
|
343
|
+
let timer;
|
|
344
|
+
const expired = new Promise((_, reject) => {
|
|
345
|
+
timer = setTimeout(() => reject(onExpire()), ms);
|
|
346
|
+
timer.unref();
|
|
347
|
+
});
|
|
348
|
+
try {
|
|
349
|
+
return await Promise.race([work, expired]);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
work.catch(() => { });
|
|
354
|
+
}
|
|
355
|
+
}
|
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" | "hopx" | "cloudflare" | "northflank" | "boat" | "exedev" | "docker";
|
|
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. */
|
|
@@ -31,8 +31,16 @@ export interface RouteOptions {
|
|
|
31
31
|
provider?: ProviderId | ProviderId[];
|
|
32
32
|
/** How to order candidates when no explicit list is given. Default: cheapest. */
|
|
33
33
|
strategy?: Strategy;
|
|
34
|
-
/**
|
|
35
|
-
|
|
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;
|
|
36
44
|
/** Capabilities every candidate must have. */
|
|
37
45
|
require?: Capability[];
|
|
38
46
|
}
|
|
@@ -81,7 +89,7 @@ export declare class RouterError extends Error {
|
|
|
81
89
|
}
|
|
82
90
|
/** Thrown by adapters for HTTP failures so the router can report status codes. */
|
|
83
91
|
export declare class ProviderError extends Error {
|
|
84
|
-
readonly provider: ProviderId;
|
|
92
|
+
readonly provider: ProviderId | (string & {});
|
|
85
93
|
readonly status?: number | undefined;
|
|
86
|
-
constructor(provider: ProviderId, message: string, status?: number | undefined);
|
|
94
|
+
constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
|
|
87
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;
|