@banata-boxes/sdk 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/dist/index.d.ts +561 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +476 -0
- package/package.json +51 -0
- package/src/index.ts +1219 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
// sdk/src/index.ts
|
|
2
|
+
// Customer-facing TypeScript SDK for Browser-as-a-Service
|
|
3
|
+
//
|
|
4
|
+
// M7 FIX: Added retry with exponential backoff on transient errors (5xx, network).
|
|
5
|
+
|
|
6
|
+
export interface BrowserConfig {
|
|
7
|
+
/** Session weight: light (512MB, default), medium (1GB), heavy (2GB — Pro+) */
|
|
8
|
+
weight?: 'light' | 'medium' | 'heavy';
|
|
9
|
+
/** Isolation mode: shared (default) or dedicated (Pro+) */
|
|
10
|
+
isolation?: 'shared' | 'dedicated';
|
|
11
|
+
/** Outbound egress profile for the session */
|
|
12
|
+
egressProfile?:
|
|
13
|
+
| 'shared-dc'
|
|
14
|
+
| 'dedicated-dc'
|
|
15
|
+
| 'rotating-residential'
|
|
16
|
+
| 'static-residential'
|
|
17
|
+
| 'byo-proxy';
|
|
18
|
+
/** Enable anti-detection stealth (Builder+, default: false) */
|
|
19
|
+
stealth?: boolean;
|
|
20
|
+
/** Customer-provided proxy (BYOP) */
|
|
21
|
+
proxy?: {
|
|
22
|
+
protocol: 'http' | 'https' | 'socks5';
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
username?: string;
|
|
26
|
+
password?: string;
|
|
27
|
+
};
|
|
28
|
+
/** Enable MP4 session recording (Pro+, default: false) */
|
|
29
|
+
recording?: boolean;
|
|
30
|
+
/** Enable auto CAPTCHA solving (reCAPTCHA, hCaptcha, Turnstile) */
|
|
31
|
+
captcha?: boolean;
|
|
32
|
+
/** Max session duration in ms (capped by your plan limit) */
|
|
33
|
+
maxDurationMs?: number;
|
|
34
|
+
/** R2 key for a saved browser profile (cookies, localStorage) */
|
|
35
|
+
profileKey?: string;
|
|
36
|
+
/** Preferred Fly.io region code (e.g. 'iad', 'lhr') */
|
|
37
|
+
region?: string;
|
|
38
|
+
/** Ask the API to wait briefly for the session to become ready before returning */
|
|
39
|
+
waitUntilReady?: boolean;
|
|
40
|
+
/** Max time in ms for the API to wait before falling back to a queued response */
|
|
41
|
+
waitTimeoutMs?: number;
|
|
42
|
+
/** Timeout in ms to wait for session to become ready (default: 30000) */
|
|
43
|
+
timeout?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BrowserSession {
|
|
47
|
+
id: string;
|
|
48
|
+
status: 'queued' | 'assigning' | 'ready' | 'active' | 'ending' | 'ended' | 'failed';
|
|
49
|
+
waitTimedOut?: boolean;
|
|
50
|
+
cdpUrl: string | null;
|
|
51
|
+
weight: string;
|
|
52
|
+
isolation: string;
|
|
53
|
+
egressProfile: string;
|
|
54
|
+
artifacts: {
|
|
55
|
+
screenshots?: string[];
|
|
56
|
+
har?: string;
|
|
57
|
+
recording?: string;
|
|
58
|
+
} | null;
|
|
59
|
+
duration: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UsageInfo {
|
|
63
|
+
totalSessions: number;
|
|
64
|
+
totalBrowserHours: number;
|
|
65
|
+
weightBreakdown: {
|
|
66
|
+
light: number;
|
|
67
|
+
medium: number;
|
|
68
|
+
heavy: number;
|
|
69
|
+
};
|
|
70
|
+
periodStart: number | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BillingInfo {
|
|
74
|
+
plan: string;
|
|
75
|
+
polarCustomerId: string | null;
|
|
76
|
+
currentPeriod: {
|
|
77
|
+
totalSessions: number;
|
|
78
|
+
totalBrowserHours: number;
|
|
79
|
+
weightBreakdown: {
|
|
80
|
+
light: number;
|
|
81
|
+
medium: number;
|
|
82
|
+
heavy: number;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type WebhookEventType =
|
|
88
|
+
| "session.created"
|
|
89
|
+
| "session.assigned"
|
|
90
|
+
| "session.ready"
|
|
91
|
+
| "session.ending"
|
|
92
|
+
| "session.ended"
|
|
93
|
+
| "session.failed"
|
|
94
|
+
| "sandbox.created"
|
|
95
|
+
| "sandbox.assigned"
|
|
96
|
+
| "sandbox.ready"
|
|
97
|
+
| "sandbox.ending"
|
|
98
|
+
| "sandbox.ended"
|
|
99
|
+
| "sandbox.failed"
|
|
100
|
+
| "sandbox.paused"
|
|
101
|
+
| "sandbox.resumed"
|
|
102
|
+
| "sandbox.opencode.updated"
|
|
103
|
+
| "sandbox.browser_preview.updated"
|
|
104
|
+
| "sandbox.handoff.requested"
|
|
105
|
+
| "sandbox.handoff.accepted"
|
|
106
|
+
| "sandbox.handoff.completed"
|
|
107
|
+
| "sandbox.artifacts.updated"
|
|
108
|
+
| "machine.created"
|
|
109
|
+
| "machine.woken"
|
|
110
|
+
| "machine.registered"
|
|
111
|
+
| "machine.suspended";
|
|
112
|
+
|
|
113
|
+
export interface WebhookEndpoint {
|
|
114
|
+
id: string;
|
|
115
|
+
url: string;
|
|
116
|
+
description?: string;
|
|
117
|
+
eventTypes: WebhookEventType[];
|
|
118
|
+
enabled: boolean;
|
|
119
|
+
createdAt: number;
|
|
120
|
+
updatedAt?: number;
|
|
121
|
+
lastDeliveredAt?: number;
|
|
122
|
+
lastFailureAt?: number;
|
|
123
|
+
lastFailureMessage?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface WebhookDelivery {
|
|
127
|
+
id: string;
|
|
128
|
+
webhookId: string;
|
|
129
|
+
eventId: string;
|
|
130
|
+
eventType: WebhookEventType;
|
|
131
|
+
resourceType?: string;
|
|
132
|
+
resourceId?: string;
|
|
133
|
+
targetUrl: string;
|
|
134
|
+
status: "pending" | "delivered" | "failed";
|
|
135
|
+
attemptCount: number;
|
|
136
|
+
responseStatus?: number;
|
|
137
|
+
responseBody?: string;
|
|
138
|
+
errorMessage?: string;
|
|
139
|
+
nextAttemptAt?: number;
|
|
140
|
+
lastAttemptAt: number;
|
|
141
|
+
deliveredAt?: number;
|
|
142
|
+
createdAt: number;
|
|
143
|
+
updatedAt: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RetryConfig {
|
|
147
|
+
/** Max number of retries (default: 3) */
|
|
148
|
+
maxRetries?: number;
|
|
149
|
+
/** Base delay in ms for exponential backoff (default: 500) */
|
|
150
|
+
baseDelayMs?: number;
|
|
151
|
+
/** Max delay in ms (default: 10000) */
|
|
152
|
+
maxDelayMs?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class BanataError extends Error {
|
|
156
|
+
/** HTTP status code (0 for network errors) */
|
|
157
|
+
status: number;
|
|
158
|
+
/** Machine-readable error code (e.g. PLAN_FEATURE_UNAVAILABLE, PLAN_LIMIT_EXCEEDED) */
|
|
159
|
+
code: string | undefined;
|
|
160
|
+
/** The plan required to use this feature, if applicable */
|
|
161
|
+
requiredPlan: string | undefined;
|
|
162
|
+
/** Your current plan, if returned by the API */
|
|
163
|
+
currentPlan: string | undefined;
|
|
164
|
+
|
|
165
|
+
constructor(
|
|
166
|
+
message: string,
|
|
167
|
+
status: number,
|
|
168
|
+
details?: { code?: string; requiredPlan?: string; currentPlan?: string },
|
|
169
|
+
) {
|
|
170
|
+
super(message);
|
|
171
|
+
this.name = 'BanataError';
|
|
172
|
+
this.status = status;
|
|
173
|
+
this.code = details?.code;
|
|
174
|
+
this.requiredPlan = details?.requiredPlan;
|
|
175
|
+
this.currentPlan = details?.currentPlan;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @deprecated Use BanataError instead */
|
|
180
|
+
export const BrowserServiceError = BanataError;
|
|
181
|
+
|
|
182
|
+
/** Check if an HTTP status is retryable (5xx or 429) */
|
|
183
|
+
function isRetryableStatus(status: number): boolean {
|
|
184
|
+
return status >= 500 || status === 429;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Sleep for a given number of ms */
|
|
188
|
+
function sleep(ms: number): Promise<void> {
|
|
189
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export class BrowserCloud {
|
|
193
|
+
private apiKey: string;
|
|
194
|
+
private baseUrl: string;
|
|
195
|
+
private retryConfig: Required<RetryConfig>;
|
|
196
|
+
|
|
197
|
+
constructor(config: { apiKey: string; baseUrl?: string; retry?: RetryConfig }) {
|
|
198
|
+
if (!config.apiKey) throw new Error('API key is required');
|
|
199
|
+
this.apiKey = config.apiKey;
|
|
200
|
+
this.baseUrl = config.baseUrl ?? 'https://api.banata.dev';
|
|
201
|
+
this.retryConfig = {
|
|
202
|
+
maxRetries: config.retry?.maxRetries ?? 3,
|
|
203
|
+
baseDelayMs: config.retry?.baseDelayMs ?? 500,
|
|
204
|
+
maxDelayMs: config.retry?.maxDelayMs ?? 10_000,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private get headers(): Record<string, string> {
|
|
209
|
+
return {
|
|
210
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// M7: Request with exponential backoff retry on transient errors
|
|
216
|
+
private async request<T>(
|
|
217
|
+
path: string,
|
|
218
|
+
options: RequestInit = {}
|
|
219
|
+
): Promise<T> {
|
|
220
|
+
const { maxRetries, baseDelayMs, maxDelayMs } = this.retryConfig;
|
|
221
|
+
let lastError: BanataError | Error | undefined;
|
|
222
|
+
|
|
223
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
226
|
+
...options,
|
|
227
|
+
headers: { ...this.headers, ...options.headers },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!res.ok) {
|
|
231
|
+
const body = await res.text();
|
|
232
|
+
let message: string;
|
|
233
|
+
let parsed: Record<string, unknown> = {};
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(body);
|
|
236
|
+
message = (parsed.error as string) ?? body;
|
|
237
|
+
} catch {
|
|
238
|
+
message = body;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const error = new BanataError(message, res.status, {
|
|
242
|
+
code: parsed.code as string | undefined,
|
|
243
|
+
requiredPlan: parsed.requiredPlan as string | undefined,
|
|
244
|
+
currentPlan: parsed.currentPlan as string | undefined,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Only retry on transient errors (5xx, 429)
|
|
248
|
+
if (isRetryableStatus(res.status) && attempt < maxRetries) {
|
|
249
|
+
lastError = error;
|
|
250
|
+
// Use Retry-After header if present (from rate limiter)
|
|
251
|
+
const retryAfterHeader = res.headers.get('Retry-After');
|
|
252
|
+
let delay: number;
|
|
253
|
+
if (retryAfterHeader) {
|
|
254
|
+
delay = Math.min(parseInt(retryAfterHeader, 10) * 1000, maxDelayMs);
|
|
255
|
+
} else {
|
|
256
|
+
delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
257
|
+
}
|
|
258
|
+
// Add jitter (0-25% of delay)
|
|
259
|
+
delay += Math.random() * delay * 0.25;
|
|
260
|
+
await sleep(delay);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return res.json() as Promise<T>;
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (err instanceof BanataError) {
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Network error — retry if attempts remain
|
|
274
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
275
|
+
if (attempt < maxRetries) {
|
|
276
|
+
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
277
|
+
await sleep(delay + Math.random() * delay * 0.25);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
throw new BanataError(
|
|
282
|
+
`Network error after ${maxRetries + 1} attempts: ${lastError.message}`,
|
|
283
|
+
0,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Should not reach here, but TypeScript needs it
|
|
289
|
+
throw lastError ?? new Error('Request failed');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Sessions ──
|
|
293
|
+
|
|
294
|
+
/** Create a new browser session */
|
|
295
|
+
async createBrowser(config: BrowserConfig = {}): Promise<BrowserSession> {
|
|
296
|
+
const { timeout, waitTimeoutMs, ...rest } = config;
|
|
297
|
+
return this.request<BrowserSession>('/v1/browsers', {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
...rest,
|
|
301
|
+
waitTimeoutMs: waitTimeoutMs ?? timeout,
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Get session status */
|
|
307
|
+
async getBrowser(sessionId: string): Promise<BrowserSession> {
|
|
308
|
+
return this.request<BrowserSession>(
|
|
309
|
+
`/v1/browsers?id=${encodeURIComponent(sessionId)}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** End a session */
|
|
314
|
+
async closeBrowser(sessionId: string): Promise<void> {
|
|
315
|
+
await this.request(`/v1/browsers?id=${encodeURIComponent(sessionId)}`, {
|
|
316
|
+
method: 'DELETE',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Wait until session is ready and return CDP URL */
|
|
321
|
+
async waitForReady(
|
|
322
|
+
sessionId: string,
|
|
323
|
+
timeoutMs: number = 30_000
|
|
324
|
+
): Promise<string> {
|
|
325
|
+
const start = Date.now();
|
|
326
|
+
|
|
327
|
+
while (Date.now() - start < timeoutMs) {
|
|
328
|
+
const session = await this.getBrowser(sessionId);
|
|
329
|
+
|
|
330
|
+
if ((session.status === 'ready' || session.status === 'active') && session.cdpUrl) {
|
|
331
|
+
return session.cdpUrl;
|
|
332
|
+
}
|
|
333
|
+
if (session.status === 'failed') {
|
|
334
|
+
throw new BanataError('Session failed to start', 500);
|
|
335
|
+
}
|
|
336
|
+
if (session.status === 'ended' || session.status === 'ending') {
|
|
337
|
+
throw new BanataError('Session ended before becoming ready', 410);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await sleep(500);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw new BanataError(
|
|
344
|
+
`Session ${sessionId} not ready within ${timeoutMs}ms`,
|
|
345
|
+
408
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Convenience: Create a session, wait for it to be ready, and return
|
|
351
|
+
* the CDP URL + a close function.
|
|
352
|
+
*
|
|
353
|
+
* Usage with Puppeteer:
|
|
354
|
+
* ```ts
|
|
355
|
+
* const { cdpUrl, close } = await cloud.launch({ weight: 'heavy' });
|
|
356
|
+
* const browser = await puppeteer.connect({ browserWSEndpoint: cdpUrl });
|
|
357
|
+
* // ... use browser ...
|
|
358
|
+
* await browser.disconnect();
|
|
359
|
+
* await close();
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
async launch(
|
|
363
|
+
config: BrowserConfig = {}
|
|
364
|
+
): Promise<{
|
|
365
|
+
cdpUrl: string;
|
|
366
|
+
sessionId: string;
|
|
367
|
+
close: () => Promise<void>;
|
|
368
|
+
}> {
|
|
369
|
+
const session = await this.createBrowser(config);
|
|
370
|
+
let cdpUrl: string;
|
|
371
|
+
try {
|
|
372
|
+
cdpUrl = await this.waitForReady(
|
|
373
|
+
session.id,
|
|
374
|
+
config.timeout ?? 30_000
|
|
375
|
+
);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
// Clean up the orphaned session on timeout/failure
|
|
378
|
+
try {
|
|
379
|
+
await this.closeBrowser(session.id);
|
|
380
|
+
} catch {
|
|
381
|
+
// Best-effort cleanup — session will be cleaned by stale cron
|
|
382
|
+
}
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
cdpUrl,
|
|
388
|
+
sessionId: session.id,
|
|
389
|
+
close: () => this.closeBrowser(session.id),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Usage & Billing ──
|
|
394
|
+
|
|
395
|
+
/** Get current period usage */
|
|
396
|
+
async getUsage(): Promise<UsageInfo> {
|
|
397
|
+
return this.request<UsageInfo>('/v1/usage');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Get billing info (plan, usage) */
|
|
401
|
+
async getBilling(): Promise<BillingInfo> {
|
|
402
|
+
return this.request<BillingInfo>('/v1/billing');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Create a checkout session for plan upgrade (returns Polar checkout URL) */
|
|
406
|
+
async createCheckout(params: {
|
|
407
|
+
plan: 'builder' | 'pro' | 'scale';
|
|
408
|
+
successUrl?: string;
|
|
409
|
+
}): Promise<{ checkoutUrl: string; checkoutId: string }> {
|
|
410
|
+
return this.request('/v1/billing/checkout', {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
body: JSON.stringify(params),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async listWebhooks(): Promise<WebhookEndpoint[]> {
|
|
417
|
+
const response = await this.request<{ data: WebhookEndpoint[] }>('/v1/webhooks');
|
|
418
|
+
return response.data;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async createWebhook(params: {
|
|
422
|
+
url: string;
|
|
423
|
+
description?: string;
|
|
424
|
+
eventTypes?: WebhookEventType[];
|
|
425
|
+
signingSecret?: string;
|
|
426
|
+
}): Promise<WebhookEndpoint & { signingSecret: string }> {
|
|
427
|
+
return this.request('/v1/webhooks', {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
body: JSON.stringify(params),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async deleteWebhook(id: string): Promise<void> {
|
|
434
|
+
await this.request(`/v1/webhooks?id=${encodeURIComponent(id)}`, {
|
|
435
|
+
method: 'DELETE',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async listWebhookDeliveries(limit = 50): Promise<WebhookDelivery[]> {
|
|
440
|
+
const response = await this.request<{ data: WebhookDelivery[] }>(
|
|
441
|
+
`/v1/webhooks/deliveries?limit=${encodeURIComponent(String(limit))}`
|
|
442
|
+
);
|
|
443
|
+
return response.data;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async testWebhook(id: string): Promise<{ eventId: string }> {
|
|
447
|
+
return this.request('/v1/webhooks/test', {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
body: JSON.stringify({ id }),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Sandbox types ──
|
|
455
|
+
|
|
456
|
+
export interface SandboxConfig {
|
|
457
|
+
/** Sandbox runtime: bun, python, or base shell */
|
|
458
|
+
runtime?: 'bun' | 'python' | 'base';
|
|
459
|
+
/** Sandbox size: small (512MB), medium (1GB), large (2GB) */
|
|
460
|
+
size?: 'small' | 'medium' | 'large';
|
|
461
|
+
/** Environment variables to inject into the sandbox */
|
|
462
|
+
env?: Record<string, string>;
|
|
463
|
+
/** Whether sandbox is ephemeral (destroyed on kill). Default: true */
|
|
464
|
+
ephemeral?: boolean;
|
|
465
|
+
/** Max session duration in ms */
|
|
466
|
+
maxDurationMs?: number;
|
|
467
|
+
/** Preferred region for sandbox placement */
|
|
468
|
+
region?: string;
|
|
469
|
+
/** Optional powerhouse capabilities to prelaunch inside the sandbox */
|
|
470
|
+
capabilities?: SandboxCapabilities;
|
|
471
|
+
/** Ask the API to wait briefly for the sandbox to become ready before returning */
|
|
472
|
+
waitUntilReady?: boolean;
|
|
473
|
+
/** Max time in ms for the API to wait before falling back to a queued response */
|
|
474
|
+
waitTimeoutMs?: number;
|
|
475
|
+
/** Timeout in ms to wait for sandbox to become ready (default: 30000) */
|
|
476
|
+
timeout?: number;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export interface SandboxCapabilities {
|
|
480
|
+
opencode?: {
|
|
481
|
+
enabled: boolean;
|
|
482
|
+
defaultAgent?: "build" | "plan";
|
|
483
|
+
allowPromptApi?: boolean;
|
|
484
|
+
};
|
|
485
|
+
browser?: {
|
|
486
|
+
mode?: "none" | "paired-banata-browser" | "local-chromium";
|
|
487
|
+
recording?: boolean;
|
|
488
|
+
persistentProfile?: boolean;
|
|
489
|
+
streamPreview?: boolean;
|
|
490
|
+
humanInLoop?: boolean;
|
|
491
|
+
};
|
|
492
|
+
documents?: {
|
|
493
|
+
libreofficeHeadless?: boolean;
|
|
494
|
+
};
|
|
495
|
+
storage?: {
|
|
496
|
+
workspace?: "ephemeral" | "checkpointed";
|
|
497
|
+
artifactPrefix?: string;
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export interface SandboxCredentialDescriptor {
|
|
502
|
+
name: string;
|
|
503
|
+
kind: string;
|
|
504
|
+
target: string;
|
|
505
|
+
lastInjectedAt?: number;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface SandboxArtifactItem {
|
|
509
|
+
key: string;
|
|
510
|
+
path: string;
|
|
511
|
+
kind: string;
|
|
512
|
+
createdAt: number;
|
|
513
|
+
contentType?: string;
|
|
514
|
+
sizeBytes?: number;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface SandboxArtifacts {
|
|
518
|
+
workspaceKey?: string;
|
|
519
|
+
checkpointKey?: string;
|
|
520
|
+
manifestKey?: string;
|
|
521
|
+
outputLogKey?: string;
|
|
522
|
+
items?: SandboxArtifactItem[];
|
|
523
|
+
updatedAt?: number;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export interface SandboxPairedBrowser {
|
|
527
|
+
sessionId?: string;
|
|
528
|
+
cdpUrl?: string;
|
|
529
|
+
previewUrl?: string;
|
|
530
|
+
recording?: boolean;
|
|
531
|
+
persistentProfile?: boolean;
|
|
532
|
+
controlMode?: "ai" | "human" | "shared";
|
|
533
|
+
controller?: string;
|
|
534
|
+
handoffRequestedAt?: number;
|
|
535
|
+
updatedAt?: number;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export interface SandboxOpencodeState {
|
|
539
|
+
status: "disabled" | "booting" | "ready" | "failed";
|
|
540
|
+
serverBaseUrl?: string;
|
|
541
|
+
sessionId?: string;
|
|
542
|
+
defaultAgent?: "build" | "plan";
|
|
543
|
+
lastPromptAt?: number;
|
|
544
|
+
lastError?: string;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export interface SandboxBrowserPreviewState {
|
|
548
|
+
status:
|
|
549
|
+
| "disabled"
|
|
550
|
+
| "provisioning"
|
|
551
|
+
| "ready"
|
|
552
|
+
| "active"
|
|
553
|
+
| "handoff_requested"
|
|
554
|
+
| "released"
|
|
555
|
+
| "failed";
|
|
556
|
+
publicUrl?: string;
|
|
557
|
+
websocketUrl?: string;
|
|
558
|
+
controlMode?: "ai" | "human" | "shared";
|
|
559
|
+
controller?: string;
|
|
560
|
+
leaseExpiresAt?: number;
|
|
561
|
+
updatedAt?: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export type SandboxHumanHandoffReason =
|
|
565
|
+
| "mfa"
|
|
566
|
+
| "captcha"
|
|
567
|
+
| "approval"
|
|
568
|
+
| "login_failed"
|
|
569
|
+
| "ambiguous_ui"
|
|
570
|
+
| "file_download"
|
|
571
|
+
| "custom";
|
|
572
|
+
|
|
573
|
+
export type SandboxHumanHandoffStatus =
|
|
574
|
+
| "pending"
|
|
575
|
+
| "accepted"
|
|
576
|
+
| "completed"
|
|
577
|
+
| "cancelled"
|
|
578
|
+
| "expired";
|
|
579
|
+
|
|
580
|
+
export interface SandboxHumanHandoffState {
|
|
581
|
+
requestId: string;
|
|
582
|
+
status: SandboxHumanHandoffStatus;
|
|
583
|
+
reason: SandboxHumanHandoffReason;
|
|
584
|
+
message: string;
|
|
585
|
+
requestedBy: "opencode" | "worker" | "sdk" | "dashboard";
|
|
586
|
+
controller?: string;
|
|
587
|
+
resumePrompt?: string;
|
|
588
|
+
note?: string;
|
|
589
|
+
requestedAt: number;
|
|
590
|
+
acceptedAt?: number;
|
|
591
|
+
completedAt?: number;
|
|
592
|
+
expiresAt?: number;
|
|
593
|
+
updatedAt: number;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export interface SandboxSession {
|
|
597
|
+
id: string;
|
|
598
|
+
status: 'queued' | 'assigning' | 'ready' | 'active' | 'ending' | 'ended' | 'failed' | 'pausing' | 'paused';
|
|
599
|
+
waitTimedOut?: boolean;
|
|
600
|
+
runtime: 'bun' | 'python' | 'base';
|
|
601
|
+
size: 'small' | 'medium' | 'large';
|
|
602
|
+
region?: string | null;
|
|
603
|
+
terminalUrl: string | null;
|
|
604
|
+
previewBaseUrl?: string | null;
|
|
605
|
+
capabilities?: SandboxCapabilities | null;
|
|
606
|
+
credentialDescriptors?: SandboxCredentialDescriptor[] | null;
|
|
607
|
+
pairedBrowser?: SandboxPairedBrowser | null;
|
|
608
|
+
opencode?: SandboxOpencodeState | null;
|
|
609
|
+
browserPreview?: SandboxBrowserPreviewState | null;
|
|
610
|
+
humanHandoff?: SandboxHumanHandoffState | null;
|
|
611
|
+
artifacts?: SandboxArtifacts | null;
|
|
612
|
+
createdAt: number;
|
|
613
|
+
duration: number | null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface SandboxListResponse {
|
|
617
|
+
items: SandboxSession[];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface SandboxWorkerRuntimeState {
|
|
621
|
+
sessionId: string | null;
|
|
622
|
+
hasSession: boolean;
|
|
623
|
+
opencode: SandboxOpencodeState | null;
|
|
624
|
+
browserPreview: SandboxBrowserPreviewState | null;
|
|
625
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
626
|
+
artifacts: SandboxArtifacts | null;
|
|
627
|
+
humanHandoff: SandboxHumanHandoffState | null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export interface SandboxOpencodePromptResponse {
|
|
631
|
+
ok: boolean;
|
|
632
|
+
sessionId?: string;
|
|
633
|
+
response?: Record<string, unknown> | null;
|
|
634
|
+
error?: string;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export interface SandboxCheckpointResponse {
|
|
638
|
+
ok: boolean;
|
|
639
|
+
artifacts?: SandboxArtifacts;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export interface SandboxArtifactDownload {
|
|
643
|
+
id: string;
|
|
644
|
+
key: string;
|
|
645
|
+
url: string;
|
|
646
|
+
expiresInSeconds: number;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export interface SandboxBrowserControlResponse {
|
|
650
|
+
ok: boolean;
|
|
651
|
+
preview: SandboxBrowserPreviewState;
|
|
652
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface SandboxHumanHandoffResponse {
|
|
656
|
+
ok: boolean;
|
|
657
|
+
handoff: SandboxHumanHandoffState;
|
|
658
|
+
preview: SandboxBrowserPreviewState | null;
|
|
659
|
+
pairedBrowser?: SandboxPairedBrowser | null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export interface SandboxHumanHandoffCompleteResponse {
|
|
663
|
+
ok: boolean;
|
|
664
|
+
handoff: SandboxHumanHandoffState;
|
|
665
|
+
preview: SandboxBrowserPreviewState | null;
|
|
666
|
+
pairedBrowser?: SandboxPairedBrowser | null;
|
|
667
|
+
resume?: SandboxOpencodePromptResponse;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface ExecResult {
|
|
671
|
+
stdout: string;
|
|
672
|
+
stderr: string;
|
|
673
|
+
exitCode: number;
|
|
674
|
+
durationMs: number;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export interface FsEntry {
|
|
678
|
+
name: string;
|
|
679
|
+
type: 'file' | 'directory';
|
|
680
|
+
size: number;
|
|
681
|
+
mtime: number;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export interface LaunchedSandbox {
|
|
685
|
+
sessionId: string;
|
|
686
|
+
terminalUrl: string;
|
|
687
|
+
exec: (command: string, args?: string[]) => Promise<ExecResult>;
|
|
688
|
+
runCode: (code: string) => Promise<ExecResult>;
|
|
689
|
+
prompt: (
|
|
690
|
+
prompt: string,
|
|
691
|
+
options?: {
|
|
692
|
+
agent?: "build" | "plan";
|
|
693
|
+
sessionId?: string;
|
|
694
|
+
noReply?: boolean;
|
|
695
|
+
},
|
|
696
|
+
) => Promise<SandboxOpencodePromptResponse>;
|
|
697
|
+
checkpoint: () => Promise<SandboxCheckpointResponse>;
|
|
698
|
+
getRuntime: () => Promise<SandboxRuntimeState>;
|
|
699
|
+
getPreview: () => Promise<SandboxBrowserPreviewInfo>;
|
|
700
|
+
getHandoff: () => Promise<SandboxHumanHandoffInfo>;
|
|
701
|
+
browserPreviewUrl: string | null;
|
|
702
|
+
setControl: (
|
|
703
|
+
mode: "ai" | "human" | "shared",
|
|
704
|
+
options?: { controller?: string; leaseMs?: number },
|
|
705
|
+
) => Promise<SandboxBrowserControlResponse>;
|
|
706
|
+
takeControl: (
|
|
707
|
+
options?: { controller?: string; leaseMs?: number },
|
|
708
|
+
) => Promise<SandboxBrowserControlResponse>;
|
|
709
|
+
returnControl: (
|
|
710
|
+
options?: { controller?: string; leaseMs?: number },
|
|
711
|
+
) => Promise<SandboxBrowserControlResponse>;
|
|
712
|
+
requestHumanHandoff: (
|
|
713
|
+
options: {
|
|
714
|
+
reason: SandboxHumanHandoffReason;
|
|
715
|
+
message: string;
|
|
716
|
+
resumePrompt?: string;
|
|
717
|
+
expiresInMs?: number;
|
|
718
|
+
},
|
|
719
|
+
) => Promise<SandboxHumanHandoffResponse>;
|
|
720
|
+
acceptHumanHandoff: (
|
|
721
|
+
options: { controller: string; leaseMs?: number },
|
|
722
|
+
) => Promise<SandboxHumanHandoffResponse>;
|
|
723
|
+
completeHumanHandoff: (
|
|
724
|
+
options?: {
|
|
725
|
+
controller?: string;
|
|
726
|
+
note?: string;
|
|
727
|
+
returnControlTo?: "ai" | "shared";
|
|
728
|
+
runResumePrompt?: boolean;
|
|
729
|
+
},
|
|
730
|
+
) => Promise<SandboxHumanHandoffCompleteResponse>;
|
|
731
|
+
fs: {
|
|
732
|
+
read: (path: string) => Promise<string>;
|
|
733
|
+
write: (path: string, content: string) => Promise<void>;
|
|
734
|
+
list: (path?: string) => Promise<FsEntry[]>;
|
|
735
|
+
};
|
|
736
|
+
kill: () => Promise<void>;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export interface SandboxRuntimeState {
|
|
740
|
+
id: string;
|
|
741
|
+
status: SandboxSession["status"];
|
|
742
|
+
capabilities: SandboxCapabilities | null;
|
|
743
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
744
|
+
opencode: SandboxOpencodeState | null;
|
|
745
|
+
browserPreview: SandboxBrowserPreviewState | null;
|
|
746
|
+
humanHandoff: SandboxHumanHandoffState | null;
|
|
747
|
+
artifacts: SandboxArtifacts | null;
|
|
748
|
+
runtime: SandboxWorkerRuntimeState | null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export interface SandboxBrowserPreviewInfo {
|
|
752
|
+
id: string;
|
|
753
|
+
browserPreviewUrl: string | null;
|
|
754
|
+
previewBaseUrl: string | null;
|
|
755
|
+
browserPreview: SandboxBrowserPreviewState | null;
|
|
756
|
+
humanHandoff: SandboxHumanHandoffState | null;
|
|
757
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
758
|
+
runtime: {
|
|
759
|
+
preview: SandboxBrowserPreviewState | null;
|
|
760
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
761
|
+
websocketUrl: string | null;
|
|
762
|
+
humanHandoff: SandboxHumanHandoffState | null;
|
|
763
|
+
} | null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export interface SandboxHumanHandoffInfo {
|
|
767
|
+
id: string;
|
|
768
|
+
humanHandoff: SandboxHumanHandoffState | null;
|
|
769
|
+
browserPreview: SandboxBrowserPreviewState | null;
|
|
770
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
771
|
+
runtime: {
|
|
772
|
+
handoff: SandboxHumanHandoffState | null;
|
|
773
|
+
preview: SandboxBrowserPreviewState | null;
|
|
774
|
+
pairedBrowser: SandboxPairedBrowser | null;
|
|
775
|
+
} | null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export class BanataSandbox {
|
|
779
|
+
private apiKey: string;
|
|
780
|
+
private baseUrl: string;
|
|
781
|
+
private retryConfig: Required<RetryConfig>;
|
|
782
|
+
|
|
783
|
+
constructor(config: { apiKey: string; baseUrl?: string; retry?: RetryConfig }) {
|
|
784
|
+
if (!config.apiKey) throw new Error('API key is required');
|
|
785
|
+
this.apiKey = config.apiKey;
|
|
786
|
+
this.baseUrl = config.baseUrl ?? 'https://api.banata.dev';
|
|
787
|
+
this.retryConfig = {
|
|
788
|
+
maxRetries: config.retry?.maxRetries ?? 3,
|
|
789
|
+
baseDelayMs: config.retry?.baseDelayMs ?? 500,
|
|
790
|
+
maxDelayMs: config.retry?.maxDelayMs ?? 10_000,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private get headers(): Record<string, string> {
|
|
795
|
+
return {
|
|
796
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
797
|
+
'Content-Type': 'application/json',
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async request<T>(
|
|
802
|
+
path: string,
|
|
803
|
+
options: RequestInit = {}
|
|
804
|
+
): Promise<T> {
|
|
805
|
+
const { maxRetries, baseDelayMs, maxDelayMs } = this.retryConfig;
|
|
806
|
+
let lastError: BanataError | Error | undefined;
|
|
807
|
+
|
|
808
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
809
|
+
try {
|
|
810
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
811
|
+
...options,
|
|
812
|
+
headers: { ...this.headers, ...options.headers },
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
if (!res.ok) {
|
|
816
|
+
const body = await res.text();
|
|
817
|
+
let message: string;
|
|
818
|
+
let parsed: Record<string, unknown> = {};
|
|
819
|
+
try {
|
|
820
|
+
parsed = JSON.parse(body);
|
|
821
|
+
message = (parsed.error as string) ?? body;
|
|
822
|
+
} catch {
|
|
823
|
+
message = body;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const error = new BanataError(message, res.status, {
|
|
827
|
+
code: parsed.code as string | undefined,
|
|
828
|
+
requiredPlan: parsed.requiredPlan as string | undefined,
|
|
829
|
+
currentPlan: parsed.currentPlan as string | undefined,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
if (isRetryableStatus(res.status) && attempt < maxRetries) {
|
|
833
|
+
lastError = error;
|
|
834
|
+
const retryAfterHeader = res.headers.get('Retry-After');
|
|
835
|
+
let delay: number;
|
|
836
|
+
if (retryAfterHeader) {
|
|
837
|
+
delay = Math.min(parseInt(retryAfterHeader, 10) * 1000, maxDelayMs);
|
|
838
|
+
} else {
|
|
839
|
+
delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
840
|
+
}
|
|
841
|
+
delay += Math.random() * delay * 0.25;
|
|
842
|
+
await sleep(delay);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Handle 204 No Content (e.g. DELETE responses)
|
|
850
|
+
if (res.status === 204) {
|
|
851
|
+
return undefined as T;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return res.json() as Promise<T>;
|
|
855
|
+
} catch (err) {
|
|
856
|
+
if (err instanceof BanataError) {
|
|
857
|
+
throw err;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
861
|
+
if (attempt < maxRetries) {
|
|
862
|
+
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
863
|
+
await sleep(delay + Math.random() * delay * 0.25);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
throw new BanataError(
|
|
868
|
+
`Network error after ${maxRetries + 1} attempts: ${lastError.message}`,
|
|
869
|
+
0,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
throw lastError ?? new Error('Request failed');
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ── Sandbox lifecycle ──
|
|
878
|
+
|
|
879
|
+
/** Create a new sandbox session */
|
|
880
|
+
async create(config: SandboxConfig = {}): Promise<SandboxSession> {
|
|
881
|
+
const { timeout, waitTimeoutMs, ...rest } = config;
|
|
882
|
+
return this.request<SandboxSession>('/v1/sandboxes', {
|
|
883
|
+
method: 'POST',
|
|
884
|
+
body: JSON.stringify({
|
|
885
|
+
...rest,
|
|
886
|
+
waitTimeoutMs: waitTimeoutMs ?? timeout,
|
|
887
|
+
}),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Get sandbox session status */
|
|
892
|
+
async get(id: string): Promise<SandboxSession> {
|
|
893
|
+
return this.request<SandboxSession>(
|
|
894
|
+
`/v1/sandboxes?id=${encodeURIComponent(id)}`
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** List sandbox sessions available to the API key's organization */
|
|
899
|
+
async list(): Promise<SandboxSession[]> {
|
|
900
|
+
const response = await this.request<SandboxListResponse>("/v1/sandboxes");
|
|
901
|
+
return response.items;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Kill (destroy) a sandbox session */
|
|
905
|
+
async kill(id: string): Promise<void> {
|
|
906
|
+
await this.request(`/v1/sandboxes?id=${encodeURIComponent(id)}`, {
|
|
907
|
+
method: 'DELETE',
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Wait until sandbox is ready and return the session */
|
|
912
|
+
async waitForReady(
|
|
913
|
+
id: string,
|
|
914
|
+
timeoutMs: number = 30_000
|
|
915
|
+
): Promise<SandboxSession> {
|
|
916
|
+
const start = Date.now();
|
|
917
|
+
|
|
918
|
+
while (Date.now() - start < timeoutMs) {
|
|
919
|
+
const session = await this.get(id);
|
|
920
|
+
|
|
921
|
+
if (session.status === 'ready' || session.status === 'active') {
|
|
922
|
+
return session;
|
|
923
|
+
}
|
|
924
|
+
if (session.status === 'failed') {
|
|
925
|
+
throw new BanataError('Sandbox failed to start', 500);
|
|
926
|
+
}
|
|
927
|
+
if (session.status === 'ended' || session.status === 'ending') {
|
|
928
|
+
throw new BanataError('Sandbox ended before becoming ready', 410);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
await sleep(500);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
throw new BanataError(
|
|
935
|
+
`Sandbox ${id} not ready within ${timeoutMs}ms`,
|
|
936
|
+
408
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Execute a command in the sandbox */
|
|
941
|
+
async exec(
|
|
942
|
+
id: string,
|
|
943
|
+
command: string,
|
|
944
|
+
args?: string[]
|
|
945
|
+
): Promise<ExecResult> {
|
|
946
|
+
return this.request<ExecResult>('/v1/sandboxes/exec', {
|
|
947
|
+
method: 'POST',
|
|
948
|
+
body: JSON.stringify({ id, command, args }),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Run code in the sandbox using its configured runtime */
|
|
953
|
+
async runCode(id: string, code: string): Promise<ExecResult> {
|
|
954
|
+
return this.request<ExecResult>('/v1/sandboxes/code', {
|
|
955
|
+
method: 'POST',
|
|
956
|
+
body: JSON.stringify({ id, code }),
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Pause a sandbox (preserves state, stops billing) */
|
|
961
|
+
async pause(id: string): Promise<void> {
|
|
962
|
+
await this.request('/v1/sandboxes/pause', {
|
|
963
|
+
method: 'POST',
|
|
964
|
+
body: JSON.stringify({ id }),
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Resume a paused sandbox */
|
|
969
|
+
async resume(id: string): Promise<void> {
|
|
970
|
+
await this.request('/v1/sandboxes/resume', {
|
|
971
|
+
method: 'POST',
|
|
972
|
+
body: JSON.stringify({ id }),
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ── File system operations ──
|
|
977
|
+
|
|
978
|
+
readonly fs = {
|
|
979
|
+
/** Read a file from the sandbox */
|
|
980
|
+
read: async (id: string, path: string): Promise<string> => {
|
|
981
|
+
const result = await this.request<{ content: string }>(
|
|
982
|
+
`/v1/sandboxes/fs/read?id=${encodeURIComponent(id)}&path=${encodeURIComponent(path)}`
|
|
983
|
+
);
|
|
984
|
+
return result.content;
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
/** Write a file to the sandbox */
|
|
988
|
+
write: async (id: string, path: string, content: string): Promise<void> => {
|
|
989
|
+
await this.request('/v1/sandboxes/fs/write', {
|
|
990
|
+
method: 'POST',
|
|
991
|
+
body: JSON.stringify({ id, path, content }),
|
|
992
|
+
});
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
/** List files in a directory in the sandbox */
|
|
996
|
+
list: async (id: string, path?: string): Promise<FsEntry[]> => {
|
|
997
|
+
return this.request<FsEntry[]>(
|
|
998
|
+
`/v1/sandboxes/fs/list?id=${encodeURIComponent(id)}&path=${encodeURIComponent(path ?? '/workspace')}`
|
|
999
|
+
);
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
/** Get terminal connection info for a sandbox */
|
|
1004
|
+
async terminal(id: string): Promise<{ terminalUrl: string | null; token: string | null }> {
|
|
1005
|
+
return this.request<{ terminalUrl: string | null; token: string | null }>(
|
|
1006
|
+
`/v1/sandboxes/terminal?id=${encodeURIComponent(id)}`
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async getRuntime(id: string): Promise<SandboxRuntimeState> {
|
|
1011
|
+
return this.request<SandboxRuntimeState>(
|
|
1012
|
+
`/v1/sandboxes/runtime?id=${encodeURIComponent(id)}`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async prompt(
|
|
1017
|
+
id: string,
|
|
1018
|
+
prompt: string,
|
|
1019
|
+
options: {
|
|
1020
|
+
agent?: "build" | "plan";
|
|
1021
|
+
sessionId?: string;
|
|
1022
|
+
noReply?: boolean;
|
|
1023
|
+
} = {},
|
|
1024
|
+
): Promise<SandboxOpencodePromptResponse> {
|
|
1025
|
+
return this.request<SandboxOpencodePromptResponse>('/v1/sandboxes/opencode/prompt', {
|
|
1026
|
+
method: 'POST',
|
|
1027
|
+
body: JSON.stringify({
|
|
1028
|
+
id,
|
|
1029
|
+
prompt,
|
|
1030
|
+
agent: options.agent,
|
|
1031
|
+
sessionId: options.sessionId,
|
|
1032
|
+
noReply: options.noReply,
|
|
1033
|
+
}),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async checkpoint(id: string): Promise<SandboxCheckpointResponse> {
|
|
1038
|
+
return this.request<SandboxCheckpointResponse>('/v1/sandboxes/checkpoint', {
|
|
1039
|
+
method: 'POST',
|
|
1040
|
+
body: JSON.stringify({ id }),
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async getArtifacts(id: string): Promise<{ id: string; artifacts: SandboxArtifacts | null }> {
|
|
1045
|
+
return this.request(
|
|
1046
|
+
`/v1/sandboxes/artifacts?id=${encodeURIComponent(id)}`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async getArtifactDownloadUrl(
|
|
1051
|
+
id: string,
|
|
1052
|
+
key: string,
|
|
1053
|
+
expiresInSeconds = 3600,
|
|
1054
|
+
): Promise<SandboxArtifactDownload> {
|
|
1055
|
+
return this.request<SandboxArtifactDownload>(
|
|
1056
|
+
`/v1/sandboxes/artifacts/download?id=${encodeURIComponent(id)}&key=${encodeURIComponent(key)}&expiresIn=${encodeURIComponent(String(expiresInSeconds))}`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async getPreview(id: string): Promise<SandboxBrowserPreviewInfo> {
|
|
1061
|
+
return this.request<SandboxBrowserPreviewInfo>(
|
|
1062
|
+
`/v1/sandboxes/browser-preview?id=${encodeURIComponent(id)}`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async getHandoff(id: string): Promise<SandboxHumanHandoffInfo> {
|
|
1067
|
+
return this.request<SandboxHumanHandoffInfo>(
|
|
1068
|
+
`/v1/sandboxes/handoff?id=${encodeURIComponent(id)}`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async requestHumanHandoff(
|
|
1073
|
+
id: string,
|
|
1074
|
+
options: {
|
|
1075
|
+
reason: SandboxHumanHandoffReason;
|
|
1076
|
+
message: string;
|
|
1077
|
+
resumePrompt?: string;
|
|
1078
|
+
expiresInMs?: number;
|
|
1079
|
+
},
|
|
1080
|
+
): Promise<SandboxHumanHandoffResponse> {
|
|
1081
|
+
return this.request<SandboxHumanHandoffResponse>('/v1/sandboxes/handoff/request', {
|
|
1082
|
+
method: 'POST',
|
|
1083
|
+
body: JSON.stringify({
|
|
1084
|
+
id,
|
|
1085
|
+
reason: options.reason,
|
|
1086
|
+
message: options.message,
|
|
1087
|
+
resumePrompt: options.resumePrompt,
|
|
1088
|
+
expiresInMs: options.expiresInMs,
|
|
1089
|
+
}),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async acceptHumanHandoff(
|
|
1094
|
+
id: string,
|
|
1095
|
+
options: { controller: string; leaseMs?: number },
|
|
1096
|
+
): Promise<SandboxHumanHandoffResponse> {
|
|
1097
|
+
return this.request<SandboxHumanHandoffResponse>('/v1/sandboxes/handoff/accept', {
|
|
1098
|
+
method: 'POST',
|
|
1099
|
+
body: JSON.stringify({
|
|
1100
|
+
id,
|
|
1101
|
+
controller: options.controller,
|
|
1102
|
+
leaseMs: options.leaseMs,
|
|
1103
|
+
}),
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async completeHumanHandoff(
|
|
1108
|
+
id: string,
|
|
1109
|
+
options: {
|
|
1110
|
+
controller?: string;
|
|
1111
|
+
note?: string;
|
|
1112
|
+
returnControlTo?: "ai" | "shared";
|
|
1113
|
+
runResumePrompt?: boolean;
|
|
1114
|
+
} = {},
|
|
1115
|
+
): Promise<SandboxHumanHandoffCompleteResponse> {
|
|
1116
|
+
return this.request<SandboxHumanHandoffCompleteResponse>('/v1/sandboxes/handoff/complete', {
|
|
1117
|
+
method: 'POST',
|
|
1118
|
+
body: JSON.stringify({
|
|
1119
|
+
id,
|
|
1120
|
+
controller: options.controller,
|
|
1121
|
+
note: options.note,
|
|
1122
|
+
returnControlTo: options.returnControlTo,
|
|
1123
|
+
runResumePrompt: options.runResumePrompt,
|
|
1124
|
+
}),
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async setControl(
|
|
1129
|
+
id: string,
|
|
1130
|
+
mode: "ai" | "human" | "shared",
|
|
1131
|
+
options: { controller?: string; leaseMs?: number } = {},
|
|
1132
|
+
): Promise<SandboxBrowserControlResponse> {
|
|
1133
|
+
return this.request<SandboxBrowserControlResponse>('/v1/sandboxes/browser-preview/control', {
|
|
1134
|
+
method: 'POST',
|
|
1135
|
+
body: JSON.stringify({
|
|
1136
|
+
id,
|
|
1137
|
+
mode,
|
|
1138
|
+
controller: options.controller,
|
|
1139
|
+
leaseMs: options.leaseMs,
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Convenience: Create a sandbox, wait for it to be ready, and return
|
|
1146
|
+
* a rich object with exec/fs/kill methods bound to this session.
|
|
1147
|
+
*
|
|
1148
|
+
* Usage:
|
|
1149
|
+
* ```ts
|
|
1150
|
+
* const sandbox = new BanataSandbox({ apiKey: '...' });
|
|
1151
|
+
* const session = await sandbox.launch({ runtime: 'bun', size: 'small' });
|
|
1152
|
+
*
|
|
1153
|
+
* const result = await session.exec('echo', ['Hello!']);
|
|
1154
|
+
* console.log(result.stdout);
|
|
1155
|
+
*
|
|
1156
|
+
* await session.kill();
|
|
1157
|
+
* ```
|
|
1158
|
+
*/
|
|
1159
|
+
async launch(config: SandboxConfig = {}): Promise<LaunchedSandbox> {
|
|
1160
|
+
const created = await this.create(config);
|
|
1161
|
+
let session: SandboxSession;
|
|
1162
|
+
|
|
1163
|
+
try {
|
|
1164
|
+
session = await this.waitForReady(
|
|
1165
|
+
created.id,
|
|
1166
|
+
config.timeout ?? 30_000
|
|
1167
|
+
);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
// Clean up the orphaned sandbox on timeout/failure
|
|
1170
|
+
try {
|
|
1171
|
+
await this.kill(created.id);
|
|
1172
|
+
} catch {
|
|
1173
|
+
// Best-effort cleanup
|
|
1174
|
+
}
|
|
1175
|
+
throw err;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const sessionId = session.id;
|
|
1179
|
+
const terminalUrl = session.terminalUrl ?? '';
|
|
1180
|
+
const browserPreviewUrl =
|
|
1181
|
+
session.browserPreview?.publicUrl ??
|
|
1182
|
+
session.pairedBrowser?.previewUrl ??
|
|
1183
|
+
null;
|
|
1184
|
+
|
|
1185
|
+
return {
|
|
1186
|
+
sessionId,
|
|
1187
|
+
terminalUrl,
|
|
1188
|
+
browserPreviewUrl,
|
|
1189
|
+
exec: (command: string, args?: string[]) =>
|
|
1190
|
+
this.exec(sessionId, command, args),
|
|
1191
|
+
runCode: (code: string) =>
|
|
1192
|
+
this.runCode(sessionId, code),
|
|
1193
|
+
prompt: (prompt, options) => this.prompt(sessionId, prompt, options),
|
|
1194
|
+
checkpoint: () => this.checkpoint(sessionId),
|
|
1195
|
+
getRuntime: () => this.getRuntime(sessionId),
|
|
1196
|
+
getPreview: () => this.getPreview(sessionId),
|
|
1197
|
+
getHandoff: () => this.getHandoff(sessionId),
|
|
1198
|
+
setControl: (mode, options) => this.setControl(sessionId, mode, options),
|
|
1199
|
+
takeControl: (options) => this.setControl(sessionId, "human", options),
|
|
1200
|
+
returnControl: (options) => this.setControl(sessionId, "ai", options),
|
|
1201
|
+
requestHumanHandoff: (options) =>
|
|
1202
|
+
this.requestHumanHandoff(sessionId, options),
|
|
1203
|
+
acceptHumanHandoff: (options) =>
|
|
1204
|
+
this.acceptHumanHandoff(sessionId, options),
|
|
1205
|
+
completeHumanHandoff: (options) =>
|
|
1206
|
+
this.completeHumanHandoff(sessionId, options),
|
|
1207
|
+
fs: {
|
|
1208
|
+
read: (path: string) => this.fs.read(sessionId, path),
|
|
1209
|
+
write: (path: string, content: string) =>
|
|
1210
|
+
this.fs.write(sessionId, path, content),
|
|
1211
|
+
list: (path?: string) => this.fs.list(sessionId, path),
|
|
1212
|
+
},
|
|
1213
|
+
kill: () => this.kill(sessionId),
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Default export for convenience
|
|
1219
|
+
export default BrowserCloud;
|