sandbox-as-a-service 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandbox as a Service
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # sandbox-as-a-service
2
+
3
+ A zero-dependency JavaScript/TypeScript client for
4
+ [Sandbox as a Service](https://sandbox-as-a-service.com) — secure, disposable
5
+ cloud sandboxes for AI agents and code execution. Each sandbox is a dedicated
6
+ virtual machine, created with one call and destroyed when you are done.
7
+
8
+ - Node.js 18+ (uses the built-in `fetch`; nothing to install alongside it)
9
+ - ESM and CommonJS, with hand-written TypeScript definitions
10
+ - Streaming command output via Server-Sent Events
11
+ - Typed errors for every failure category
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install sandbox-as-a-service
17
+ ```
18
+
19
+ ## Quickstart
20
+
21
+ ESM:
22
+
23
+ ```js
24
+ import { Client } from 'sandbox-as-a-service';
25
+
26
+ const client = new Client(); // reads AAS_API_KEY from the environment
27
+
28
+ const sandbox = await client.createSandbox({ size: 'small', timeoutMinutes: 10 });
29
+ try {
30
+ const result = await sandbox.exec('python3 -c "print(6 * 7)"');
31
+ console.log(result.stdout); // 42
32
+
33
+ await sandbox.writeFile('/workspace/app.py', "print('hello from the sandbox')");
34
+ console.log((await sandbox.readFile('/workspace/app.py')).content);
35
+ } finally {
36
+ await sandbox.destroy();
37
+ }
38
+ ```
39
+
40
+ TypeScript:
41
+
42
+ ```ts
43
+ import { Client, ExecutionFailed } from 'sandbox-as-a-service';
44
+
45
+ const client = new Client(); // reads AAS_API_KEY from the environment
46
+ const sandbox = await client.createSandbox({ size: 'small', timeoutMinutes: 10 });
47
+ try {
48
+ const result = await sandbox.exec('python3 -m pytest -q', { timeoutMs: 300000, cwd: '/workspace' });
49
+ result.check(); // throws ExecutionFailed on a non-zero exit code
50
+ } catch (err) {
51
+ if (err instanceof ExecutionFailed) console.error(err.execution.stderr);
52
+ } finally {
53
+ await sandbox.destroy();
54
+ }
55
+ ```
56
+
57
+ CommonJS works the same way: `const { Client } = require('sandbox-as-a-service');`
58
+
59
+ On Node.js 22+ you can let the scope destroy the sandbox for you:
60
+
61
+ ```js
62
+ const client = new Client();
63
+ await using sandbox = await client.createSandbox({ size: 'small', timeoutMinutes: 10 });
64
+ const result = await sandbox.exec('echo hello');
65
+ // the sandbox is destroyed here
66
+ ```
67
+
68
+ Set `AAS_API_KEY` from [Dashboard → API keys](https://sandbox-as-a-service.com/dashboard/keys) and
69
+ the client picks it up. Pass `new Client({ apiKey, baseUrl, timeoutMs })` to override; a bare
70
+ `baseUrl` gets `/v1` appended. Creating a sandbox blocks until the machine is ready.
71
+
72
+ ## Streaming output
73
+
74
+ Pass `onStdout` or `onStderr` and the same call streams: the callbacks fire
75
+ as the sandbox produces output, and the return value is the same `Execution`
76
+ a blocking call returns.
77
+
78
+ ```js
79
+ const result = await sandbox.exec(
80
+ 'for i in 1 2 3; do echo tick $i; sleep 1; done',
81
+ {
82
+ onStdout: (chunk) => process.stdout.write(chunk),
83
+ onStderr: (chunk) => process.stderr.write(chunk),
84
+ },
85
+ );
86
+ // callbacks fire as output arrives; result is the usual Execution object
87
+ ```
88
+
89
+ Closing the connection mid-stream — the process exiting, Ctrl-C — kills the
90
+ remote command and records it as `cancelled`.
91
+
92
+ ## Files
93
+
94
+ ```js
95
+ await sandbox.writeFile('/workspace/app.py', "print('hello')");
96
+ await sandbox.writeFile('/workspace/blob.bin', base64, { encoding: 'base64' });
97
+
98
+ const file = await sandbox.readFile('/workspace/app.py');
99
+ console.log(file.content, file.sizeBytes); // .text and .bytes are aliases
100
+
101
+ const listing = await sandbox.listFiles('/workspace'); // entries: name, type, size_bytes
102
+ console.log(listing.names());
103
+
104
+ await sandbox.deleteFile('/workspace/app.py'); // { recursive: true } for a directory tree
105
+ ```
106
+
107
+ ## Ports
108
+
109
+ ```js
110
+ const preview = await sandbox.exposePort(8000); // { url, port, ... }
111
+ const open = await sandbox.listPorts();
112
+ await sandbox.closePort(8000);
113
+ ```
114
+
115
+ ## Errors
116
+
117
+ Non-2xx responses raise a specific error, so a caller can react to the reason
118
+ rather than parse a status code. Every `SandboxApiError` carries `status`,
119
+ `type`, `requestId` and the decoded `responseBody`; quote the request id in a
120
+ support request.
121
+
122
+ ```js
123
+ import { Client, NotFoundError, RateLimitError } from 'sandbox-as-a-service';
124
+
125
+ const client = new Client();
126
+
127
+ try {
128
+ const sandbox = await client.getSandbox('sbx_does_not_exist');
129
+ } catch (err) {
130
+ if (err instanceof NotFoundError) console.log('gone');
131
+ if (err instanceof RateLimitError) console.log('slow down, retry after', err.retryAfter, 'seconds');
132
+ }
133
+ ```
134
+
135
+ | Error | Raised when |
136
+ | --- | --- |
137
+ | `AuthenticationError` | The key is missing, malformed or revoked (401). |
138
+ | `PermissionDeniedError` | The key is valid but not allowed to do this (403). |
139
+ | `NotFoundError` | No such sandbox, file or execution (404). |
140
+ | `InvalidRequestError` | The request body or parameters were rejected (400); a 422 maps to the base `SandboxApiError`. |
141
+ | `ConflictError` | The sandbox is in a state that forbids the operation (409). |
142
+ | `PaymentRequiredError` | The account has no credit left (402). |
143
+ | `RateLimitError` | A rate limit was hit (429); `retryAfter` is set when the header is present. |
144
+ | `ServiceUnavailableError` | A transient server or upstream failure (503). |
145
+ | `SandboxConnectionError` | The request never reached the API — DNS, TLS or timeout. |
146
+ | `SandboxConfigurationError` | The client was constructed with something it cannot use. |
147
+
148
+ ## What the client covers
149
+
150
+ - `client.createSandbox({ size, name, timeoutMinutes, idempotencyKey })` — creates a sandbox and returns it ready to use.
151
+ - `client.getSandbox(id)`, `client.listSandboxes({ limit, startingAfter, includeDeleted })`, `client.iterSandboxes()`
152
+ - `client.getAccount()`, `client.getUsage({ days })`
153
+ - `sandbox.refresh()`, `sandbox.extend(additionalMinutes)`, `sandbox.destroy()`
154
+ - `sandbox.exec(command, { timeoutMs, cwd, env, onStdout, onStderr })`, `sandbox.getExecution(id)`
155
+ - `sandbox.writeFile(path, content, { encoding })`, `sandbox.readFile(path, { encoding })`
156
+ - `sandbox.listFiles(path, { recursive })`, `sandbox.deleteFile(path, { recursive })`
157
+ - `sandbox.exposePort(port)`, `sandbox.listPorts()`, `sandbox.closePort(port)`
158
+
159
+ Snapshots are available over the REST API only for now.
160
+
161
+ Full API reference: <https://sandbox-as-a-service.com/docs/api>
package/index.d.ts ADDED
@@ -0,0 +1,318 @@
1
+ // Type definitions for sandbox-as-a-service 0.1.0
2
+ // Zero-dependency JavaScript/TypeScript client for Sandbox as a Service.
3
+ // API reference: https://sandbox-as-a-service.com/docs/api
4
+
5
+ declare global {
6
+ // Present in lib.esnext.disposable; re-declared so the types also load on
7
+ // configurations that do not include that lib.
8
+ interface SymbolConstructor {
9
+ readonly asyncDispose: unique symbol;
10
+ }
11
+ }
12
+
13
+ export type SandboxSize = 'small' | 'medium' | 'large' | (string & {});
14
+ export type FileEncoding = 'utf8' | 'base64' | (string & {});
15
+
16
+ export interface ClientOptions {
17
+ /**
18
+ * A key created in the dashboard; it starts with `aas_sk_`. If omitted,
19
+ * `AAS_API_KEY` is read from the environment.
20
+ */
21
+ apiKey?: string | null;
22
+ /**
23
+ * The API root. The default is the public service (or `AAS_BASE_URL` when
24
+ * set); a bare host gets `/v1` appended, and a URL that already ends in
25
+ * `/v1` is used as-is.
26
+ */
27
+ baseUrl?: string | null;
28
+ /**
29
+ * Default per-request timeout in milliseconds. Sandbox creation blocks
30
+ * until the machine is ready, so this is generous by design.
31
+ * @default 600000
32
+ */
33
+ timeoutMs?: number;
34
+ }
35
+
36
+ export interface CreateSandboxOptions {
37
+ /** @default 'small' */
38
+ size?: SandboxSize;
39
+ name?: string | null;
40
+ timeoutMinutes?: number | null;
41
+ /**
42
+ * Retrying a create is safe when you pass the same key; the platform
43
+ * returns the original sandbox instead of a second one. When omitted, a
44
+ * fresh random key is generated for this call.
45
+ */
46
+ idempotencyKey?: string | null;
47
+ }
48
+
49
+ export interface ListSandboxesOptions {
50
+ /** @default 20 */
51
+ limit?: number;
52
+ startingAfter?: string | null;
53
+ includeDeleted?: boolean;
54
+ }
55
+
56
+ export interface ExecOptions {
57
+ /** Bounds the command itself, in milliseconds (1000–600000). @default 60000 */
58
+ timeoutMs?: number;
59
+ cwd?: string | null;
60
+ env?: Record<string, string> | null;
61
+ /** Fire per chunk as the sandbox produces stdout; switches the call to streaming. */
62
+ onStdout?: ((chunk: string) => void | Promise<void>) | null;
63
+ /** Fire per chunk as the sandbox produces stderr; switches the call to streaming. */
64
+ onStderr?: ((chunk: string) => void | Promise<void>) | null;
65
+ }
66
+
67
+ export interface WriteFileOptions {
68
+ /** @default 'utf8' */
69
+ encoding?: FileEncoding;
70
+ }
71
+
72
+ export interface ReadFileOptions {
73
+ /** @default 'utf8' */
74
+ encoding?: FileEncoding;
75
+ }
76
+
77
+ export interface ListFilesOptions {
78
+ /** @default true */
79
+ recursive?: boolean;
80
+ }
81
+
82
+ export interface DeleteFileOptions {
83
+ /** @default false */
84
+ recursive?: boolean;
85
+ }
86
+
87
+ export interface GetUsageOptions {
88
+ /** @default 30 */
89
+ days?: number;
90
+ }
91
+
92
+ export interface SandboxResources {
93
+ vcpu?: number | null;
94
+ memory_gb?: number | null;
95
+ disk_gb?: number | null;
96
+ [key: string]: unknown;
97
+ }
98
+
99
+ export interface FileEntry {
100
+ name?: string;
101
+ type?: string;
102
+ size_bytes?: number;
103
+ [key: string]: unknown;
104
+ }
105
+
106
+ export class Sandbox {
107
+ constructor(client: Client, data: Record<string, any>);
108
+ readonly id: string;
109
+ object: string;
110
+ name: string | null;
111
+ status: string;
112
+ size: string | null;
113
+ resources: SandboxResources;
114
+ timeoutMinutes: number | null;
115
+ createdAt: string | null;
116
+ readyAt: string | null;
117
+ expiresAt: string | null;
118
+ deletedAt: string | null;
119
+ /** The raw API object this sandbox was built from. */
120
+ raw: Record<string, any>;
121
+
122
+ /** Fetch the current state and update this object in place. */
123
+ refresh(): Promise<Sandbox>;
124
+ /** Add lifetime to a running sandbox and return the updated object. */
125
+ extend(additionalMinutes?: number): Promise<Sandbox>;
126
+ /** Destroy the sandbox. Billing stops at teardown. */
127
+ destroy(): Promise<Record<string, any>>;
128
+
129
+ /** Run a shell command inside the sandbox and return its result. */
130
+ exec(command: string, options?: ExecOptions): Promise<Execution>;
131
+ /** Fetch an execution by id. */
132
+ getExecution(executionId: string): Promise<Execution>;
133
+
134
+ /** Write a file inside the sandbox. */
135
+ writeFile(path: string, content: string, options?: WriteFileOptions): Promise<Record<string, any>>;
136
+ /** Read a file and decode it (`utf8` or `base64`). */
137
+ readFile(path: string, options?: ReadFileOptions): Promise<FileContent>;
138
+ /** List a directory. Returns entries with `name`, `type` and `size_bytes`. */
139
+ listFiles(path?: string, options?: ListFilesOptions): Promise<FileListing>;
140
+ /** Delete a file, or a directory tree when `recursive` is true. */
141
+ deleteFile(path: string, options?: DeleteFileOptions): Promise<Record<string, any>>;
142
+
143
+ /** Expose a port on a public preview URL. Returns `{url, port, ...}`. */
144
+ exposePort(port: number): Promise<Record<string, any>>;
145
+ /** List the preview URLs open on the sandbox. */
146
+ listPorts(): Promise<Record<string, any>[]>;
147
+ /** Close a preview URL previously opened for `port`. */
148
+ closePort(port: number): Promise<Record<string, any>>;
149
+
150
+ /**
151
+ * Destroys the sandbox when used with explicit resource management
152
+ * (`await using sandbox = await client.createSandbox()`), on runtimes
153
+ * that provide `Symbol.asyncDispose` (Node.js 22+).
154
+ */
155
+ [Symbol.asyncDispose](): Promise<void>;
156
+ }
157
+
158
+ export class Execution {
159
+ constructor(data: Record<string, any>);
160
+ readonly id: string;
161
+ object: string;
162
+ sandboxId: string | null;
163
+ status: string;
164
+ exitCode: number | null;
165
+ stdout: string;
166
+ stderr: string;
167
+ truncated: boolean;
168
+ durationMs: number | null;
169
+ startedAt: string | null;
170
+ finishedAt: string | null;
171
+ /** The raw API object this execution was built from. */
172
+ raw: Record<string, any>;
173
+
174
+ /** True when the exit code is zero and the command completed. */
175
+ readonly ok: boolean;
176
+ /** Raise `ExecutionFailed` if the command did not exit 0. */
177
+ check(): this;
178
+ }
179
+
180
+ export class ExecutionFailed extends Error {
181
+ constructor(execution: Execution);
182
+ /** The execution that failed. */
183
+ readonly execution: Execution;
184
+ }
185
+
186
+ export class FileContent {
187
+ constructor(data: Record<string, any>);
188
+ path: string;
189
+ /** The decoded content (text for `utf8`, base64 text for `base64`). */
190
+ content: string;
191
+ sizeBytes: number;
192
+ encoding: string;
193
+ /** The raw API object this file content was built from. */
194
+ raw: Record<string, any>;
195
+ /** Alias for `content`. */
196
+ readonly text: string;
197
+ /** Alias for `sizeBytes`. */
198
+ readonly bytes: number;
199
+ }
200
+
201
+ export class FileListing {
202
+ constructor(data: Record<string, any>);
203
+ path: string;
204
+ entries: FileEntry[];
205
+ truncated: boolean;
206
+ /** The entry names, in listing order. */
207
+ names(): string[];
208
+ [Symbol.iterator](): Iterator<FileEntry>;
209
+ readonly length: number;
210
+ }
211
+
212
+ export class Page {
213
+ constructor(data: Record<string, any>);
214
+ data: Record<string, any>[];
215
+ hasMore: boolean;
216
+ nextCursor: string | null;
217
+ [Symbol.iterator](): Iterator<Record<string, any>>;
218
+ readonly length: number;
219
+ }
220
+
221
+ export class Client {
222
+ constructor(options?: ClientOptions);
223
+ apiKey: string;
224
+ baseUrl: string;
225
+ timeoutMs: number;
226
+
227
+ /**
228
+ * Create a sandbox and return it once it is ready to accept commands.
229
+ * `POST /v1/sandboxes` is synchronous: the response arrives after the
230
+ * machine has booted.
231
+ */
232
+ createSandbox(options?: CreateSandboxOptions): Promise<Sandbox>;
233
+ /** Fetch one sandbox by id. */
234
+ getSandbox(sandboxId: string): Promise<Sandbox>;
235
+ /** List sandboxes, newest first. Follow `nextCursor` for more pages. */
236
+ listSandboxes(options?: ListSandboxesOptions): Promise<Page>;
237
+ /** Iterate every sandbox across pages, following the cursor for you. */
238
+ iterSandboxes(options?: ListSandboxesOptions): AsyncGenerator<Record<string, any>, void, unknown>;
239
+ /** Usage and spend over a window. */
240
+ getUsage(options?: GetUsageOptions): Promise<Record<string, any>>;
241
+ /** Balance, limits and current pricing. */
242
+ getAccount(): Promise<Record<string, any>>;
243
+ /** Release client resources (a no-op, kept for symmetry). */
244
+ close(): void;
245
+ }
246
+
247
+ // ------------------------------------------------------------------- errors
248
+
249
+ export class SandboxError extends Error {}
250
+
251
+ export class SandboxApiError extends SandboxError {
252
+ constructor(
253
+ message: string,
254
+ options?: {
255
+ status?: number | null;
256
+ type?: string | null;
257
+ requestId?: string | null;
258
+ responseBody?: unknown;
259
+ }
260
+ );
261
+ /** HTTP status code. */
262
+ status: number | null;
263
+ /** The API's stable machine-readable error code (`error.type`). */
264
+ type: string | null;
265
+ /** The API's request id, for support. */
266
+ requestId: string | null;
267
+ /** The decoded body, when it was JSON. */
268
+ responseBody: unknown;
269
+ /** Alias for `type`. */
270
+ readonly code: string | null;
271
+ /** Alias for `responseBody`. */
272
+ readonly body: unknown;
273
+ toString(): string;
274
+ }
275
+
276
+ /** The client was constructed or called with something it cannot use. Raised before any request is made. */
277
+ export class SandboxConfigurationError extends SandboxError {}
278
+
279
+ /** The request never produced an HTTP response (DNS, TLS, timeout, reset). */
280
+ export class SandboxConnectionError extends SandboxError {}
281
+
282
+ /** 401 — missing, malformed, unknown or revoked key. */
283
+ export class AuthenticationError extends SandboxApiError {}
284
+
285
+ /** 403 — the key is valid but not allowed to do this. */
286
+ export class PermissionDeniedError extends SandboxApiError {}
287
+
288
+ /** 404 — the sandbox, execution or file does not exist (or already expired). */
289
+ export class NotFoundError extends SandboxApiError {}
290
+
291
+ /** 400 — the request body or query is not accepted. A 422 maps to the base `SandboxApiError`. */
292
+ export class InvalidRequestError extends SandboxApiError {}
293
+
294
+ /** 409 — the sandbox is not in a state that allows this, or an idempotency clash. */
295
+ export class ConflictError extends SandboxApiError {}
296
+
297
+ /** 402 — the account has no credit left. */
298
+ export class PaymentRequiredError extends SandboxApiError {}
299
+
300
+ /** 429 — too many requests. `retryAfter` is set when the Retry-After header is sent. */
301
+ export class RateLimitError extends SandboxApiError {
302
+ constructor(
303
+ message: string,
304
+ options?: {
305
+ status?: number | null;
306
+ type?: string | null;
307
+ requestId?: string | null;
308
+ responseBody?: unknown;
309
+ retryAfter?: number | null;
310
+ }
311
+ );
312
+ retryAfter: number | null;
313
+ }
314
+
315
+ /** 503 — the platform could not serve the request. Safe to retry later. */
316
+ export class ServiceUnavailableError extends SandboxApiError {}
317
+
318
+ export declare const VERSION: string;
package/index.js ADDED
@@ -0,0 +1,752 @@
1
+ 'use strict';
2
+
3
+ // A zero-dependency Node.js client for the Sandbox as a Service REST API.
4
+ //
5
+ // The whole surface is a handful of endpoints — create a sandbox, run commands
6
+ // in it, move files, expose a port, extend it, destroy it — so this client is
7
+ // a thin, typed wrapper rather than a framework. It is built on the global
8
+ // `fetch` that ships with Node.js 18 and newer: there is nothing to install
9
+ // alongside it and nothing that can fail to resolve in a minimal agent
10
+ // environment.
11
+ //
12
+ // Quickstart:
13
+ //
14
+ // import { Client } from 'sandbox-as-a-service';
15
+ //
16
+ // const client = new Client(); // reads AAS_API_KEY from the environment
17
+ // const sandbox = await client.createSandbox({ size: 'small', timeoutMinutes: 10 });
18
+ // try {
19
+ // const result = await sandbox.exec("python3 -c 'print(6 * 7)'");
20
+ // console.log(result.stdout.trim()); // -> 42
21
+ // } finally {
22
+ // await sandbox.destroy();
23
+ // }
24
+ //
25
+ // API reference: https://sandbox-as-a-service.com/docs/api
26
+
27
+ const crypto = require('node:crypto');
28
+
29
+ const VERSION = '0.1.0';
30
+ const DEFAULT_BASE_URL = 'https://sandbox-as-a-service.com';
31
+ const USER_AGENT = `sandbox-as-a-service-js/${VERSION}`;
32
+
33
+ // ------------------------------------------------------------------- errors
34
+
35
+ class SandboxError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = this.constructor.name;
39
+ }
40
+ }
41
+
42
+ class SandboxApiError extends SandboxError {
43
+ /**
44
+ * @param {string} message
45
+ * @param {{status?: number|null, type?: string|null, requestId?: string|null, responseBody?: unknown}} [options]
46
+ */
47
+ constructor(message, options = {}) {
48
+ super(message);
49
+ const { status = null, type = null, requestId = null, responseBody = null } = options;
50
+ this.message = message;
51
+ this.status = status;
52
+ this.type = type;
53
+ this.requestId = requestId;
54
+ this.responseBody = responseBody;
55
+ }
56
+
57
+ // Aliases for the API field names, so callers can read either spelling.
58
+ get code() {
59
+ return this.type;
60
+ }
61
+
62
+ get body() {
63
+ return this.responseBody;
64
+ }
65
+
66
+ toString() {
67
+ let base = this.message;
68
+ if (this.status != null) {
69
+ let detail = `HTTP ${this.status}`;
70
+ if (this.type) detail += `, ${this.type}`;
71
+ base += ` (${detail})`;
72
+ }
73
+ if (this.requestId) base += ` [request_id=${this.requestId}]`;
74
+ return base;
75
+ }
76
+ }
77
+
78
+ class SandboxConfigurationError extends SandboxError {}
79
+
80
+ class SandboxConnectionError extends SandboxError {}
81
+
82
+ class AuthenticationError extends SandboxApiError {}
83
+
84
+ class PermissionDeniedError extends SandboxApiError {}
85
+
86
+ class NotFoundError extends SandboxApiError {}
87
+
88
+ class InvalidRequestError extends SandboxApiError {}
89
+
90
+ class ConflictError extends SandboxApiError {}
91
+
92
+ class PaymentRequiredError extends SandboxApiError {}
93
+
94
+ class RateLimitError extends SandboxApiError {
95
+ /**
96
+ * @param {string} message
97
+ * @param {{status?: number|null, type?: string|null, requestId?: string|null, responseBody?: unknown, retryAfter?: number|null}} [options]
98
+ */
99
+ constructor(message, options = {}) {
100
+ const { retryAfter = null, ...rest } = options;
101
+ super(message, rest);
102
+ this.retryAfter = retryAfter;
103
+ }
104
+ }
105
+
106
+ class ServiceUnavailableError extends SandboxApiError {}
107
+
108
+ // Maps the API's `error.type` strings to a class. The type is the contract;
109
+ // the status code is a second signal used when the type is unfamiliar.
110
+ const BY_TYPE = {
111
+ invalid_request: InvalidRequestError,
112
+ unauthorized: AuthenticationError,
113
+ authentication_error: AuthenticationError,
114
+ forbidden: PermissionDeniedError,
115
+ not_found: NotFoundError,
116
+ conflict: ConflictError,
117
+ insufficient_credits: PaymentRequiredError,
118
+ payment_required: PaymentRequiredError,
119
+ rate_limited: RateLimitError,
120
+ service_unavailable: ServiceUnavailableError,
121
+ };
122
+
123
+ const BY_STATUS = {
124
+ 400: InvalidRequestError,
125
+ 401: AuthenticationError,
126
+ 402: PaymentRequiredError,
127
+ 403: PermissionDeniedError,
128
+ 404: NotFoundError,
129
+ 409: ConflictError,
130
+ 429: RateLimitError,
131
+ 503: ServiceUnavailableError,
132
+ };
133
+
134
+ function isPlainObject(value) {
135
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
136
+ }
137
+
138
+ function errorFromResponse(status, body, retryAfter = null) {
139
+ let errType = null;
140
+ let message = `Request failed with HTTP ${status}`;
141
+ let requestId = null;
142
+ if (isPlainObject(body)) {
143
+ requestId = body.request_id ?? null;
144
+ const err = body.error;
145
+ if (isPlainObject(err)) {
146
+ errType = err.type ?? null;
147
+ if (err.message) message = String(err.message);
148
+ }
149
+ }
150
+ const cls = (errType && BY_TYPE[errType]) || BY_STATUS[status] || SandboxApiError;
151
+ if (cls === RateLimitError) {
152
+ return new RateLimitError(message, {
153
+ status,
154
+ type: errType,
155
+ requestId,
156
+ responseBody: body,
157
+ retryAfter,
158
+ });
159
+ }
160
+ return new cls(message, { status, type: errType, requestId, responseBody: body });
161
+ }
162
+
163
+ // ------------------------------------------------------------------ helpers
164
+
165
+ function decodeBody(text) {
166
+ if (!text) return null;
167
+ try {
168
+ return JSON.parse(text);
169
+ } catch {
170
+ return text;
171
+ }
172
+ }
173
+
174
+ function parseRetryAfter(value) {
175
+ if (value == null || value === '') return null;
176
+ const seconds = Number(value);
177
+ return Number.isFinite(seconds) ? seconds : null;
178
+ }
179
+
180
+ function connectionError(url, err) {
181
+ const reason = err && err.message ? err.message : String(err);
182
+ return new SandboxConnectionError(`Could not reach ${url}: ${reason}`);
183
+ }
184
+
185
+ // Parse a Server-Sent Events body, yielding [event, data] as events end.
186
+ //
187
+ // The stream is read chunk by chunk, so events surface as they arrive rather
188
+ // than at EOF. Lines are split at "\n" in a text buffer, which keeps events
189
+ // that arrive split across chunk boundaries intact, and a streaming
190
+ // TextDecoder keeps multi-byte UTF-8 characters that straddle a chunk
191
+ // boundary intact. Comment lines (": ping" heartbeats) are filtered out. A
192
+ // block that ends without a trailing blank line is not dispatched; the caller
193
+ // reports the missing terminal event rather than inventing one.
194
+ async function* sseEvents(body) {
195
+ const reader = body.getReader();
196
+ const decoder = new TextDecoder('utf-8');
197
+ let buffer = '';
198
+ let event = null;
199
+ let dataLines = [];
200
+ try {
201
+ for (;;) {
202
+ const { done, value } = await reader.read();
203
+ if (done) break;
204
+ buffer += decoder.decode(value, { stream: true });
205
+ let newline;
206
+ while ((newline = buffer.indexOf('\n')) !== -1) {
207
+ let line = buffer.slice(0, newline);
208
+ buffer = buffer.slice(newline + 1);
209
+ if (line.endsWith('\r')) line = line.slice(0, -1);
210
+ if (line === '') {
211
+ if (event !== null && dataLines.length > 0) yield [event, dataLines.join('\n')];
212
+ event = null;
213
+ dataLines = [];
214
+ } else if (line.startsWith(':')) {
215
+ continue; // heartbeat comment
216
+ } else if (line.startsWith('event: ')) {
217
+ event = line.slice('event: '.length);
218
+ } else if (line.startsWith('data: ')) {
219
+ dataLines.push(line.slice('data: '.length));
220
+ }
221
+ }
222
+ }
223
+ // A stream that ends without a final blank line leaves its last event
224
+ // undispatched; the caller reports the missing terminal event instead of
225
+ // inventing one.
226
+ } finally {
227
+ reader.releaseLock();
228
+ }
229
+ }
230
+
231
+ // ------------------------------------------------------------------- models
232
+
233
+ class Sandbox {
234
+ /**
235
+ * A running (or recently created) sandbox.
236
+ *
237
+ * Instances are returned by `Client.createSandbox` and `Client.getSandbox`;
238
+ * you do not construct them yourself. The command and file helpers talk to
239
+ * the sandbox this object names, so the `id` is the only state that matters.
240
+ */
241
+ constructor(client, data) {
242
+ this._client = client;
243
+ this._apply(data);
244
+ }
245
+
246
+ _apply(data) {
247
+ this.id = data.id;
248
+ this.object = data.object ?? 'sandbox';
249
+ this.name = data.name ?? null;
250
+ this.status = data.status ?? 'running';
251
+ this.size = data.size ?? null;
252
+ this.resources = { ...(data.resources || {}) };
253
+ this.timeoutMinutes = data.timeout_minutes ?? null;
254
+ this.createdAt = data.created_at ?? null;
255
+ this.readyAt = data.ready_at ?? null;
256
+ this.expiresAt = data.expires_at ?? null;
257
+ this.deletedAt = data.deleted_at ?? null;
258
+ this.raw = { ...data };
259
+ return this;
260
+ }
261
+
262
+ // -------------------------------------------------------------- lifecycle
263
+
264
+ /** Fetch the current state and update this object in place. */
265
+ async refresh() {
266
+ return this._apply(await this._client._request('GET', `/sandboxes/${this.id}`));
267
+ }
268
+
269
+ /** Add lifetime to a running sandbox and return the updated object. */
270
+ async extend(additionalMinutes = 15) {
271
+ const data = await this._client._request('POST', `/sandboxes/${this.id}/extend`, {
272
+ body: { additional_minutes: additionalMinutes },
273
+ });
274
+ return this._apply(data);
275
+ }
276
+
277
+ /**
278
+ * Destroy the sandbox. Billing stops at teardown.
279
+ *
280
+ * Returns the API's confirmation object. A teardown still in progress comes
281
+ * back with `status === "deleting"` and is retried by the platform; it is
282
+ * not an error.
283
+ */
284
+ async destroy() {
285
+ return this._client._request('DELETE', `/sandboxes/${this.id}`);
286
+ }
287
+
288
+ // --------------------------------------------------------------- commands
289
+
290
+ /**
291
+ * Run a shell command inside the sandbox and return its result.
292
+ *
293
+ * This call resolves when the command finishes. `timeoutMs` bounds the
294
+ * command itself (1s–600s); the HTTP client is given a little longer so a
295
+ * command that runs to its limit still returns a result rather than a
296
+ * client-side timeout.
297
+ *
298
+ * Pass `onStdout` or `onStderr` to stream: the call switches to the API's
299
+ * event stream and the callback fires per chunk as the sandbox produces
300
+ * output, instead of everything arriving at the end. The return value is
301
+ * the usual `Execution`, built from the stream's terminal `exit` event. An
302
+ * exception thrown by a callback propagates and closes the stream — which
303
+ * kills the remote command, the same as disconnecting.
304
+ */
305
+ async exec(command, { timeoutMs = 60_000, cwd = null, env = null, onStdout = null, onStderr = null } = {}) {
306
+ const body = { command, timeout_ms: timeoutMs };
307
+ if (cwd !== null && cwd !== undefined) body.cwd = cwd;
308
+ if (env !== null && env !== undefined) body.env = { ...env };
309
+ const requestTimeoutMs = Math.max(this._client.timeoutMs, timeoutMs + 30_000);
310
+ let data;
311
+ if (onStdout !== null || onStderr !== null) {
312
+ body.stream = true;
313
+ data = await this._client._streamExec(
314
+ `/sandboxes/${this.id}/exec`,
315
+ body,
316
+ requestTimeoutMs,
317
+ onStdout,
318
+ onStderr
319
+ );
320
+ } else {
321
+ data = await this._client._request('POST', `/sandboxes/${this.id}/exec`, {
322
+ body,
323
+ timeoutMs: requestTimeoutMs,
324
+ });
325
+ }
326
+ return new Execution(data);
327
+ }
328
+
329
+ async getExecution(executionId) {
330
+ return new Execution(await this._client._request('GET', `/executions/${executionId}`));
331
+ }
332
+
333
+ // ------------------------------------------------------------------ files
334
+
335
+ /**
336
+ * Write a file inside the sandbox.
337
+ *
338
+ * `path` is relative to the workspace (`/workspace`) or absolute under it.
339
+ * Pass `encoding: "base64"` to write binary content.
340
+ */
341
+ async writeFile(path, content, { encoding = 'utf8' } = {}) {
342
+ return this._client._request('PUT', `/sandboxes/${this.id}/files`, {
343
+ body: { path, content, encoding },
344
+ });
345
+ }
346
+
347
+ /** Read a file and decode it (`utf8` or `base64`). */
348
+ async readFile(path, { encoding = 'utf8' } = {}) {
349
+ const data = await this._client._request('GET', `/sandboxes/${this.id}/files`, {
350
+ query: { path, encoding },
351
+ });
352
+ return new FileContent(data);
353
+ }
354
+
355
+ /** List a directory. Returns entries with `name`, `type` and `size_bytes`. */
356
+ async listFiles(path = '/workspace', { recursive = true } = {}) {
357
+ const data = await this._client._request('GET', `/sandboxes/${this.id}/files`, {
358
+ query: { path, list: 'true', recursive: recursive ? 'true' : 'false' },
359
+ });
360
+ return new FileListing(data);
361
+ }
362
+
363
+ /** Delete a file, or a directory tree when `recursive` is true. */
364
+ async deleteFile(path, { recursive = false } = {}) {
365
+ return this._client._request('DELETE', `/sandboxes/${this.id}/files`, {
366
+ query: { path, recursive: recursive ? 'true' : 'false' },
367
+ });
368
+ }
369
+
370
+ // ------------------------------------------------------------------ ports
371
+
372
+ /** Expose a port on a public preview URL. Returns `{url, port, ...}`. */
373
+ async exposePort(port) {
374
+ return this._client._request('POST', `/sandboxes/${this.id}/ports`, { body: { port } });
375
+ }
376
+
377
+ async listPorts() {
378
+ const data = await this._client._request('GET', `/sandboxes/${this.id}/ports`);
379
+ return isPlainObject(data) && Array.isArray(data.data) ? [...data.data] : [];
380
+ }
381
+
382
+ /** Close a preview URL previously opened for `port`. */
383
+ async closePort(port) {
384
+ return this._client._request('DELETE', `/sandboxes/${this.id}/ports/${port}`);
385
+ }
386
+
387
+ toString() {
388
+ return `<Sandbox id=${JSON.stringify(this.id)} status=${JSON.stringify(this.status)} size=${JSON.stringify(this.size)}>`;
389
+ }
390
+ }
391
+
392
+ // `await using sandbox = await client.createSandbox()` destroys the sandbox
393
+ // when the scope ends — the same contract as the Python context manager.
394
+ // The symbol only exists on Node.js 22 and newer, so the method is attached
395
+ // conditionally to stay loadable on 18.
396
+ if (typeof Symbol !== 'undefined' && typeof Symbol.asyncDispose !== 'undefined') {
397
+ Sandbox.prototype[Symbol.asyncDispose] = function asyncDispose() {
398
+ return this.destroy();
399
+ };
400
+ }
401
+
402
+ class Execution {
403
+ /** The result of `Sandbox.exec`. */
404
+ constructor(data) {
405
+ this.id = data.id;
406
+ this.object = data.object ?? 'execution';
407
+ this.sandboxId = data.sandbox_id ?? null;
408
+ this.status = data.status ?? 'completed';
409
+ this.exitCode = data.exit_code ?? null;
410
+ this.stdout = data.stdout || '';
411
+ this.stderr = data.stderr || '';
412
+ this.truncated = Boolean(data.truncated);
413
+ this.durationMs = data.duration_ms ?? null;
414
+ this.startedAt = data.started_at ?? null;
415
+ this.finishedAt = data.finished_at ?? null;
416
+ this.raw = { ...data };
417
+ }
418
+
419
+ get ok() {
420
+ return this.exitCode === 0 && this.status === 'completed';
421
+ }
422
+
423
+ /** Raise `ExecutionFailed` if the command did not exit 0. */
424
+ check() {
425
+ if (!this.ok) throw new ExecutionFailed(this);
426
+ return this;
427
+ }
428
+
429
+ toString() {
430
+ return `<Execution id=${JSON.stringify(this.id)} status=${JSON.stringify(this.status)} exit_code=${JSON.stringify(this.exitCode)}>`;
431
+ }
432
+ }
433
+
434
+ class ExecutionFailed extends Error {
435
+ constructor(execution) {
436
+ const stripped = execution.stderr.trim();
437
+ const lines = stripped === '' ? [] : stripped.split('\n');
438
+ const tail = lines.length > 0 ? `: ${lines[lines.length - 1]}` : '';
439
+ super(`command '${execution.status}' with exit code ${execution.exitCode}${tail}`);
440
+ this.name = 'ExecutionFailed';
441
+ this.execution = execution;
442
+ }
443
+ }
444
+
445
+ class FileContent {
446
+ /** A file read back from a sandbox. */
447
+ constructor(data) {
448
+ this.path = data.path ?? '';
449
+ this.content = data.content ?? '';
450
+ this.sizeBytes = data.bytes ?? 0;
451
+ this.encoding = data.encoding ?? 'utf8';
452
+ this.raw = { ...data };
453
+ }
454
+
455
+ get text() {
456
+ return this.content;
457
+ }
458
+
459
+ get bytes() {
460
+ return this.sizeBytes;
461
+ }
462
+
463
+ toString() {
464
+ return `<FileContent path=${JSON.stringify(this.path)} bytes=${JSON.stringify(this.sizeBytes)}>`;
465
+ }
466
+ }
467
+
468
+ class FileListing {
469
+ /** A directory listing, capped at the platform's entry limit. */
470
+ constructor(data) {
471
+ this.path = data.path ?? '';
472
+ this.entries = Array.isArray(data.entries) ? [...data.entries] : [];
473
+ this.truncated = Boolean(data.truncated);
474
+ }
475
+
476
+ names() {
477
+ return this.entries.map((entry) => (isPlainObject(entry) && entry.name != null ? entry.name : ''));
478
+ }
479
+
480
+ [Symbol.iterator]() {
481
+ return this.entries[Symbol.iterator]();
482
+ }
483
+
484
+ get length() {
485
+ return this.entries.length;
486
+ }
487
+
488
+ toString() {
489
+ return `<FileListing path=${JSON.stringify(this.path)} entries=${JSON.stringify(this.entries.length)}>`;
490
+ }
491
+ }
492
+
493
+ class Page {
494
+ /** One page of a cursor-paginated list. */
495
+ constructor(data) {
496
+ const source = isPlainObject(data) ? data : {};
497
+ this.data = Array.isArray(source.data) ? [...source.data] : [];
498
+ this.hasMore = Boolean(source.has_more);
499
+ this.nextCursor = source.next_cursor ?? null;
500
+ }
501
+
502
+ [Symbol.iterator]() {
503
+ return this.data[Symbol.iterator]();
504
+ }
505
+
506
+ get length() {
507
+ return this.data.length;
508
+ }
509
+
510
+ toString() {
511
+ return `<Page items=${JSON.stringify(this.data.length)} has_more=${JSON.stringify(this.hasMore)}>`;
512
+ }
513
+ }
514
+
515
+ // ------------------------------------------------------------------ client
516
+
517
+ class Client {
518
+ /**
519
+ * Client for one account's API key.
520
+ *
521
+ * @param {{apiKey?: string|null, baseUrl?: string|null, timeoutMs?: number}} [options]
522
+ *
523
+ * `apiKey`: a key created in the dashboard; it starts with `aas_sk_`. If
524
+ * omitted, `AAS_API_KEY` is read from the environment.
525
+ *
526
+ * `baseUrl`: the API root. The default is the public service (or
527
+ * `AAS_BASE_URL` when set); a bare host gets `/v1` appended, and a URL that
528
+ * already ends in `/v1` is used as-is.
529
+ *
530
+ * `timeoutMs`: default per-request timeout in milliseconds. Sandbox
531
+ * creation blocks until the machine is ready, so this is generous by
532
+ * design.
533
+ */
534
+ constructor({ apiKey = null, baseUrl = null, timeoutMs = 600_000 } = {}) {
535
+ this.apiKey = apiKey ?? process.env.AAS_API_KEY ?? null;
536
+ if (!this.apiKey) {
537
+ throw new SandboxConfigurationError(
538
+ 'No API key. Pass apiKey: ... or set the AAS_API_KEY environment variable.'
539
+ );
540
+ }
541
+ const base = baseUrl ?? process.env.AAS_BASE_URL ?? DEFAULT_BASE_URL;
542
+ if (!base.startsWith('http://') && !base.startsWith('https://')) {
543
+ throw new SandboxConfigurationError(`base_url must be http(s), got ${JSON.stringify(base)}`);
544
+ }
545
+ let normalized = base.replace(/\/+$/, '');
546
+ if (!normalized.endsWith('/v1')) normalized += '/v1';
547
+ this.baseUrl = normalized;
548
+ this.timeoutMs = Number(timeoutMs);
549
+ }
550
+
551
+ // ------------------------------------------------------------------ HTTP
552
+
553
+ async _request(method, path, { body = null, query = null, headers = null, timeoutMs = null } = {}) {
554
+ let url = `${this.baseUrl}${path}`;
555
+ if (query) {
556
+ const clean = Object.fromEntries(
557
+ Object.entries(query).filter(([, value]) => value !== null && value !== undefined)
558
+ );
559
+ const queryString = new URLSearchParams(clean).toString();
560
+ if (queryString) url += `?${queryString}`;
561
+ }
562
+
563
+ const requestHeaders = {
564
+ Authorization: `Bearer ${this.apiKey}`,
565
+ Accept: 'application/json',
566
+ 'User-Agent': USER_AGENT,
567
+ };
568
+ let payload;
569
+ if (body !== null && body !== undefined) {
570
+ payload = JSON.stringify(body);
571
+ requestHeaders['Content-Type'] = 'application/json';
572
+ }
573
+ if (headers) Object.assign(requestHeaders, headers);
574
+
575
+ const timeout = timeoutMs ?? this.timeoutMs;
576
+ let response;
577
+ try {
578
+ response = await fetch(url, {
579
+ method,
580
+ headers: requestHeaders,
581
+ body: payload,
582
+ signal: AbortSignal.timeout(timeout),
583
+ });
584
+ } catch (err) {
585
+ if (err instanceof SandboxError) throw err;
586
+ throw connectionError(url, err);
587
+ }
588
+
589
+ const text = await response.text();
590
+ const decoded = decodeBody(text);
591
+ if (!response.ok) {
592
+ throw errorFromResponse(response.status, decoded, parseRetryAfter(response.headers.get('Retry-After')));
593
+ }
594
+ return decoded;
595
+ }
596
+
597
+ async _streamExec(path, body, timeoutMs, onStdout, onStderr) {
598
+ /**
599
+ * Consume a streamed exec, invoking callbacks per chunk.
600
+ *
601
+ * Returns the exit event's payload (the same object the non-streaming
602
+ * call returns). An `error` event is raised through the same exception
603
+ * mapping the rest of the client uses; an exception a callback raises
604
+ * propagates, which closes the connection and — like disconnecting —
605
+ * kills the remote command.
606
+ */
607
+ const url = `${this.baseUrl}${path}`;
608
+ let response;
609
+ try {
610
+ response = await fetch(url, {
611
+ method: 'POST',
612
+ headers: {
613
+ Authorization: `Bearer ${this.apiKey}`,
614
+ Accept: 'text/event-stream',
615
+ 'User-Agent': USER_AGENT,
616
+ 'Content-Type': 'application/json',
617
+ },
618
+ body: JSON.stringify(body),
619
+ signal: AbortSignal.timeout(timeoutMs),
620
+ });
621
+ } catch (err) {
622
+ if (err instanceof SandboxError) throw err;
623
+ throw connectionError(url, err);
624
+ }
625
+ if (!response.ok) {
626
+ const text = await response.text();
627
+ throw errorFromResponse(response.status, decodeBody(text), parseRetryAfter(response.headers.get('Retry-After')));
628
+ }
629
+
630
+ const stream = response.body;
631
+ try {
632
+ if (stream) {
633
+ for await (const [event, payload] of sseEvents(stream)) {
634
+ if (event === 'stdout') {
635
+ if (onStdout !== null) await onStdout(JSON.parse(payload).data);
636
+ } else if (event === 'stderr') {
637
+ if (onStderr !== null) await onStderr(JSON.parse(payload).data);
638
+ } else if (event === 'exit') {
639
+ return JSON.parse(payload);
640
+ } else if (event === 'error') {
641
+ // The same error object the API returns out of band; use the
642
+ // client's existing status/type mapping. A failure that reaches
643
+ // the stream is always after the 200, so its status comes from
644
+ // the type alone.
645
+ throw errorFromResponse(502, JSON.parse(payload));
646
+ }
647
+ }
648
+ }
649
+ } finally {
650
+ // Cancelling the body closes the socket, which is what tells the
651
+ // platform the client went away — cancelling the command. This also
652
+ // fires when a callback raises or the exit event ends the loop early.
653
+ if (stream) {
654
+ try {
655
+ await stream.cancel();
656
+ } catch {
657
+ // Already closed; nothing to do.
658
+ }
659
+ }
660
+ }
661
+ throw new SandboxConnectionError('the event stream ended without an exit event');
662
+ }
663
+
664
+ // -------------------------------------------------------------- resources
665
+
666
+ /**
667
+ * Create a sandbox and return it once it is ready to accept commands.
668
+ *
669
+ * `POST /v1/sandboxes` is synchronous: the response arrives after the
670
+ * machine has booted. Sizes are `small`, `medium` and `large`.
671
+ *
672
+ * Retrying a create is safe when you pass the same `idempotencyKey`; the
673
+ * platform returns the original sandbox instead of a second one. When
674
+ * omitted, a fresh key is generated for this call.
675
+ */
676
+ async createSandbox({ size = 'small', name = null, timeoutMinutes = null, idempotencyKey = null } = {}) {
677
+ const body = { size };
678
+ if (name !== null && name !== undefined) body.name = name;
679
+ if (timeoutMinutes !== null && timeoutMinutes !== undefined) body.timeout_minutes = timeoutMinutes;
680
+ const key = idempotencyKey || crypto.randomUUID();
681
+ const data = await this._request('POST', '/sandboxes', { body, headers: { 'Idempotency-Key': key } });
682
+ return new Sandbox(this, data);
683
+ }
684
+
685
+ async getSandbox(sandboxId) {
686
+ return new Sandbox(this, await this._request('GET', `/sandboxes/${sandboxId}`));
687
+ }
688
+
689
+ /** List sandboxes, newest first. Follow `nextCursor` for more pages. */
690
+ async listSandboxes({ limit = 20, startingAfter = null, includeDeleted = false } = {}) {
691
+ const query = { limit };
692
+ if (startingAfter) query.starting_after = startingAfter;
693
+ if (includeDeleted) query.include_deleted = 'true';
694
+ return new Page(await this._request('GET', '/sandboxes', { query }));
695
+ }
696
+
697
+ /** Iterate every sandbox across pages, following the cursor for you. */
698
+ async *iterSandboxes({ limit = 20, includeDeleted = false } = {}) {
699
+ let cursor = null;
700
+ for (;;) {
701
+ const page = await this.listSandboxes({ limit, startingAfter: cursor, includeDeleted });
702
+ for (const item of page.data) yield item;
703
+ if (!page.hasMore || !page.nextCursor) return;
704
+ cursor = page.nextCursor;
705
+ }
706
+ }
707
+
708
+ async getUsage({ days = 30 } = {}) {
709
+ return this._request('GET', '/usage', { query: { days } });
710
+ }
711
+
712
+ async getAccount() {
713
+ return this._request('GET', '/account');
714
+ }
715
+
716
+ // ------------------------------------------------------------- lifecycle
717
+
718
+ /**
719
+ * Release client resources.
720
+ *
721
+ * The global fetch keeps no pooled connection the client owns, so this is
722
+ * a no-op kept for symmetry with the Python client and other clients.
723
+ */
724
+ close() {}
725
+
726
+ toString() {
727
+ return `<Client base_url=${JSON.stringify(this.baseUrl)}>`;
728
+ }
729
+ }
730
+
731
+ module.exports = {
732
+ Client,
733
+ Sandbox,
734
+ Execution,
735
+ ExecutionFailed,
736
+ FileContent,
737
+ FileListing,
738
+ Page,
739
+ SandboxError,
740
+ SandboxApiError,
741
+ SandboxConfigurationError,
742
+ SandboxConnectionError,
743
+ AuthenticationError,
744
+ PermissionDeniedError,
745
+ NotFoundError,
746
+ InvalidRequestError,
747
+ ConflictError,
748
+ PaymentRequiredError,
749
+ RateLimitError,
750
+ ServiceUnavailableError,
751
+ VERSION,
752
+ };
package/index.mjs ADDED
@@ -0,0 +1,26 @@
1
+ import cjs from './index.js';
2
+
3
+ export const {
4
+ Client,
5
+ Sandbox,
6
+ Execution,
7
+ ExecutionFailed,
8
+ FileContent,
9
+ FileListing,
10
+ Page,
11
+ SandboxError,
12
+ SandboxApiError,
13
+ SandboxConfigurationError,
14
+ SandboxConnectionError,
15
+ AuthenticationError,
16
+ PermissionDeniedError,
17
+ NotFoundError,
18
+ InvalidRequestError,
19
+ ConflictError,
20
+ PaymentRequiredError,
21
+ RateLimitError,
22
+ ServiceUnavailableError,
23
+ VERSION,
24
+ } = cjs;
25
+
26
+ export default cjs;
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "sandbox-as-a-service",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency JavaScript/TypeScript client for Sandbox as a Service — secure, disposable cloud sandboxes for AI agents and code execution",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "main": "index.js",
10
+ "types": "index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "import": "./index.mjs",
15
+ "require": "./index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "index.mjs",
21
+ "index.d.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "repository": "https://sandbox-as-a-service.com/docs/sdk",
26
+ "homepage": "https://sandbox-as-a-service.com/docs/sdk",
27
+ "keywords": [
28
+ "sandbox",
29
+ "sandbox-as-a-service",
30
+ "code-execution",
31
+ "ai-agents",
32
+ "cloud",
33
+ "vm",
34
+ "api-client",
35
+ "sdk"
36
+ ],
37
+ "sideEffects": false
38
+ }