withruntime 0.3.1 → 0.5.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/DAYTONA.md +129 -0
- package/E2B.md +146 -0
- package/README.md +84 -12
- package/VERCEL.md +127 -0
- package/dist/ai/index.d.ts +33 -0
- package/dist/ai/index.js +38 -0
- package/dist/claude-agent-sdk/index.d.ts +7 -0
- package/dist/claude-agent-sdk/index.js +65 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +949 -105
- package/dist/client.d.ts +25 -2
- package/dist/client.js +14 -1
- package/dist/credentials.d.ts +10 -0
- package/dist/credentials.js +56 -1
- package/dist/daytona/client.d.ts +44 -0
- package/dist/daytona/client.js +40 -0
- package/dist/daytona/context.d.ts +26 -0
- package/dist/daytona/context.js +22 -0
- package/dist/daytona/daytona.d.ts +185 -0
- package/dist/daytona/daytona.js +416 -0
- package/dist/daytona/errors.d.ts +106 -0
- package/dist/daytona/errors.js +182 -0
- package/dist/daytona/filesystem.d.ts +84 -0
- package/dist/daytona/filesystem.js +206 -0
- package/dist/daytona/git.d.ts +33 -0
- package/dist/daytona/git.js +128 -0
- package/dist/daytona/image.d.ts +45 -0
- package/dist/daytona/image.js +140 -0
- package/dist/daytona/index.d.ts +50 -0
- package/dist/daytona/index.js +50 -0
- package/dist/daytona/process.d.ts +86 -0
- package/dist/daytona/process.js +339 -0
- package/dist/daytona/sandbox.d.ts +196 -0
- package/dist/daytona/sandbox.js +448 -0
- package/dist/e2b/client.d.ts +58 -0
- package/dist/e2b/client.js +66 -0
- package/dist/e2b/code-interpreter.d.ts +144 -0
- package/dist/e2b/code-interpreter.js +244 -0
- package/dist/e2b/commands.d.ts +100 -0
- package/dist/e2b/commands.js +268 -0
- package/dist/e2b/errors.d.ts +72 -0
- package/dist/e2b/errors.js +151 -0
- package/dist/e2b/filesystem.d.ts +82 -0
- package/dist/e2b/filesystem.js +143 -0
- package/dist/e2b/index.d.ts +28 -0
- package/dist/e2b/index.js +61 -0
- package/dist/e2b/sandbox.d.ts +203 -0
- package/dist/e2b/sandbox.js +490 -0
- package/dist/e2b/unsupported.d.ts +19 -0
- package/dist/e2b/unsupported.js +27 -0
- package/dist/errors.d.ts +10 -4
- package/dist/errors.js +26 -6
- package/dist/image-context.d.ts +39 -0
- package/dist/image-context.js +176 -0
- package/dist/index.d.ts +19 -2
- package/dist/index.js +17 -2
- package/dist/keys.d.ts +43 -0
- package/dist/keys.js +196 -0
- package/dist/login.js +27 -6
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +4 -3
- package/dist/openai-agents/index.d.ts +132 -0
- package/dist/openai-agents/index.js +791 -0
- package/dist/products/audit.d.ts +48 -0
- package/dist/products/audit.js +10 -0
- package/dist/products/images.d.ts +113 -7
- package/dist/products/images.js +186 -27
- package/dist/products/index.d.ts +11 -0
- package/dist/products/index.js +17 -1
- package/dist/products/observability.d.ts +186 -0
- package/dist/products/observability.js +117 -0
- package/dist/products/secrets.d.ts +40 -0
- package/dist/products/secrets.js +24 -0
- package/dist/products/switching.d.ts +89 -0
- package/dist/products/switching.js +25 -0
- package/dist/proxy.d.ts +30 -0
- package/dist/proxy.js +178 -0
- package/dist/sandbox.d.ts +30 -2
- package/dist/sandbox.js +101 -1
- package/dist/ssh.d.ts +21 -0
- package/dist/ssh.js +312 -0
- package/dist/tar.d.ts +1 -0
- package/dist/tar.js +4 -4
- package/dist/tools/index.d.ts +62 -0
- package/dist/tools/index.js +180 -0
- package/dist/transport.d.ts +12 -2
- package/dist/transport.js +76 -16
- package/dist/tunnel.d.ts +67 -0
- package/dist/tunnel.js +311 -0
- package/dist/types.d.ts +57 -1
- package/dist/vercel/client.d.ts +40 -0
- package/dist/vercel/client.js +39 -0
- package/dist/vercel/command.d.ts +70 -0
- package/dist/vercel/command.js +148 -0
- package/dist/vercel/errors.d.ts +53 -0
- package/dist/vercel/errors.js +113 -0
- package/dist/vercel/filesystem.d.ts +150 -0
- package/dist/vercel/filesystem.js +248 -0
- package/dist/vercel/index.d.ts +30 -0
- package/dist/vercel/index.js +38 -0
- package/dist/vercel/sandbox.d.ts +317 -0
- package/dist/vercel/sandbox.js +726 -0
- package/dist/vercel/snapshot.d.ts +93 -0
- package/dist/vercel/snapshot.js +123 -0
- package/package.json +69 -5
package/dist/transport.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { ConnectionError, errorFor, RuntimeError } from "./errors.js";
|
|
2
|
-
|
|
1
|
+
import { ConnectionError, errorFor, RuntimeError, WAITS_FOR_ROOM } from "./errors.js";
|
|
2
|
+
import { describeRoute, envFetch, openWebSocket } from "./proxy.js";
|
|
3
|
+
export const VERSION = "0.5.0";
|
|
3
4
|
export const DEFAULT_BASE_URL = "https://api.withruntime.com";
|
|
4
5
|
export function apiOrigin(value) {
|
|
5
6
|
const url = new URL(value);
|
|
@@ -83,10 +84,14 @@ export class Transport {
|
|
|
83
84
|
#apiKey;
|
|
84
85
|
#findKey;
|
|
85
86
|
#fetch;
|
|
87
|
+
/** Whether calls go through envFetch, so a failure can name its proxy. */
|
|
88
|
+
#routed;
|
|
86
89
|
#timeoutMs;
|
|
87
90
|
#maxRetries;
|
|
88
91
|
#client;
|
|
89
92
|
#slots;
|
|
93
|
+
/** What `sandboxes.create` waits for room by default. */
|
|
94
|
+
waitForCapacityMs;
|
|
90
95
|
constructor(options) {
|
|
91
96
|
if (typeof options.apiKey === "function")
|
|
92
97
|
this.#findKey = options.apiKey;
|
|
@@ -95,11 +100,15 @@ export class Transport {
|
|
|
95
100
|
else
|
|
96
101
|
this.#apiKey = options.apiKey;
|
|
97
102
|
this.baseUrl = apiOrigin(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
98
|
-
|
|
103
|
+
// The environment's proxy (HTTPS_PROXY, NO_PROXY) on Node and Bun alike;
|
|
104
|
+
// a fetch passed in is used as it is.
|
|
105
|
+
this.#fetch = options.fetch ?? envFetch;
|
|
106
|
+
this.#routed = options.fetch === undefined;
|
|
99
107
|
this.#timeoutMs = options.timeoutMs ?? 300_000;
|
|
100
108
|
this.#maxRetries = options.maxRetries ?? 4;
|
|
101
109
|
this.#client = options.client ?? `sdk-js/${VERSION}`;
|
|
102
110
|
this.#slots = slots(Math.max(1, options.maxConnections ?? 32));
|
|
111
|
+
this.waitForCapacityMs = Math.max(0, options.waitForCapacityMs ?? 120_000);
|
|
103
112
|
}
|
|
104
113
|
/** The key, found once on first use when it was not given. */
|
|
105
114
|
async #key() {
|
|
@@ -127,48 +136,83 @@ export class Transport {
|
|
|
127
136
|
}
|
|
128
137
|
/** Sends a call and returns the raw Response once it succeeded. Retries
|
|
129
138
|
* transport failures, 429, 502, 503 and 504 with backoff and jitter, with
|
|
130
|
-
* the same key every time
|
|
139
|
+
* the same key every time, and waits out a full house for a call that sets
|
|
140
|
+
* waitForCapacityMs. */
|
|
131
141
|
async send(call) {
|
|
132
142
|
const apiKey = await this.#key();
|
|
133
143
|
const write = call.method !== "GET";
|
|
134
144
|
const key = write ? (call.idempotencyKey ?? crypto.randomUUID()) : undefined;
|
|
135
|
-
|
|
145
|
+
// Time spent waiting for room is added to the call's deadline, not taken
|
|
146
|
+
// from it: once admitted, a create still has its whole timeoutMs.
|
|
147
|
+
const roomMs = Math.max(0, call.waitForCapacityMs ?? 0);
|
|
148
|
+
const roomUntil = performance.now() + roomMs;
|
|
149
|
+
let roomAttempt = 0;
|
|
150
|
+
const deadline = AbortSignal.timeout((call.timeoutMs ?? this.#timeoutMs) + roomMs);
|
|
136
151
|
const signal = call.signal ? AbortSignal.any([call.signal, deadline]) : deadline;
|
|
137
152
|
const url = `${this.baseUrl}${call.path}${encodeQuery(call.query)}`;
|
|
138
153
|
const body = call.bytes ?? (call.body === undefined ? undefined : JSON.stringify(call.body));
|
|
139
154
|
const retry = call.retry ?? true;
|
|
140
155
|
for (let attempt = 0;; attempt++) {
|
|
141
156
|
let response;
|
|
157
|
+
// Called as a plain function: Cloudflare Workers' fetch throws "Illegal
|
|
158
|
+
// invocation" when called as a method of another object (this.#fetch()).
|
|
159
|
+
const send = this.#fetch;
|
|
142
160
|
try {
|
|
143
|
-
|
|
161
|
+
// "manual", not "error": Workers accept only "follow" and "manual".
|
|
162
|
+
// The API never redirects, so a redirect is refused here exactly as
|
|
163
|
+
// "error" refused it: thrown, retried, then a connection_error.
|
|
164
|
+
response = await send(url, {
|
|
144
165
|
method: call.method,
|
|
145
166
|
headers: this.#headers(call, key, apiKey),
|
|
146
|
-
redirect: "
|
|
167
|
+
redirect: "manual",
|
|
147
168
|
signal,
|
|
148
169
|
...(body === undefined ? {} : { body: body }),
|
|
149
170
|
});
|
|
171
|
+
if (response.type === "opaqueredirect" ||
|
|
172
|
+
(response.status >= 300 && response.status < 400)) {
|
|
173
|
+
await response.body?.cancel().catch(() => { });
|
|
174
|
+
throw new TypeError(`fetch failed: Runtime answered a redirect (${response.status}).`);
|
|
175
|
+
}
|
|
150
176
|
}
|
|
151
177
|
catch (cause) {
|
|
152
|
-
|
|
178
|
+
// A proxy variable the SDK cannot use: retrying cannot change it.
|
|
179
|
+
if (cause instanceof RuntimeError)
|
|
180
|
+
throw cause;
|
|
181
|
+
if (signal.aborted || !retry || attempt >= this.#maxRetries) {
|
|
182
|
+
const route = this.#routed && !signal.aborted ? describeRoute(url, cause) : { via: "" };
|
|
153
183
|
throw new ConnectionError({
|
|
154
184
|
message: signal.aborted
|
|
155
185
|
? "The call was cancelled or ran past its deadline."
|
|
156
186
|
: write
|
|
157
|
-
? `No answer from Runtime at ${this.baseUrl}. The change may have happened; retrying with the same idempotencyKey is safe.`
|
|
158
|
-
: `No answer from Runtime at ${this.baseUrl}.`,
|
|
187
|
+
? `No answer from Runtime at ${this.baseUrl}${route.via}. The change may have happened; retrying with the same idempotencyKey is safe.`
|
|
188
|
+
: `No answer from Runtime at ${this.baseUrl}${route.via}.`,
|
|
159
189
|
hint: signal.aborted
|
|
160
190
|
? "Raise timeoutMs for a long call, or retry with the same idempotencyKey."
|
|
161
|
-
: "Check the network, and RUNTIME_API_URL if you set it.",
|
|
191
|
+
: (route.hint ?? "Check the network, and RUNTIME_API_URL if you set it."),
|
|
162
192
|
code: signal.aborted ? "timeout" : "connection_error",
|
|
163
193
|
status: 0,
|
|
164
194
|
...(key ? { idempotencyKey: key } : {}),
|
|
165
195
|
cause,
|
|
166
196
|
});
|
|
197
|
+
}
|
|
167
198
|
await sleep(backoff(attempt), signal);
|
|
168
199
|
continue;
|
|
169
200
|
}
|
|
170
201
|
if (response.ok)
|
|
171
202
|
return response;
|
|
203
|
+
// Runtime never answers 407: it is a proxy refusing the tunnel, which
|
|
204
|
+
// Bun's fetch hands back as a response rather than an error.
|
|
205
|
+
if (response.status === 407 && this.#routed) {
|
|
206
|
+
await response.body?.cancel();
|
|
207
|
+
const route = describeRoute(url, 407);
|
|
208
|
+
throw new ConnectionError({
|
|
209
|
+
message: `No answer from Runtime at ${this.baseUrl}${route.via}.`,
|
|
210
|
+
code: "connection_error",
|
|
211
|
+
status: 0,
|
|
212
|
+
...(route.hint ? { hint: route.hint } : {}),
|
|
213
|
+
...(key ? { idempotencyKey: key } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
172
216
|
const text = await response.text();
|
|
173
217
|
let parsed = null;
|
|
174
218
|
try {
|
|
@@ -178,6 +222,19 @@ export class Transport {
|
|
|
178
222
|
parsed = { error: { message: text.slice(0, 500) || `HTTP ${response.status}` } };
|
|
179
223
|
}
|
|
180
224
|
const error = errorFor(response.status, parsed, key);
|
|
225
|
+
// A full house: wait for a slot or for room, then send the same call
|
|
226
|
+
// again. A request that can never fit (a fork of more copies than the
|
|
227
|
+
// trial runs) is not waited for.
|
|
228
|
+
if (retry &&
|
|
229
|
+
roomMs > 0 &&
|
|
230
|
+
WAITS_FOR_ROOM.has(error.code) &&
|
|
231
|
+
error.details?.field !== "count") {
|
|
232
|
+
const left = roomUntil - performance.now();
|
|
233
|
+
if (left <= 0)
|
|
234
|
+
throw error;
|
|
235
|
+
await sleep(Math.min(left, error.retryAfterMs ?? roomBackoff(roomAttempt++)), signal);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
181
238
|
const retryable = [429, 502, 503, 504].includes(response.status) && !DELIBERATE.has(error.code);
|
|
182
239
|
if (!retry || !retryable || attempt >= this.#maxRetries)
|
|
183
240
|
throw error;
|
|
@@ -239,11 +296,9 @@ export class Transport {
|
|
|
239
296
|
async websocket(path, query) {
|
|
240
297
|
const apiKey = await this.#key();
|
|
241
298
|
const url = `${this.baseUrl.replace(/^http/, "ws")}${path}${encodeQuery(query)}`;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return new Socket(url, {
|
|
246
|
-
headers: { authorization: `Bearer ${apiKey}`, "x-runtime-client": this.#client },
|
|
299
|
+
return openWebSocket(url, {
|
|
300
|
+
authorization: `Bearer ${apiKey}`,
|
|
301
|
+
"x-runtime-client": this.#client,
|
|
247
302
|
});
|
|
248
303
|
}
|
|
249
304
|
}
|
|
@@ -259,3 +314,8 @@ export function missingKey() {
|
|
|
259
314
|
function backoff(attempt) {
|
|
260
315
|
return Math.min(8000, 250 * 2 ** attempt) * (0.5 + Math.random());
|
|
261
316
|
}
|
|
317
|
+
/** Between tries for room: 0.5 s, 1 s, 2 s, 4 s, then every 8 s, jittered, so
|
|
318
|
+
* a queue of CI jobs spreads out instead of knocking at once. */
|
|
319
|
+
function roomBackoff(attempt) {
|
|
320
|
+
return Math.min(8000, 500 * 2 ** attempt) * (0.75 + Math.random() * 0.5);
|
|
321
|
+
}
|
package/dist/tunnel.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** One TCP connection (or SSH login) inside a tunnel. Set the handlers before
|
|
2
|
+
* data can arrive, which is as soon as the promise that gave you this resolves. */
|
|
3
|
+
export declare class TunnelStream {
|
|
4
|
+
#private;
|
|
5
|
+
private readonly tunnel;
|
|
6
|
+
readonly id: number;
|
|
7
|
+
/** Bytes from the sandbox side. */
|
|
8
|
+
onData?: (data: Uint8Array) => void;
|
|
9
|
+
/** The sandbox side will send nothing more; you may still write. */
|
|
10
|
+
onEnd?: () => void;
|
|
11
|
+
/** The connection is gone. `reason` is "done" after a clean finish. */
|
|
12
|
+
onClose?: (reason: string) => void;
|
|
13
|
+
/** sshd's own error output, for an SSH login that fails. */
|
|
14
|
+
onLog?: (text: string) => void;
|
|
15
|
+
constructor(tunnel: Tunnel, id: number);
|
|
16
|
+
/** Sends bytes. Resolves once they are within the tunnel's window, so
|
|
17
|
+
* awaiting each write is enough backpressure. */
|
|
18
|
+
write(data: Uint8Array): Promise<void>;
|
|
19
|
+
/** No more bytes from this side; the sandbox side reads end of input. */
|
|
20
|
+
end(): void;
|
|
21
|
+
/** Drops the connection at once. After both sides have ended, the sandbox
|
|
22
|
+
* finishes it by itself, so a close then would only race the last bytes. */
|
|
23
|
+
close(): void;
|
|
24
|
+
/** @internal */
|
|
25
|
+
receive(kind: number, payload: Uint8Array): void;
|
|
26
|
+
/** @internal */
|
|
27
|
+
gone(reason: string): void;
|
|
28
|
+
}
|
|
29
|
+
/** A local port forwarded into the sandbox. */
|
|
30
|
+
export type PortForward = {
|
|
31
|
+
/** The local address and port that forward (the port the system chose, if you passed 0). */
|
|
32
|
+
readonly host: string;
|
|
33
|
+
readonly localPort: number;
|
|
34
|
+
/** The port inside the sandbox. */
|
|
35
|
+
readonly port: number;
|
|
36
|
+
/** Stops listening and closes every forwarded connection. */
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
export declare class Tunnel {
|
|
40
|
+
#private;
|
|
41
|
+
private readonly socket;
|
|
42
|
+
readonly closed: Promise<string>;
|
|
43
|
+
private constructor();
|
|
44
|
+
/** Waits for the server's ready message. */
|
|
45
|
+
static open(socket: WebSocket): Promise<Tunnel>;
|
|
46
|
+
/** A TCP connection to `port` on the sandbox's loopback (127.0.0.1, then ::1). */
|
|
47
|
+
connect(port: number): Promise<TunnelStream>;
|
|
48
|
+
/** An SSH login as the sandbox's user, authorized by this one public key
|
|
49
|
+
* (the text of an OpenSSH `.pub` file). Speak SSH over the stream. */
|
|
50
|
+
ssh(publicKey: string): Promise<TunnelStream>;
|
|
51
|
+
/** Listens on a local port and carries each connection to `port` in the
|
|
52
|
+
* sandbox. Node and Bun only. */
|
|
53
|
+
forward(port: number, options?: {
|
|
54
|
+
localPort?: number;
|
|
55
|
+
host?: string;
|
|
56
|
+
}): Promise<PortForward>;
|
|
57
|
+
/** Closes the tunnel and every connection in it. */
|
|
58
|
+
close(): void;
|
|
59
|
+
/** @internal Data for one stream, within the window. */
|
|
60
|
+
send(id: number, data: Uint8Array): Promise<void>;
|
|
61
|
+
/** @internal End or close, in order behind any data still waiting. */
|
|
62
|
+
control(kind: number, id: number): Promise<void>;
|
|
63
|
+
/** @internal */
|
|
64
|
+
forget(id: number): void;
|
|
65
|
+
private receive;
|
|
66
|
+
private end;
|
|
67
|
+
}
|
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { RuntimeError } from "./errors.js";
|
|
2
|
+
/* A tunnel into a sandbox: one WebSocket carrying many TCP connections to
|
|
3
|
+
ports on the sandbox's loopback, and SSH logins. The server end and the
|
|
4
|
+
protocol are documented in packages/cloud/src/api/tunnels.ts. */
|
|
5
|
+
const OPEN = 0x6f;
|
|
6
|
+
const DATA = 0x64;
|
|
7
|
+
const END = 0x65;
|
|
8
|
+
const CLOSE = 0x63;
|
|
9
|
+
const ACK = 0x61;
|
|
10
|
+
const LOG = 0x6c;
|
|
11
|
+
/** Data this side may send past the sandbox's last report of what it took. */
|
|
12
|
+
const WINDOW = 1_048_576;
|
|
13
|
+
const CHUNK = 65_536;
|
|
14
|
+
function frame(kind, stream, payload = new Uint8Array()) {
|
|
15
|
+
const out = new Uint8Array(5 + payload.length);
|
|
16
|
+
out[0] = kind;
|
|
17
|
+
new DataView(out.buffer).setUint32(1, stream);
|
|
18
|
+
out.set(payload, 5);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** One TCP connection (or SSH login) inside a tunnel. Set the handlers before
|
|
22
|
+
* data can arrive, which is as soon as the promise that gave you this resolves. */
|
|
23
|
+
export class TunnelStream {
|
|
24
|
+
tunnel;
|
|
25
|
+
id;
|
|
26
|
+
/** Bytes from the sandbox side. */
|
|
27
|
+
onData;
|
|
28
|
+
/** The sandbox side will send nothing more; you may still write. */
|
|
29
|
+
onEnd;
|
|
30
|
+
/** The connection is gone. `reason` is "done" after a clean finish. */
|
|
31
|
+
onClose;
|
|
32
|
+
/** sshd's own error output, for an SSH login that fails. */
|
|
33
|
+
onLog;
|
|
34
|
+
#ended = false;
|
|
35
|
+
#remoteEnded = false;
|
|
36
|
+
#closed = false;
|
|
37
|
+
constructor(tunnel, id) {
|
|
38
|
+
this.tunnel = tunnel;
|
|
39
|
+
this.id = id;
|
|
40
|
+
}
|
|
41
|
+
/** Sends bytes. Resolves once they are within the tunnel's window, so
|
|
42
|
+
* awaiting each write is enough backpressure. */
|
|
43
|
+
write(data) {
|
|
44
|
+
if (this.#ended || this.#closed)
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
return this.tunnel.send(this.id, data);
|
|
47
|
+
}
|
|
48
|
+
/** No more bytes from this side; the sandbox side reads end of input. */
|
|
49
|
+
end() {
|
|
50
|
+
if (this.#ended || this.#closed)
|
|
51
|
+
return;
|
|
52
|
+
this.#ended = true;
|
|
53
|
+
void this.tunnel.control(END, this.id);
|
|
54
|
+
}
|
|
55
|
+
/** Drops the connection at once. After both sides have ended, the sandbox
|
|
56
|
+
* finishes it by itself, so a close then would only race the last bytes. */
|
|
57
|
+
close() {
|
|
58
|
+
if (this.#closed)
|
|
59
|
+
return;
|
|
60
|
+
const finishing = this.#ended && this.#remoteEnded;
|
|
61
|
+
this.#closed = true;
|
|
62
|
+
if (!finishing)
|
|
63
|
+
void this.tunnel.control(CLOSE, this.id);
|
|
64
|
+
this.tunnel.forget(this.id);
|
|
65
|
+
}
|
|
66
|
+
/** @internal */
|
|
67
|
+
receive(kind, payload) {
|
|
68
|
+
if (kind === DATA)
|
|
69
|
+
this.onData?.(payload);
|
|
70
|
+
else if (kind === LOG)
|
|
71
|
+
this.onLog?.(new TextDecoder().decode(payload));
|
|
72
|
+
else if (kind === END) {
|
|
73
|
+
this.#remoteEnded = true;
|
|
74
|
+
this.onEnd?.();
|
|
75
|
+
}
|
|
76
|
+
else if (kind === CLOSE)
|
|
77
|
+
this.gone(new TextDecoder().decode(payload) || "closed");
|
|
78
|
+
}
|
|
79
|
+
/** @internal */
|
|
80
|
+
gone(reason) {
|
|
81
|
+
if (this.#closed)
|
|
82
|
+
return;
|
|
83
|
+
this.#closed = true;
|
|
84
|
+
this.tunnel.forget(this.id);
|
|
85
|
+
this.onClose?.(reason);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export class Tunnel {
|
|
89
|
+
socket;
|
|
90
|
+
closed;
|
|
91
|
+
#streams = new Map();
|
|
92
|
+
#opening = new Map();
|
|
93
|
+
#next = 1;
|
|
94
|
+
#sent = 0;
|
|
95
|
+
#credited = 0;
|
|
96
|
+
#waiting = [];
|
|
97
|
+
#sending = Promise.resolve();
|
|
98
|
+
#over;
|
|
99
|
+
constructor(socket, closed) {
|
|
100
|
+
this.socket = socket;
|
|
101
|
+
this.closed = closed;
|
|
102
|
+
}
|
|
103
|
+
/** Waits for the server's ready message. */
|
|
104
|
+
static open(socket) {
|
|
105
|
+
socket.binaryType = "arraybuffer";
|
|
106
|
+
let settle;
|
|
107
|
+
const closed = new Promise((resolve) => (settle = resolve));
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
let tunnel;
|
|
110
|
+
let failure;
|
|
111
|
+
socket.onmessage = (event) => {
|
|
112
|
+
if (typeof event.data === "string") {
|
|
113
|
+
const message = JSON.parse(event.data);
|
|
114
|
+
if (message.type === "ready" && !tunnel) {
|
|
115
|
+
tunnel = new Tunnel(socket, closed);
|
|
116
|
+
resolve(tunnel);
|
|
117
|
+
}
|
|
118
|
+
else if (message.type === "error") {
|
|
119
|
+
failure = new RuntimeError({
|
|
120
|
+
message: message.error?.message ?? "The tunnel failed.",
|
|
121
|
+
code: message.error?.code ?? "tunnel_failed",
|
|
122
|
+
status: 0,
|
|
123
|
+
...(message.error?.hint ? { hint: message.error.hint } : {}),
|
|
124
|
+
...(message.error?.requestId ? { requestId: message.error.requestId } : {}),
|
|
125
|
+
});
|
|
126
|
+
if (!tunnel)
|
|
127
|
+
reject(failure);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
tunnel?.receive(new Uint8Array(event.data));
|
|
132
|
+
};
|
|
133
|
+
socket.onclose = () => {
|
|
134
|
+
const reason = failure?.message ?? "The tunnel closed.";
|
|
135
|
+
tunnel?.end(reason);
|
|
136
|
+
settle(reason);
|
|
137
|
+
if (!tunnel)
|
|
138
|
+
reject(new RuntimeError({
|
|
139
|
+
message: "The tunnel could not be opened: check the key, that the sandbox is running, and that your organization has fewer than 16 tunnels open.",
|
|
140
|
+
code: "tunnel_refused",
|
|
141
|
+
status: 0,
|
|
142
|
+
}));
|
|
143
|
+
};
|
|
144
|
+
socket.onerror = () => undefined;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** A TCP connection to `port` on the sandbox's loopback (127.0.0.1, then ::1). */
|
|
148
|
+
connect(port) {
|
|
149
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
150
|
+
return Promise.reject(new RuntimeError({
|
|
151
|
+
message: "port must be 1 to 65535.",
|
|
152
|
+
code: "invalid_request",
|
|
153
|
+
status: 0,
|
|
154
|
+
}));
|
|
155
|
+
return this.#open(`tcp ${port}`, port);
|
|
156
|
+
}
|
|
157
|
+
/** An SSH login as the sandbox's user, authorized by this one public key
|
|
158
|
+
* (the text of an OpenSSH `.pub` file). Speak SSH over the stream. */
|
|
159
|
+
ssh(publicKey) {
|
|
160
|
+
return this.#open(`ssh ${publicKey.trim()}`);
|
|
161
|
+
}
|
|
162
|
+
/** Listens on a local port and carries each connection to `port` in the
|
|
163
|
+
* sandbox. Node and Bun only. */
|
|
164
|
+
async forward(port, options = {}) {
|
|
165
|
+
const net = await import("node:net");
|
|
166
|
+
const host = options.host ?? "127.0.0.1";
|
|
167
|
+
const sockets = new Set();
|
|
168
|
+
// Half-open: a client that ends its input still gets the answer.
|
|
169
|
+
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
|
|
170
|
+
sockets.add(socket);
|
|
171
|
+
socket.pause();
|
|
172
|
+
socket.on("error", () => undefined);
|
|
173
|
+
socket.on("close", () => sockets.delete(socket));
|
|
174
|
+
this.connect(port).then((stream) => {
|
|
175
|
+
stream.onData = (data) => void socket.write(data);
|
|
176
|
+
stream.onEnd = () => void socket.end();
|
|
177
|
+
// "done" follows the last data; ending lets the socket flush it.
|
|
178
|
+
stream.onClose = (reason) => void (reason === "done" ? socket.end() : socket.destroy());
|
|
179
|
+
socket.on("data", (data) => {
|
|
180
|
+
socket.pause();
|
|
181
|
+
void stream.write(new Uint8Array(data)).then(() => socket.resume());
|
|
182
|
+
});
|
|
183
|
+
socket.on("end", () => stream.end());
|
|
184
|
+
socket.on("close", () => stream.close());
|
|
185
|
+
socket.resume();
|
|
186
|
+
}, () => socket.destroy());
|
|
187
|
+
});
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
server.once("error", reject);
|
|
190
|
+
server.listen(options.localPort ?? port, host, () => resolve());
|
|
191
|
+
});
|
|
192
|
+
const address = server.address();
|
|
193
|
+
return {
|
|
194
|
+
host,
|
|
195
|
+
localPort: typeof address === "object" && address ? address.port : (options.localPort ?? port),
|
|
196
|
+
port,
|
|
197
|
+
close: () => new Promise((resolve) => {
|
|
198
|
+
for (const socket of sockets)
|
|
199
|
+
socket.destroy();
|
|
200
|
+
server.close(() => resolve());
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Closes the tunnel and every connection in it. */
|
|
205
|
+
close() {
|
|
206
|
+
this.socket.close(1000);
|
|
207
|
+
}
|
|
208
|
+
#open(target, port) {
|
|
209
|
+
if (this.#over)
|
|
210
|
+
return Promise.reject(new RuntimeError({ message: this.#over, code: "tunnel_closed", status: 0 }));
|
|
211
|
+
const id = this.#next++;
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
this.#opening.set(id, {
|
|
214
|
+
resolve,
|
|
215
|
+
reject: (error) => reject(port !== undefined && error.message === "closed"
|
|
216
|
+
? new RuntimeError({
|
|
217
|
+
message: `Nothing is listening on port ${port} in the sandbox.`,
|
|
218
|
+
code: "port_closed",
|
|
219
|
+
status: 0,
|
|
220
|
+
hint: "Start the server first, and let it listen on 127.0.0.1, ::1 or every address.",
|
|
221
|
+
})
|
|
222
|
+
: error.message === "reserved"
|
|
223
|
+
? new RuntimeError({
|
|
224
|
+
message: `Port ${port} is the sandbox's own outbound proxy, which a tunnel does not reach.`,
|
|
225
|
+
code: "port_reserved",
|
|
226
|
+
status: 0,
|
|
227
|
+
})
|
|
228
|
+
: error.message === "no-sshd"
|
|
229
|
+
? new RuntimeError({
|
|
230
|
+
message: "The sandbox has no OpenSSH server (sshd).",
|
|
231
|
+
code: "sshd_missing",
|
|
232
|
+
status: 0,
|
|
233
|
+
hint: "Install it once: sudo apt-get install -y openssh-server",
|
|
234
|
+
})
|
|
235
|
+
: new RuntimeError({
|
|
236
|
+
message: `The sandbox refused the connection (${error.message}).`,
|
|
237
|
+
code: "tunnel_refused",
|
|
238
|
+
status: 0,
|
|
239
|
+
})),
|
|
240
|
+
});
|
|
241
|
+
this.#write(frame(OPEN, id, new TextEncoder().encode(target)));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/** @internal Data for one stream, within the window. */
|
|
245
|
+
send(id, data) {
|
|
246
|
+
const sending = this.#sending.then(async () => {
|
|
247
|
+
for (let at = 0; at < data.length && !this.#over; at += CHUNK) {
|
|
248
|
+
const piece = data.subarray(at, at + CHUNK);
|
|
249
|
+
while (this.#sent + piece.length - this.#credited > WINDOW && !this.#over)
|
|
250
|
+
await new Promise((resolve) => this.#waiting.push(resolve));
|
|
251
|
+
if (this.#over)
|
|
252
|
+
return;
|
|
253
|
+
this.#sent += piece.length;
|
|
254
|
+
this.#write(frame(DATA, id, piece));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
this.#sending = sending.catch(() => undefined);
|
|
258
|
+
return sending;
|
|
259
|
+
}
|
|
260
|
+
/** @internal End or close, in order behind any data still waiting. */
|
|
261
|
+
control(kind, id) {
|
|
262
|
+
const sending = this.#sending.then(() => this.#write(frame(kind, id)));
|
|
263
|
+
this.#sending = sending.catch(() => undefined);
|
|
264
|
+
return sending;
|
|
265
|
+
}
|
|
266
|
+
/** @internal */
|
|
267
|
+
forget(id) {
|
|
268
|
+
this.#streams.delete(id);
|
|
269
|
+
}
|
|
270
|
+
#write(bytes) {
|
|
271
|
+
if (!this.#over && this.socket.readyState === 1)
|
|
272
|
+
this.socket.send(bytes);
|
|
273
|
+
}
|
|
274
|
+
receive(message) {
|
|
275
|
+
if (message.length < 5)
|
|
276
|
+
return;
|
|
277
|
+
const kind = message[0];
|
|
278
|
+
const view = new DataView(message.buffer, message.byteOffset, message.length);
|
|
279
|
+
const id = view.getUint32(1);
|
|
280
|
+
const payload = message.subarray(5);
|
|
281
|
+
if (kind === ACK) {
|
|
282
|
+
this.#credited = Math.max(this.#credited, Number(view.getBigUint64(5)));
|
|
283
|
+
for (const wake of this.#waiting.splice(0))
|
|
284
|
+
wake();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const opening = this.#opening.get(id);
|
|
288
|
+
if (opening) {
|
|
289
|
+
this.#opening.delete(id);
|
|
290
|
+
if (kind === OPEN) {
|
|
291
|
+
const stream = new TunnelStream(this, id);
|
|
292
|
+
this.#streams.set(id, stream);
|
|
293
|
+
opening.resolve(stream);
|
|
294
|
+
}
|
|
295
|
+
else
|
|
296
|
+
opening.reject(new Error(new TextDecoder().decode(payload) || "closed"));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.#streams.get(id)?.receive(kind, payload);
|
|
300
|
+
}
|
|
301
|
+
end(reason) {
|
|
302
|
+
this.#over = reason;
|
|
303
|
+
for (const wake of this.#waiting.splice(0))
|
|
304
|
+
wake();
|
|
305
|
+
for (const { reject } of this.#opening.values())
|
|
306
|
+
reject(new Error(reason));
|
|
307
|
+
this.#opening.clear();
|
|
308
|
+
for (const stream of [...this.#streams.values()])
|
|
309
|
+
stream.gone(reason);
|
|
310
|
+
}
|
|
311
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -16,6 +16,16 @@ export type SandboxInfo = {
|
|
|
16
16
|
cpuFloorMillis: number;
|
|
17
17
|
pausable: boolean;
|
|
18
18
|
timeoutSeconds: number;
|
|
19
|
+
/** Pauses after this many idle seconds; 0 is never. */
|
|
20
|
+
idlePauseSeconds?: number;
|
|
21
|
+
/** True for the default idle pause, which pauses only a sandbox nothing has used yet. */
|
|
22
|
+
idlePauseUnusedOnly?: boolean;
|
|
23
|
+
/** A request (exec, files, terminal, a visit to a shared port) wakes it when paused. */
|
|
24
|
+
autoWake?: boolean;
|
|
25
|
+
/** Its lease renews itself while credit lasts, and its disk is kept after a stop. */
|
|
26
|
+
persistent?: boolean;
|
|
27
|
+
/** The last exec, file, terminal, desktop or preview request, to within a minute. */
|
|
28
|
+
lastActiveAt?: string | null;
|
|
19
29
|
onLeaseEnd: "pause" | "stop";
|
|
20
30
|
createdAt: string;
|
|
21
31
|
readyAt: string | null;
|
|
@@ -28,6 +38,16 @@ export type SandboxInfo = {
|
|
|
28
38
|
heldMicros: number;
|
|
29
39
|
simulated?: boolean;
|
|
30
40
|
replayed?: boolean;
|
|
41
|
+
/** On a create from an image with a start command: whether it started,
|
|
42
|
+
* became ready (readyMs), timed out, or exited first. */
|
|
43
|
+
start?: {
|
|
44
|
+
state: "started" | "ready" | "timeout" | "exited" | "not_running";
|
|
45
|
+
processId?: string;
|
|
46
|
+
exitCode?: number | null;
|
|
47
|
+
readyMs?: number;
|
|
48
|
+
};
|
|
49
|
+
/** Present and true when getOrCreate answered a sandbox that already held the name. */
|
|
50
|
+
reused?: boolean;
|
|
31
51
|
[key: string]: unknown;
|
|
32
52
|
};
|
|
33
53
|
export type CreateSandbox = {
|
|
@@ -46,6 +66,18 @@ export type CreateSandbox = {
|
|
|
46
66
|
pausable?: boolean;
|
|
47
67
|
/** What happens when timeoutSeconds runs out: "pause" (default) or "stop". */
|
|
48
68
|
onLeaseEnd?: "pause" | "stop";
|
|
69
|
+
/** Pause after this many seconds with no exec, file, terminal, desktop or
|
|
70
|
+
* preview request (60 to 86400; 0 never). A request wakes it again. */
|
|
71
|
+
idlePauseSeconds?: number;
|
|
72
|
+
/** A request to a paused sandbox wakes it. Default true. */
|
|
73
|
+
autoWake?: boolean;
|
|
74
|
+
/** Keep it running while credit lasts (its lease renews itself) and keep its
|
|
75
|
+
* disk after a stop, for restart(). Paid only. */
|
|
76
|
+
persistent?: boolean;
|
|
77
|
+
/** The most it may cost over its whole life, in microdollars. */
|
|
78
|
+
maxTotalCostMicros?: number;
|
|
79
|
+
/** With name: return the sandbox that already has the name, woken if paused. */
|
|
80
|
+
getOrCreate?: boolean;
|
|
49
81
|
maxCostMicros?: number;
|
|
50
82
|
/** Network rules from the first start; the same shape as sandbox.network.set.
|
|
51
83
|
* Omit for the public web on ports 80 and 443. */
|
|
@@ -55,7 +87,9 @@ export type CreateSandbox = {
|
|
|
55
87
|
deny?: string[];
|
|
56
88
|
connect?: string[];
|
|
57
89
|
};
|
|
58
|
-
/** A ready image
|
|
90
|
+
/** A ready image (runtime.images.build): its id, name (its latest tag),
|
|
91
|
+
* name:tag or name@version. When the image has a start command, create
|
|
92
|
+
* answers once its ready check passes and says how in `start`. */
|
|
59
93
|
image?: string;
|
|
60
94
|
/** A ready snapshot's id: the sandbox starts as a copy of it, with its shape. Not with image. */
|
|
61
95
|
snapshot?: string;
|
|
@@ -173,3 +207,25 @@ export type Usage = {
|
|
|
173
207
|
[key: string]: unknown;
|
|
174
208
|
};
|
|
175
209
|
export type FeedbackKind = "bug" | "missing_feature" | "competitor_gap" | "migration_blocker" | "docs" | "pricing" | "praise" | "other";
|
|
210
|
+
/** What `sandbox.update()` changes; fields left out stay as they are. */
|
|
211
|
+
export type SandboxSettings = {
|
|
212
|
+
name?: string;
|
|
213
|
+
labels?: Record<string, string>;
|
|
214
|
+
/** A request to a paused sandbox wakes it. */
|
|
215
|
+
autoWake?: boolean;
|
|
216
|
+
/** Pause after this many seconds with no activity, counted from now; 0 never. */
|
|
217
|
+
idlePauseSeconds?: number;
|
|
218
|
+
/** Keep it running while credit lasts and keep its disk after a stop. Paid only. */
|
|
219
|
+
persistent?: boolean;
|
|
220
|
+
/** Lifetime cap in microdollars; null removes it. */
|
|
221
|
+
maxTotalCostMicros?: number | null;
|
|
222
|
+
};
|
|
223
|
+
/** How `sandbox.keepAlive()` extends the lease. */
|
|
224
|
+
export type KeepAliveOptions = {
|
|
225
|
+
/** How often it checks, in seconds. Default 60. */
|
|
226
|
+
everySeconds?: number;
|
|
227
|
+
/** How much lease it keeps ahead of now, in seconds (60 to 3600). Default 600. */
|
|
228
|
+
marginSeconds?: number;
|
|
229
|
+
/** Called with an error an extension met; the loop carries on. */
|
|
230
|
+
onError?: (error: unknown) => void;
|
|
231
|
+
};
|