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 +21 -0
- package/README.md +161 -0
- package/index.d.ts +318 -0
- package/index.js +752 -0
- package/index.mjs +26 -0
- package/package.json +38 -0
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
|
+
}
|