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.
Files changed (105) hide show
  1. package/DAYTONA.md +129 -0
  2. package/E2B.md +146 -0
  3. package/README.md +84 -12
  4. package/VERCEL.md +127 -0
  5. package/dist/ai/index.d.ts +33 -0
  6. package/dist/ai/index.js +38 -0
  7. package/dist/claude-agent-sdk/index.d.ts +7 -0
  8. package/dist/claude-agent-sdk/index.js +65 -0
  9. package/dist/cli.d.ts +6 -0
  10. package/dist/cli.js +949 -105
  11. package/dist/client.d.ts +25 -2
  12. package/dist/client.js +14 -1
  13. package/dist/credentials.d.ts +10 -0
  14. package/dist/credentials.js +56 -1
  15. package/dist/daytona/client.d.ts +44 -0
  16. package/dist/daytona/client.js +40 -0
  17. package/dist/daytona/context.d.ts +26 -0
  18. package/dist/daytona/context.js +22 -0
  19. package/dist/daytona/daytona.d.ts +185 -0
  20. package/dist/daytona/daytona.js +416 -0
  21. package/dist/daytona/errors.d.ts +106 -0
  22. package/dist/daytona/errors.js +182 -0
  23. package/dist/daytona/filesystem.d.ts +84 -0
  24. package/dist/daytona/filesystem.js +206 -0
  25. package/dist/daytona/git.d.ts +33 -0
  26. package/dist/daytona/git.js +128 -0
  27. package/dist/daytona/image.d.ts +45 -0
  28. package/dist/daytona/image.js +140 -0
  29. package/dist/daytona/index.d.ts +50 -0
  30. package/dist/daytona/index.js +50 -0
  31. package/dist/daytona/process.d.ts +86 -0
  32. package/dist/daytona/process.js +339 -0
  33. package/dist/daytona/sandbox.d.ts +196 -0
  34. package/dist/daytona/sandbox.js +448 -0
  35. package/dist/e2b/client.d.ts +58 -0
  36. package/dist/e2b/client.js +66 -0
  37. package/dist/e2b/code-interpreter.d.ts +144 -0
  38. package/dist/e2b/code-interpreter.js +244 -0
  39. package/dist/e2b/commands.d.ts +100 -0
  40. package/dist/e2b/commands.js +268 -0
  41. package/dist/e2b/errors.d.ts +72 -0
  42. package/dist/e2b/errors.js +151 -0
  43. package/dist/e2b/filesystem.d.ts +82 -0
  44. package/dist/e2b/filesystem.js +143 -0
  45. package/dist/e2b/index.d.ts +28 -0
  46. package/dist/e2b/index.js +61 -0
  47. package/dist/e2b/sandbox.d.ts +203 -0
  48. package/dist/e2b/sandbox.js +490 -0
  49. package/dist/e2b/unsupported.d.ts +19 -0
  50. package/dist/e2b/unsupported.js +27 -0
  51. package/dist/errors.d.ts +10 -4
  52. package/dist/errors.js +26 -6
  53. package/dist/image-context.d.ts +39 -0
  54. package/dist/image-context.js +176 -0
  55. package/dist/index.d.ts +19 -2
  56. package/dist/index.js +17 -2
  57. package/dist/keys.d.ts +43 -0
  58. package/dist/keys.js +196 -0
  59. package/dist/login.js +27 -6
  60. package/dist/mcp.d.ts +2 -2
  61. package/dist/mcp.js +4 -3
  62. package/dist/openai-agents/index.d.ts +132 -0
  63. package/dist/openai-agents/index.js +791 -0
  64. package/dist/products/audit.d.ts +48 -0
  65. package/dist/products/audit.js +10 -0
  66. package/dist/products/images.d.ts +113 -7
  67. package/dist/products/images.js +186 -27
  68. package/dist/products/index.d.ts +11 -0
  69. package/dist/products/index.js +17 -1
  70. package/dist/products/observability.d.ts +186 -0
  71. package/dist/products/observability.js +117 -0
  72. package/dist/products/secrets.d.ts +40 -0
  73. package/dist/products/secrets.js +24 -0
  74. package/dist/products/switching.d.ts +89 -0
  75. package/dist/products/switching.js +25 -0
  76. package/dist/proxy.d.ts +30 -0
  77. package/dist/proxy.js +178 -0
  78. package/dist/sandbox.d.ts +30 -2
  79. package/dist/sandbox.js +101 -1
  80. package/dist/ssh.d.ts +21 -0
  81. package/dist/ssh.js +312 -0
  82. package/dist/tar.d.ts +1 -0
  83. package/dist/tar.js +4 -4
  84. package/dist/tools/index.d.ts +62 -0
  85. package/dist/tools/index.js +180 -0
  86. package/dist/transport.d.ts +12 -2
  87. package/dist/transport.js +76 -16
  88. package/dist/tunnel.d.ts +67 -0
  89. package/dist/tunnel.js +311 -0
  90. package/dist/types.d.ts +57 -1
  91. package/dist/vercel/client.d.ts +40 -0
  92. package/dist/vercel/client.js +39 -0
  93. package/dist/vercel/command.d.ts +70 -0
  94. package/dist/vercel/command.js +148 -0
  95. package/dist/vercel/errors.d.ts +53 -0
  96. package/dist/vercel/errors.js +113 -0
  97. package/dist/vercel/filesystem.d.ts +150 -0
  98. package/dist/vercel/filesystem.js +248 -0
  99. package/dist/vercel/index.d.ts +30 -0
  100. package/dist/vercel/index.js +38 -0
  101. package/dist/vercel/sandbox.d.ts +317 -0
  102. package/dist/vercel/sandbox.js +726 -0
  103. package/dist/vercel/snapshot.d.ts +93 -0
  104. package/dist/vercel/snapshot.js +123 -0
  105. package/package.json +69 -5
package/dist/transport.js CHANGED
@@ -1,5 +1,6 @@
1
- import { ConnectionError, errorFor, RuntimeError } from "./errors.js";
2
- export const VERSION = "0.3.1";
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
- this.#fetch = options.fetch ?? fetch;
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
- const deadline = AbortSignal.timeout(call.timeoutMs ?? this.#timeoutMs);
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
- response = await this.#fetch(url, {
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: "error",
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
- if (signal.aborted || !retry || attempt >= this.#maxRetries)
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
- const Socket = globalThis.WebSocket;
243
- if (!Socket)
244
- throw new Error("This runtime has no WebSocket; use Node 22+ or Bun.");
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
+ }
@@ -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's id (runtime.images.build); the sandbox starts from it. */
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
+ };