@cloudflare/sandbox 0.8.9 → 0.8.11
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/bridge/index.d.ts +187 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +2499 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/{contexts-DqnVW04M.d.ts → contexts-icMN26lE.d.ts} +1 -1
- package/dist/{contexts-DqnVW04M.d.ts.map → contexts-icMN26lE.d.ts.map} +1 -1
- package/dist/{dist-CR1a2zcN.js → dist-Ilf8VjmX.js} +1 -1
- package/dist/{dist-CR1a2zcN.js.map → dist-Ilf8VjmX.js.map} +1 -1
- package/dist/{errors-DJtO4mmS.js → errors-Bz21XTBJ.js} +1 -1
- package/dist/{errors-DJtO4mmS.js.map → errors-Bz21XTBJ.js.map} +1 -1
- package/dist/file-stream-Bn2PceyF.js +6262 -0
- package/dist/file-stream-Bn2PceyF.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -6279
- package/dist/openai/index.d.ts +1 -1
- package/dist/openai/index.js +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/opencode/index.js +2 -2
- package/dist/{sandbox-KLljXK8V.d.ts → sandbox-C0Tjs0dj.d.ts} +5 -7
- package/dist/{sandbox-KLljXK8V.d.ts.map → sandbox-C0Tjs0dj.d.ts.map} +1 -1
- package/package.json +8 -2
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,2499 @@
|
|
|
1
|
+
import "../dist-Ilf8VjmX.js";
|
|
2
|
+
import "../errors-Bz21XTBJ.js";
|
|
3
|
+
import { i as getSandbox, n as streamFile } from "../file-stream-Bn2PceyF.js";
|
|
4
|
+
import { DurableObject, env } from "cloudflare:workers";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
|
|
7
|
+
//#region src/bridge/pool.ts
|
|
8
|
+
/**
|
|
9
|
+
* Prime the warm pool — pushes current configuration to the WarmPool
|
|
10
|
+
* Durable Object so it starts its alarm loop.
|
|
11
|
+
*
|
|
12
|
+
* Called by the scheduled() handler and by POST /pool/prime.
|
|
13
|
+
*/
|
|
14
|
+
async function primePool(env$1, warmPoolBinding) {
|
|
15
|
+
const warmTarget = Number.parseInt(env$1.WARM_POOL_TARGET || "0", 10) || 0;
|
|
16
|
+
const refreshInterval = Number.parseInt(env$1.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
17
|
+
const ns = env$1[warmPoolBinding];
|
|
18
|
+
const poolId = ns.idFromName("global-pool");
|
|
19
|
+
await ns.get(poolId).configure({
|
|
20
|
+
warmTarget,
|
|
21
|
+
refreshInterval
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/bridge/helpers.ts
|
|
27
|
+
/**
|
|
28
|
+
* Utility functions used by the bridge routes.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* UTF-8-safe base64 encoding.
|
|
32
|
+
* btoa() only handles latin-1; encode to UTF-8 bytes first via TextEncoder.
|
|
33
|
+
*/
|
|
34
|
+
function toBase64(str) {
|
|
35
|
+
const bytes = new TextEncoder().encode(str);
|
|
36
|
+
let binary = "";
|
|
37
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
38
|
+
return btoa(binary);
|
|
39
|
+
}
|
|
40
|
+
/** RFC 4648 base32 encoding (lowercase). Returns only [a-z2-7]. */
|
|
41
|
+
function base32Encode(data) {
|
|
42
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
43
|
+
let bits = 0;
|
|
44
|
+
let value = 0;
|
|
45
|
+
let out = "";
|
|
46
|
+
for (const byte of data) {
|
|
47
|
+
value = value << 8 | byte;
|
|
48
|
+
bits += 8;
|
|
49
|
+
while (bits >= 5) {
|
|
50
|
+
out += alphabet[value >>> bits - 5 & 31];
|
|
51
|
+
bits -= 5;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (bits > 0) out += alphabet[value << 5 - bits & 31];
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function errorJson(error, code, status) {
|
|
58
|
+
const body = {
|
|
59
|
+
error,
|
|
60
|
+
code
|
|
61
|
+
};
|
|
62
|
+
return new Response(JSON.stringify(body), {
|
|
63
|
+
status,
|
|
64
|
+
headers: { "Content-Type": "application/json" }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Shell-quote a single argv token so it is safe to embed in a sh command
|
|
69
|
+
* string. Tokens that contain only safe characters are returned unchanged
|
|
70
|
+
* for readability. All others are wrapped in ANSI-C $'...' quoting which
|
|
71
|
+
* can represent newlines, tabs, and other control characters as escape
|
|
72
|
+
* sequences — unlike plain single quotes which pass content literally and
|
|
73
|
+
* break when the value contains a real newline.
|
|
74
|
+
*/
|
|
75
|
+
function shellQuote(arg) {
|
|
76
|
+
if (/^[A-Za-z0-9@%+=:,./-]+$/.test(arg)) return arg;
|
|
77
|
+
return "$'" + arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + "'";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* POSIX-normalise a path (resolve `.` / `..` segments) and verify it lives
|
|
81
|
+
* under /workspace. Returns the resolved absolute path on success, or null
|
|
82
|
+
* if the path escapes the workspace.
|
|
83
|
+
*/
|
|
84
|
+
function resolveWorkspacePath(userPath) {
|
|
85
|
+
const abs = userPath.startsWith("/") ? userPath : `/workspace/${userPath}`;
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const seg of abs.split("/")) {
|
|
88
|
+
if (seg === "" || seg === ".") continue;
|
|
89
|
+
if (seg === "..") parts.pop();
|
|
90
|
+
else parts.push(seg);
|
|
91
|
+
}
|
|
92
|
+
const resolved = "/" + parts.join("/");
|
|
93
|
+
if (resolved === "/workspace" || resolved.startsWith("/workspace/")) return resolved;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Validate a session ID. Rejects path traversal, control chars, and excessive length.
|
|
98
|
+
* Returns the validated ID or null if invalid.
|
|
99
|
+
*/
|
|
100
|
+
function validateSessionId(id) {
|
|
101
|
+
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(id)) return null;
|
|
102
|
+
if (id.includes("..")) return null;
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Convert an SSE stream from readFileStream() into a raw byte stream.
|
|
107
|
+
* Decodes base64 chunks for binary files and UTF-8-encodes text chunks.
|
|
108
|
+
*/
|
|
109
|
+
function sseToByteStream(sse) {
|
|
110
|
+
const encoder = new TextEncoder();
|
|
111
|
+
return new ReadableStream({ async start(controller) {
|
|
112
|
+
try {
|
|
113
|
+
for await (const chunk of streamFile(sse)) controller.enqueue(chunk instanceof Uint8Array ? chunk : encoder.encode(chunk));
|
|
114
|
+
controller.close();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
controller.error(err);
|
|
117
|
+
}
|
|
118
|
+
} });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/bridge/openapi.ts
|
|
123
|
+
/**
|
|
124
|
+
* OpenAPI 3.1 schema for the Cloudflare Sandbox Service API.
|
|
125
|
+
*
|
|
126
|
+
* Served at GET /v1/openapi.json (requires Bearer token auth).
|
|
127
|
+
*/
|
|
128
|
+
const OPENAPI_SCHEMA = {
|
|
129
|
+
openapi: "3.1.0",
|
|
130
|
+
info: {
|
|
131
|
+
title: "Cloudflare Sandbox Service API",
|
|
132
|
+
version: "1.0.0",
|
|
133
|
+
description: "HTTP API consumed by the Python `CloudflareSandboxClient`. Forwards each operation to a named Cloudflare Sandbox Durable Object via the `@cloudflare/sandbox` SDK."
|
|
134
|
+
},
|
|
135
|
+
components: {
|
|
136
|
+
securitySchemes: { BearerAuth: {
|
|
137
|
+
type: "http",
|
|
138
|
+
scheme: "bearer",
|
|
139
|
+
description: "API token set via `wrangler secret put SANDBOX_API_KEY`. The /openapi.* routes also accept the token as a `?token=` query parameter."
|
|
140
|
+
} },
|
|
141
|
+
schemas: {
|
|
142
|
+
ExecRequest: {
|
|
143
|
+
type: "object",
|
|
144
|
+
required: ["argv"],
|
|
145
|
+
properties: {
|
|
146
|
+
argv: {
|
|
147
|
+
type: "array",
|
|
148
|
+
items: { type: "string" },
|
|
149
|
+
minItems: 1,
|
|
150
|
+
description: "Argv array — already shell-expanded by the Python layer if shell=True.",
|
|
151
|
+
example: [
|
|
152
|
+
"sh",
|
|
153
|
+
"-lc",
|
|
154
|
+
"echo hello"
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
timeout_ms: {
|
|
158
|
+
type: "integer",
|
|
159
|
+
description: "Per-call timeout in milliseconds.",
|
|
160
|
+
example: 3e4
|
|
161
|
+
},
|
|
162
|
+
cwd: {
|
|
163
|
+
type: "string",
|
|
164
|
+
description: "Working directory for the command (defaults to sandbox cwd).",
|
|
165
|
+
example: "/workspace"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
WriteResponse: {
|
|
170
|
+
type: "object",
|
|
171
|
+
required: ["ok"],
|
|
172
|
+
properties: { ok: {
|
|
173
|
+
type: "boolean",
|
|
174
|
+
enum: [true],
|
|
175
|
+
description: "Always `true` on success."
|
|
176
|
+
} }
|
|
177
|
+
},
|
|
178
|
+
RunningResponse: {
|
|
179
|
+
type: "object",
|
|
180
|
+
required: ["running"],
|
|
181
|
+
properties: { running: {
|
|
182
|
+
type: "boolean",
|
|
183
|
+
description: "`true` if the sandbox container is alive and responding."
|
|
184
|
+
} }
|
|
185
|
+
},
|
|
186
|
+
OkResponse: {
|
|
187
|
+
type: "object",
|
|
188
|
+
required: ["ok"],
|
|
189
|
+
properties: { ok: {
|
|
190
|
+
type: "boolean",
|
|
191
|
+
enum: [true],
|
|
192
|
+
description: "Always `true` on success."
|
|
193
|
+
} }
|
|
194
|
+
},
|
|
195
|
+
MountBucketCredentials: {
|
|
196
|
+
type: "object",
|
|
197
|
+
required: ["accessKeyId", "secretAccessKey"],
|
|
198
|
+
properties: {
|
|
199
|
+
accessKeyId: {
|
|
200
|
+
type: "string",
|
|
201
|
+
description: "S3-compatible access key ID."
|
|
202
|
+
},
|
|
203
|
+
secretAccessKey: {
|
|
204
|
+
type: "string",
|
|
205
|
+
description: "S3-compatible secret access key."
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
MountBucketRequestOptions: {
|
|
210
|
+
type: "object",
|
|
211
|
+
required: ["endpoint"],
|
|
212
|
+
properties: {
|
|
213
|
+
endpoint: {
|
|
214
|
+
type: "string",
|
|
215
|
+
description: "S3-compatible endpoint URL.",
|
|
216
|
+
example: "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com"
|
|
217
|
+
},
|
|
218
|
+
readOnly: {
|
|
219
|
+
type: "boolean",
|
|
220
|
+
description: "Mount filesystem as read-only (default: false).",
|
|
221
|
+
default: false
|
|
222
|
+
},
|
|
223
|
+
prefix: {
|
|
224
|
+
type: "string",
|
|
225
|
+
description: "Optional prefix/subdirectory within the bucket to mount. Must start and end with `/`.",
|
|
226
|
+
example: "/uploads/images/"
|
|
227
|
+
},
|
|
228
|
+
credentials: {
|
|
229
|
+
$ref: "#/components/schemas/MountBucketCredentials",
|
|
230
|
+
description: "Explicit credentials. When omitted, the SDK auto-detects from Worker secrets (R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY or AWS equivalents)."
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
MountBucketRequest: {
|
|
235
|
+
type: "object",
|
|
236
|
+
required: [
|
|
237
|
+
"bucket",
|
|
238
|
+
"mountPath",
|
|
239
|
+
"options"
|
|
240
|
+
],
|
|
241
|
+
properties: {
|
|
242
|
+
bucket: {
|
|
243
|
+
type: "string",
|
|
244
|
+
description: "Bucket name.",
|
|
245
|
+
example: "my-r2-bucket"
|
|
246
|
+
},
|
|
247
|
+
mountPath: {
|
|
248
|
+
type: "string",
|
|
249
|
+
description: "Absolute path in the container to mount at.",
|
|
250
|
+
example: "/mnt/data"
|
|
251
|
+
},
|
|
252
|
+
options: { $ref: "#/components/schemas/MountBucketRequestOptions" }
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
UnmountBucketRequest: {
|
|
256
|
+
type: "object",
|
|
257
|
+
required: ["mountPath"],
|
|
258
|
+
properties: { mountPath: {
|
|
259
|
+
type: "string",
|
|
260
|
+
description: "Absolute path where the bucket is currently mounted.",
|
|
261
|
+
example: "/mnt/data"
|
|
262
|
+
} }
|
|
263
|
+
},
|
|
264
|
+
ErrorResponse: {
|
|
265
|
+
type: "object",
|
|
266
|
+
required: ["error", "code"],
|
|
267
|
+
properties: {
|
|
268
|
+
error: {
|
|
269
|
+
type: "string",
|
|
270
|
+
description: "Human-readable error description."
|
|
271
|
+
},
|
|
272
|
+
code: {
|
|
273
|
+
type: "string",
|
|
274
|
+
description: "Stable machine-readable error code.",
|
|
275
|
+
enum: [
|
|
276
|
+
"unauthorized",
|
|
277
|
+
"invalid_request",
|
|
278
|
+
"exec_error",
|
|
279
|
+
"exec_transport_error",
|
|
280
|
+
"workspace_read_not_found",
|
|
281
|
+
"workspace_archive_read_error",
|
|
282
|
+
"workspace_archive_write_error",
|
|
283
|
+
"capacity_exceeded",
|
|
284
|
+
"pool_error",
|
|
285
|
+
"mount_error",
|
|
286
|
+
"unmount_error",
|
|
287
|
+
"session_error"
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
PoolStats: {
|
|
293
|
+
type: "object",
|
|
294
|
+
required: [
|
|
295
|
+
"warm",
|
|
296
|
+
"assigned",
|
|
297
|
+
"total",
|
|
298
|
+
"config",
|
|
299
|
+
"maxInstances"
|
|
300
|
+
],
|
|
301
|
+
properties: {
|
|
302
|
+
warm: {
|
|
303
|
+
type: "integer",
|
|
304
|
+
description: "Number of warm (unassigned) containers ready for use."
|
|
305
|
+
},
|
|
306
|
+
assigned: {
|
|
307
|
+
type: "integer",
|
|
308
|
+
description: "Number of containers assigned to sandbox IDs."
|
|
309
|
+
},
|
|
310
|
+
total: {
|
|
311
|
+
type: "integer",
|
|
312
|
+
description: "Total containers tracked by the pool."
|
|
313
|
+
},
|
|
314
|
+
config: {
|
|
315
|
+
type: "object",
|
|
316
|
+
properties: {
|
|
317
|
+
warmTarget: { type: "integer" },
|
|
318
|
+
refreshInterval: { type: "integer" }
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
maxInstances: {
|
|
322
|
+
type: ["integer", "null"],
|
|
323
|
+
description: "Inferred max_instances limit, or null if not yet known."
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
responses: {
|
|
329
|
+
Unauthorized: {
|
|
330
|
+
description: "Missing or invalid Bearer token.",
|
|
331
|
+
content: { "application/json": {
|
|
332
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
333
|
+
example: {
|
|
334
|
+
error: "Unauthorized",
|
|
335
|
+
code: "unauthorized"
|
|
336
|
+
}
|
|
337
|
+
} }
|
|
338
|
+
},
|
|
339
|
+
InvalidRequest: {
|
|
340
|
+
description: "Malformed request body or missing required fields.",
|
|
341
|
+
content: { "application/json": {
|
|
342
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
343
|
+
example: {
|
|
344
|
+
error: "argv must be a non-empty array",
|
|
345
|
+
code: "invalid_request"
|
|
346
|
+
}
|
|
347
|
+
} }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
security: [{ BearerAuth: [] }],
|
|
352
|
+
paths: {
|
|
353
|
+
"/v1/sandbox": { post: {
|
|
354
|
+
operationId: "createSandbox",
|
|
355
|
+
summary: "Create a new sandbox session",
|
|
356
|
+
description: "Generates a new unique sandbox ID. Use this ID with all `/v1/sandbox/{id}/*` routes.",
|
|
357
|
+
"x-codeSamples": [{
|
|
358
|
+
lang: "curl",
|
|
359
|
+
label: "cURL",
|
|
360
|
+
source: "curl -X POST https://$HOST/v1/sandbox \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
361
|
+
}],
|
|
362
|
+
responses: {
|
|
363
|
+
"200": {
|
|
364
|
+
description: "New sandbox session created.",
|
|
365
|
+
content: { "application/json": { schema: {
|
|
366
|
+
type: "object",
|
|
367
|
+
required: ["id"],
|
|
368
|
+
properties: { id: {
|
|
369
|
+
type: "string",
|
|
370
|
+
description: "Unique sandbox ID for use with `/v1/sandbox/{id}/*` routes.",
|
|
371
|
+
example: "mfrggzdfmy2tqnrzgezdgnbv"
|
|
372
|
+
} }
|
|
373
|
+
} } }
|
|
374
|
+
},
|
|
375
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
376
|
+
}
|
|
377
|
+
} },
|
|
378
|
+
"/v1/sandbox/{id}/exec": { post: {
|
|
379
|
+
operationId: "execCommand",
|
|
380
|
+
summary: "Execute a command in the sandbox",
|
|
381
|
+
description: "Runs a shell command inside the named sandbox and streams output as Server-Sent Events (SSE). Events: `stdout` (base64 chunk), `stderr` (base64 chunk), `exit` (JSON with exit_code), `error` (JSON with error and code). The stream terminates after an `exit` or `error` event.",
|
|
382
|
+
"x-codeSamples": [{
|
|
383
|
+
lang: "curl",
|
|
384
|
+
label: "cURL",
|
|
385
|
+
source: "curl -N -X POST https://$HOST/v1/sandbox/my-sandbox/exec \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"argv\":[\"sh\",\"-lc\",\"echo hello\"]}'"
|
|
386
|
+
}],
|
|
387
|
+
parameters: [{
|
|
388
|
+
name: "id",
|
|
389
|
+
in: "path",
|
|
390
|
+
required: true,
|
|
391
|
+
schema: { type: "string" },
|
|
392
|
+
description: "Sandbox instance name (maps to a Durable Object key)."
|
|
393
|
+
}, {
|
|
394
|
+
name: "Session-Id",
|
|
395
|
+
in: "header",
|
|
396
|
+
required: false,
|
|
397
|
+
schema: {
|
|
398
|
+
type: "string",
|
|
399
|
+
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
400
|
+
},
|
|
401
|
+
description: "Scope this operation to a specific session. Uses the default session if omitted."
|
|
402
|
+
}],
|
|
403
|
+
requestBody: {
|
|
404
|
+
required: true,
|
|
405
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ExecRequest" } } }
|
|
406
|
+
},
|
|
407
|
+
responses: {
|
|
408
|
+
"200": {
|
|
409
|
+
description: "SSE stream of command output. Events:\n- `event: stdout` — data is a base64-encoded chunk of stdout\n- `event: stderr` — data is a base64-encoded chunk of stderr\n- `event: exit` — data is JSON `{\"exit_code\": N}` (terminal)\n- `event: error` — data is JSON `{\"error\": \"...\", \"code\": \"...\"}` (terminal)",
|
|
410
|
+
content: { "text/event-stream": { schema: { type: "string" } } }
|
|
411
|
+
},
|
|
412
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
413
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
414
|
+
"403": {
|
|
415
|
+
description: "cwd resolves outside /workspace.",
|
|
416
|
+
content: { "application/json": {
|
|
417
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
418
|
+
example: {
|
|
419
|
+
error: "cwd must resolve to a location within /workspace",
|
|
420
|
+
code: "invalid_request"
|
|
421
|
+
}
|
|
422
|
+
} }
|
|
423
|
+
},
|
|
424
|
+
"502": {
|
|
425
|
+
description: "SDK transport error before the SSE stream could be established. Once the stream is open, errors are delivered as `event: error` SSE events instead.",
|
|
426
|
+
content: { "application/json": {
|
|
427
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
428
|
+
example: {
|
|
429
|
+
error: "exec failed: connection reset",
|
|
430
|
+
code: "exec_transport_error"
|
|
431
|
+
}
|
|
432
|
+
} }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} },
|
|
436
|
+
"/v1/sandbox/{id}/file/{path}": {
|
|
437
|
+
get: {
|
|
438
|
+
operationId: "readFile",
|
|
439
|
+
summary: "Read a file from the sandbox filesystem",
|
|
440
|
+
"x-codeSamples": [{
|
|
441
|
+
lang: "curl",
|
|
442
|
+
label: "cURL",
|
|
443
|
+
source: "curl -X GET https://$HOST/v1/sandbox/my-sandbox/file/workspace/main.py \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o main.py"
|
|
444
|
+
}],
|
|
445
|
+
parameters: [
|
|
446
|
+
{
|
|
447
|
+
name: "id",
|
|
448
|
+
in: "path",
|
|
449
|
+
required: true,
|
|
450
|
+
schema: { type: "string" },
|
|
451
|
+
description: "Sandbox instance name."
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "path",
|
|
455
|
+
in: "path",
|
|
456
|
+
required: true,
|
|
457
|
+
schema: { type: "string" },
|
|
458
|
+
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: "Session-Id",
|
|
462
|
+
in: "header",
|
|
463
|
+
required: false,
|
|
464
|
+
schema: {
|
|
465
|
+
type: "string",
|
|
466
|
+
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
467
|
+
},
|
|
468
|
+
description: "Scope this operation to a specific session. Uses the default session if omitted."
|
|
469
|
+
}
|
|
470
|
+
],
|
|
471
|
+
responses: {
|
|
472
|
+
"200": {
|
|
473
|
+
description: "Raw file bytes.",
|
|
474
|
+
content: { "application/octet-stream": { schema: {
|
|
475
|
+
type: "string",
|
|
476
|
+
format: "binary"
|
|
477
|
+
} } }
|
|
478
|
+
},
|
|
479
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
480
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
481
|
+
"403": {
|
|
482
|
+
description: "Path resolves outside /workspace.",
|
|
483
|
+
content: { "application/json": {
|
|
484
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
485
|
+
example: {
|
|
486
|
+
error: "path must resolve to a location within /workspace",
|
|
487
|
+
code: "invalid_request"
|
|
488
|
+
}
|
|
489
|
+
} }
|
|
490
|
+
},
|
|
491
|
+
"404": {
|
|
492
|
+
description: "File not found in the sandbox.",
|
|
493
|
+
content: { "application/json": {
|
|
494
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
495
|
+
example: {
|
|
496
|
+
error: "File not found: /workspace/foo.txt",
|
|
497
|
+
code: "workspace_read_not_found"
|
|
498
|
+
}
|
|
499
|
+
} }
|
|
500
|
+
},
|
|
501
|
+
"502": {
|
|
502
|
+
description: "SDK read call failed.",
|
|
503
|
+
content: { "application/json": {
|
|
504
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
505
|
+
example: {
|
|
506
|
+
error: "read failed: connection reset",
|
|
507
|
+
code: "exec_transport_error"
|
|
508
|
+
}
|
|
509
|
+
} }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
put: {
|
|
514
|
+
operationId: "writeFile",
|
|
515
|
+
summary: "Write a file into the sandbox filesystem",
|
|
516
|
+
"x-codeSamples": [{
|
|
517
|
+
lang: "curl",
|
|
518
|
+
label: "cURL",
|
|
519
|
+
source: "curl -X PUT https://$HOST/v1/sandbox/my-sandbox/file/workspace/main.py \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/octet-stream\" \\\n --data-binary @main.py"
|
|
520
|
+
}],
|
|
521
|
+
parameters: [
|
|
522
|
+
{
|
|
523
|
+
name: "id",
|
|
524
|
+
in: "path",
|
|
525
|
+
required: true,
|
|
526
|
+
schema: { type: "string" },
|
|
527
|
+
description: "Sandbox instance name."
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: "path",
|
|
531
|
+
in: "path",
|
|
532
|
+
required: true,
|
|
533
|
+
schema: { type: "string" },
|
|
534
|
+
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
name: "Session-Id",
|
|
538
|
+
in: "header",
|
|
539
|
+
required: false,
|
|
540
|
+
schema: {
|
|
541
|
+
type: "string",
|
|
542
|
+
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
543
|
+
},
|
|
544
|
+
description: "Scope this operation to a specific session. Uses the default session if omitted."
|
|
545
|
+
}
|
|
546
|
+
],
|
|
547
|
+
requestBody: {
|
|
548
|
+
required: true,
|
|
549
|
+
description: "Raw file content to write.",
|
|
550
|
+
content: { "application/octet-stream": { schema: {
|
|
551
|
+
type: "string",
|
|
552
|
+
format: "binary"
|
|
553
|
+
} } }
|
|
554
|
+
},
|
|
555
|
+
responses: {
|
|
556
|
+
"200": {
|
|
557
|
+
description: "File written successfully.",
|
|
558
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/WriteResponse" } } }
|
|
559
|
+
},
|
|
560
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
561
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
562
|
+
"403": {
|
|
563
|
+
description: "Path resolves outside /workspace.",
|
|
564
|
+
content: { "application/json": {
|
|
565
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
566
|
+
example: {
|
|
567
|
+
error: "path must resolve to a location within /workspace",
|
|
568
|
+
code: "invalid_request"
|
|
569
|
+
}
|
|
570
|
+
} }
|
|
571
|
+
},
|
|
572
|
+
"502": {
|
|
573
|
+
description: "SDK write call failed.",
|
|
574
|
+
content: { "application/json": {
|
|
575
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
576
|
+
example: {
|
|
577
|
+
error: "write failed: connection reset",
|
|
578
|
+
code: "workspace_archive_write_error"
|
|
579
|
+
}
|
|
580
|
+
} }
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
"/v1/sandbox/{id}/pty": { get: {
|
|
586
|
+
operationId: "ptyTerminal",
|
|
587
|
+
summary: "Open a PTY terminal session via WebSocket",
|
|
588
|
+
description: "Upgrades the HTTP connection to a WebSocket and proxies it to a PTY shell inside the sandbox.\n\n**WebSocket frame protocol:**\n\n| Direction | Frame type | Content |\n|-----------|------------|--------------------------------------------------|\n| Client → Server | Binary | UTF-8 encoded keystrokes / input |\n| Server → Client | Binary | Terminal output (including ANSI escape sequences) |\n| Client → Server | Text (JSON) | Control messages, e.g. `{\"type\":\"resize\",\"cols\":120,\"rows\":30}` |\n| Server → Client | Text (JSON) | Status messages: `ready`, `exit`, `error` |\n\n**Status messages (server → client):**\n- `{\"type\":\"ready\"}` — PTY is accepting input\n- `{\"type\":\"exit\",\"code\":0,\"signal\":\"SIGTERM\"}` — PTY exited\n- `{\"type\":\"error\",\"message\":\"...\"}` — error occurred\n\nIf the client disconnects, the PTY stays alive; reconnecting replays buffered output.",
|
|
589
|
+
"x-codeSamples": [{
|
|
590
|
+
lang: "JavaScript",
|
|
591
|
+
label: "WebSocket",
|
|
592
|
+
source: "const ws = new WebSocket(\"wss://$HOST/v1/sandbox/my-sandbox/pty?cols=120&rows=30\");\nws.binaryType = \"arraybuffer\";\nws.onmessage = (e) => { /* handle binary output or JSON status */ };"
|
|
593
|
+
}],
|
|
594
|
+
parameters: [
|
|
595
|
+
{
|
|
596
|
+
name: "id",
|
|
597
|
+
in: "path",
|
|
598
|
+
required: true,
|
|
599
|
+
schema: { type: "string" },
|
|
600
|
+
description: "Sandbox instance name."
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: "cols",
|
|
604
|
+
in: "query",
|
|
605
|
+
required: false,
|
|
606
|
+
schema: {
|
|
607
|
+
type: "integer",
|
|
608
|
+
default: 80
|
|
609
|
+
},
|
|
610
|
+
description: "Terminal width in columns."
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
name: "rows",
|
|
614
|
+
in: "query",
|
|
615
|
+
required: false,
|
|
616
|
+
schema: {
|
|
617
|
+
type: "integer",
|
|
618
|
+
default: 24
|
|
619
|
+
},
|
|
620
|
+
description: "Terminal height in rows."
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: "shell",
|
|
624
|
+
in: "query",
|
|
625
|
+
required: false,
|
|
626
|
+
schema: { type: "string" },
|
|
627
|
+
description: "Shell binary to run (e.g. `/bin/bash`). Uses the container default if omitted."
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
name: "session",
|
|
631
|
+
in: "query",
|
|
632
|
+
required: false,
|
|
633
|
+
schema: { type: "string" },
|
|
634
|
+
description: "SDK session ID. If provided, the PTY is scoped to this session."
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "Session-Id",
|
|
638
|
+
in: "header",
|
|
639
|
+
required: false,
|
|
640
|
+
schema: {
|
|
641
|
+
type: "string",
|
|
642
|
+
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
643
|
+
},
|
|
644
|
+
description: "Scope this operation to a specific session. Uses the default session if omitted."
|
|
645
|
+
}
|
|
646
|
+
],
|
|
647
|
+
responses: {
|
|
648
|
+
"101": { description: "WebSocket upgrade successful. Binary and text frames flow bidirectionally as described above." },
|
|
649
|
+
"400": {
|
|
650
|
+
description: "Missing `Upgrade: websocket` header or invalid query parameters.",
|
|
651
|
+
content: { "application/json": {
|
|
652
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
653
|
+
example: {
|
|
654
|
+
error: "WebSocket upgrade required",
|
|
655
|
+
code: "invalid_request"
|
|
656
|
+
}
|
|
657
|
+
} }
|
|
658
|
+
},
|
|
659
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
660
|
+
"502": {
|
|
661
|
+
description: "SDK terminal() call failed.",
|
|
662
|
+
content: { "application/json": {
|
|
663
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
664
|
+
example: {
|
|
665
|
+
error: "terminal failed: connection reset",
|
|
666
|
+
code: "exec_transport_error"
|
|
667
|
+
}
|
|
668
|
+
} }
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
} },
|
|
672
|
+
"/v1/sandbox/{id}/running": { get: {
|
|
673
|
+
operationId: "isSandboxRunning",
|
|
674
|
+
summary: "Check whether the sandbox container is alive",
|
|
675
|
+
description: "Executes a no-op command inside the sandbox. Always returns HTTP 200; inspect the `running` field.",
|
|
676
|
+
"x-codeSamples": [{
|
|
677
|
+
lang: "curl",
|
|
678
|
+
label: "cURL",
|
|
679
|
+
source: "curl -X GET https://$HOST/v1/sandbox/my-sandbox/running \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
680
|
+
}],
|
|
681
|
+
parameters: [{
|
|
682
|
+
name: "id",
|
|
683
|
+
in: "path",
|
|
684
|
+
required: true,
|
|
685
|
+
schema: { type: "string" },
|
|
686
|
+
description: "Sandbox instance name."
|
|
687
|
+
}],
|
|
688
|
+
responses: {
|
|
689
|
+
"200": {
|
|
690
|
+
description: "Liveness status (always returned, even when not running).",
|
|
691
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/RunningResponse" } } }
|
|
692
|
+
},
|
|
693
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
694
|
+
}
|
|
695
|
+
} },
|
|
696
|
+
"/v1/sandbox/{id}/persist": { post: {
|
|
697
|
+
operationId: "persistWorkspace",
|
|
698
|
+
summary: "Serialize the sandbox workspace to a tar archive",
|
|
699
|
+
description: "Archives the /workspace directory inside the sandbox and streams the resulting tar back as raw bytes.",
|
|
700
|
+
"x-codeSamples": [{
|
|
701
|
+
lang: "curl",
|
|
702
|
+
label: "cURL",
|
|
703
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/persist \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o workspace.tar"
|
|
704
|
+
}],
|
|
705
|
+
parameters: [{
|
|
706
|
+
name: "id",
|
|
707
|
+
in: "path",
|
|
708
|
+
required: true,
|
|
709
|
+
schema: { type: "string" },
|
|
710
|
+
description: "Sandbox instance name."
|
|
711
|
+
}, {
|
|
712
|
+
name: "excludes",
|
|
713
|
+
in: "query",
|
|
714
|
+
required: false,
|
|
715
|
+
schema: { type: "string" },
|
|
716
|
+
description: "Comma-separated list of relative paths to exclude from the archive.",
|
|
717
|
+
example: "__pycache__,.venv"
|
|
718
|
+
}],
|
|
719
|
+
responses: {
|
|
720
|
+
"200": {
|
|
721
|
+
description: "Raw tar archive bytes.",
|
|
722
|
+
content: { "application/octet-stream": { schema: {
|
|
723
|
+
type: "string",
|
|
724
|
+
format: "binary"
|
|
725
|
+
} } }
|
|
726
|
+
},
|
|
727
|
+
"400": {
|
|
728
|
+
description: "Invalid exclude paths (e.g. path traversal).",
|
|
729
|
+
content: { "application/json": {
|
|
730
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
731
|
+
example: {
|
|
732
|
+
error: "exclude paths must not contain \"..\"",
|
|
733
|
+
code: "invalid_request"
|
|
734
|
+
}
|
|
735
|
+
} }
|
|
736
|
+
},
|
|
737
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
738
|
+
"502": {
|
|
739
|
+
description: "tar command failed inside the sandbox.",
|
|
740
|
+
content: { "application/json": {
|
|
741
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
742
|
+
example: {
|
|
743
|
+
error: "tar failed (exit 1): ...",
|
|
744
|
+
code: "workspace_archive_read_error"
|
|
745
|
+
}
|
|
746
|
+
} }
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} },
|
|
750
|
+
"/v1/sandbox/{id}/hydrate": { post: {
|
|
751
|
+
operationId: "hydrateWorkspace",
|
|
752
|
+
summary: "Populate the sandbox workspace from a tar archive",
|
|
753
|
+
description: "Accepts a raw tar archive as the request body and extracts it into /workspace inside the sandbox.",
|
|
754
|
+
"x-codeSamples": [{
|
|
755
|
+
lang: "curl",
|
|
756
|
+
label: "cURL",
|
|
757
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/hydrate \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/octet-stream\" \\\n --data-binary @workspace.tar"
|
|
758
|
+
}],
|
|
759
|
+
parameters: [{
|
|
760
|
+
name: "id",
|
|
761
|
+
in: "path",
|
|
762
|
+
required: true,
|
|
763
|
+
schema: { type: "string" },
|
|
764
|
+
description: "Sandbox instance name."
|
|
765
|
+
}],
|
|
766
|
+
requestBody: {
|
|
767
|
+
required: true,
|
|
768
|
+
description: "Raw tar archive bytes.",
|
|
769
|
+
content: { "application/octet-stream": { schema: {
|
|
770
|
+
type: "string",
|
|
771
|
+
format: "binary"
|
|
772
|
+
} } }
|
|
773
|
+
},
|
|
774
|
+
responses: {
|
|
775
|
+
"200": {
|
|
776
|
+
description: "Archive extracted successfully.",
|
|
777
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
778
|
+
},
|
|
779
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
780
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
781
|
+
"502": {
|
|
782
|
+
description: "tar extract failed inside the sandbox.",
|
|
783
|
+
content: { "application/json": {
|
|
784
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
785
|
+
example: {
|
|
786
|
+
error: "tar extract failed (exit 1): ...",
|
|
787
|
+
code: "workspace_archive_write_error"
|
|
788
|
+
}
|
|
789
|
+
} }
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
} },
|
|
793
|
+
"/v1/sandbox/{id}/mount": { post: {
|
|
794
|
+
operationId: "mountBucket",
|
|
795
|
+
summary: "Mount an S3-compatible bucket into the container",
|
|
796
|
+
description: "Mounts an S3-compatible bucket (R2, S3, GCS, etc.) as a local directory via s3fs-FUSE. Credentials are optional — the SDK auto-detects from Worker secrets when omitted.",
|
|
797
|
+
"x-codeSamples": [{
|
|
798
|
+
lang: "curl",
|
|
799
|
+
label: "cURL",
|
|
800
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/mount \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"bucket\":\"my-bucket\",\"mountPath\":\"/mnt/data\",\"options\":{\"endpoint\":\"https://ACCT.r2.cloudflarestorage.com\"}}'"
|
|
801
|
+
}],
|
|
802
|
+
parameters: [{
|
|
803
|
+
name: "id",
|
|
804
|
+
in: "path",
|
|
805
|
+
required: true,
|
|
806
|
+
schema: { type: "string" },
|
|
807
|
+
description: "Sandbox instance name."
|
|
808
|
+
}],
|
|
809
|
+
requestBody: {
|
|
810
|
+
required: true,
|
|
811
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/MountBucketRequest" } } }
|
|
812
|
+
},
|
|
813
|
+
responses: {
|
|
814
|
+
"200": {
|
|
815
|
+
description: "Bucket mounted successfully.",
|
|
816
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
817
|
+
},
|
|
818
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
819
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
820
|
+
"502": {
|
|
821
|
+
description: "SDK mount call failed (invalid config, duplicate mount, or s3fs error).",
|
|
822
|
+
content: { "application/json": {
|
|
823
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
824
|
+
example: {
|
|
825
|
+
error: "mount failed: Mount path already in use",
|
|
826
|
+
code: "mount_error"
|
|
827
|
+
}
|
|
828
|
+
} }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
} },
|
|
832
|
+
"/v1/sandbox/{id}/unmount": { post: {
|
|
833
|
+
operationId: "unmountBucket",
|
|
834
|
+
summary: "Unmount a previously mounted bucket",
|
|
835
|
+
description: "Unmounts a bucket filesystem that was previously mounted via `/v1/sandbox/{id}/mount`.",
|
|
836
|
+
"x-codeSamples": [{
|
|
837
|
+
lang: "curl",
|
|
838
|
+
label: "cURL",
|
|
839
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/unmount \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"mountPath\":\"/mnt/data\"}'"
|
|
840
|
+
}],
|
|
841
|
+
parameters: [{
|
|
842
|
+
name: "id",
|
|
843
|
+
in: "path",
|
|
844
|
+
required: true,
|
|
845
|
+
schema: { type: "string" },
|
|
846
|
+
description: "Sandbox instance name."
|
|
847
|
+
}],
|
|
848
|
+
requestBody: {
|
|
849
|
+
required: true,
|
|
850
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/UnmountBucketRequest" } } }
|
|
851
|
+
},
|
|
852
|
+
responses: {
|
|
853
|
+
"200": {
|
|
854
|
+
description: "Bucket unmounted successfully.",
|
|
855
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
856
|
+
},
|
|
857
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
858
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
859
|
+
"502": {
|
|
860
|
+
description: "SDK unmount call failed (no active mount or unmount error).",
|
|
861
|
+
content: { "application/json": {
|
|
862
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
863
|
+
example: {
|
|
864
|
+
error: "unmount failed: No active mount found",
|
|
865
|
+
code: "unmount_error"
|
|
866
|
+
}
|
|
867
|
+
} }
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} },
|
|
871
|
+
"/v1/sandbox/{id}": { delete: {
|
|
872
|
+
operationId: "deleteSandbox",
|
|
873
|
+
summary: "Destroy a sandbox instance (best-effort)",
|
|
874
|
+
description: "Calls destroy() on the sandbox Durable Object to release container resources. Best-effort: unknown sandbox IDs return 204 without allocating a container.",
|
|
875
|
+
"x-codeSamples": [{
|
|
876
|
+
lang: "curl",
|
|
877
|
+
label: "cURL",
|
|
878
|
+
source: "curl -X DELETE https://$HOST/v1/sandbox/my-sandbox \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
879
|
+
}],
|
|
880
|
+
parameters: [{
|
|
881
|
+
name: "id",
|
|
882
|
+
in: "path",
|
|
883
|
+
required: true,
|
|
884
|
+
schema: { type: "string" },
|
|
885
|
+
description: "Sandbox instance name."
|
|
886
|
+
}],
|
|
887
|
+
responses: {
|
|
888
|
+
"204": { description: "Sandbox destroyed (best-effort). Container resources are released." },
|
|
889
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
890
|
+
}
|
|
891
|
+
} },
|
|
892
|
+
"/v1/sandbox/{id}/session": { post: {
|
|
893
|
+
operationId: "createSession",
|
|
894
|
+
summary: "Create an execution session",
|
|
895
|
+
description: "Sessions isolate working directory and environment variables across commands. The returned session ID is used with the `Session-Id` header on exec, file, and PTY endpoints.",
|
|
896
|
+
parameters: [{
|
|
897
|
+
name: "id",
|
|
898
|
+
in: "path",
|
|
899
|
+
required: true,
|
|
900
|
+
schema: { type: "string" },
|
|
901
|
+
description: "Sandbox instance name."
|
|
902
|
+
}],
|
|
903
|
+
requestBody: {
|
|
904
|
+
required: false,
|
|
905
|
+
content: { "application/json": { schema: {
|
|
906
|
+
type: "object",
|
|
907
|
+
properties: {
|
|
908
|
+
id: {
|
|
909
|
+
type: "string",
|
|
910
|
+
description: "Custom session ID. Auto-generated if omitted."
|
|
911
|
+
},
|
|
912
|
+
cwd: {
|
|
913
|
+
type: "string",
|
|
914
|
+
description: "Initial working directory for the session."
|
|
915
|
+
},
|
|
916
|
+
env: {
|
|
917
|
+
type: "object",
|
|
918
|
+
additionalProperties: { type: "string" },
|
|
919
|
+
description: "Environment variables scoped to this session."
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
} } }
|
|
923
|
+
},
|
|
924
|
+
responses: {
|
|
925
|
+
"200": {
|
|
926
|
+
description: "Session created.",
|
|
927
|
+
content: { "application/json": { schema: {
|
|
928
|
+
type: "object",
|
|
929
|
+
required: ["id"],
|
|
930
|
+
properties: { id: {
|
|
931
|
+
type: "string",
|
|
932
|
+
description: "Session ID to pass via `Session-Id` header."
|
|
933
|
+
} }
|
|
934
|
+
} } }
|
|
935
|
+
},
|
|
936
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
937
|
+
"502": {
|
|
938
|
+
description: "Session creation failed.",
|
|
939
|
+
content: { "application/json": {
|
|
940
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
941
|
+
example: {
|
|
942
|
+
error: "session creation failed",
|
|
943
|
+
code: "session_error"
|
|
944
|
+
}
|
|
945
|
+
} }
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
} },
|
|
949
|
+
"/v1/sandbox/{id}/session/{sid}": { delete: {
|
|
950
|
+
operationId: "deleteSession",
|
|
951
|
+
summary: "Delete an execution session",
|
|
952
|
+
description: "Removes a named session. The default session cannot be deleted.",
|
|
953
|
+
parameters: [{
|
|
954
|
+
name: "id",
|
|
955
|
+
in: "path",
|
|
956
|
+
required: true,
|
|
957
|
+
schema: { type: "string" },
|
|
958
|
+
description: "Sandbox instance name."
|
|
959
|
+
}, {
|
|
960
|
+
name: "sid",
|
|
961
|
+
in: "path",
|
|
962
|
+
required: true,
|
|
963
|
+
schema: { type: "string" },
|
|
964
|
+
description: "Session ID to delete."
|
|
965
|
+
}],
|
|
966
|
+
responses: {
|
|
967
|
+
"200": {
|
|
968
|
+
description: "Session deleted.",
|
|
969
|
+
content: { "application/json": { schema: {
|
|
970
|
+
type: "object",
|
|
971
|
+
required: ["success", "sessionId"],
|
|
972
|
+
properties: {
|
|
973
|
+
success: {
|
|
974
|
+
type: "boolean",
|
|
975
|
+
description: "`true` if the session was deleted."
|
|
976
|
+
},
|
|
977
|
+
sessionId: {
|
|
978
|
+
type: "string",
|
|
979
|
+
description: "ID of the deleted session."
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
} } }
|
|
983
|
+
},
|
|
984
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
985
|
+
"502": {
|
|
986
|
+
description: "Session deletion failed (e.g. cannot delete default session).",
|
|
987
|
+
content: { "application/json": {
|
|
988
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
989
|
+
example: {
|
|
990
|
+
error: "cannot delete the default session",
|
|
991
|
+
code: "session_error"
|
|
992
|
+
}
|
|
993
|
+
} }
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
} },
|
|
997
|
+
"/health": { get: {
|
|
998
|
+
operationId: "healthCheck",
|
|
999
|
+
summary: "Worker health check",
|
|
1000
|
+
description: "Simple liveness probe. Not protected by authentication.",
|
|
1001
|
+
"x-codeSamples": [{
|
|
1002
|
+
lang: "curl",
|
|
1003
|
+
label: "cURL",
|
|
1004
|
+
source: "curl https://$HOST/health"
|
|
1005
|
+
}],
|
|
1006
|
+
security: [],
|
|
1007
|
+
responses: { "200": {
|
|
1008
|
+
description: "Worker is up.",
|
|
1009
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
1010
|
+
} }
|
|
1011
|
+
} },
|
|
1012
|
+
"/v1/openapi.json": { get: {
|
|
1013
|
+
operationId: "getOpenApiSchema",
|
|
1014
|
+
summary: "OpenAPI schema",
|
|
1015
|
+
description: "Returns this OpenAPI 3.1 schema document.",
|
|
1016
|
+
"x-codeSamples": [{
|
|
1017
|
+
lang: "curl",
|
|
1018
|
+
label: "cURL",
|
|
1019
|
+
source: "curl https://$HOST/v1/openapi.json \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
1020
|
+
}],
|
|
1021
|
+
responses: {
|
|
1022
|
+
"200": {
|
|
1023
|
+
description: "OpenAPI schema document.",
|
|
1024
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
1025
|
+
},
|
|
1026
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1027
|
+
}
|
|
1028
|
+
} },
|
|
1029
|
+
"/v1/pool/stats": { get: {
|
|
1030
|
+
operationId: "getPoolStats",
|
|
1031
|
+
summary: "Pool statistics",
|
|
1032
|
+
description: "Returns current warm pool statistics including warm/assigned counts and configuration.",
|
|
1033
|
+
"x-codeSamples": [{
|
|
1034
|
+
lang: "curl",
|
|
1035
|
+
label: "cURL",
|
|
1036
|
+
source: "curl https://$HOST/v1/pool/stats \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
1037
|
+
}],
|
|
1038
|
+
responses: {
|
|
1039
|
+
"200": {
|
|
1040
|
+
description: "Pool statistics.",
|
|
1041
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PoolStats" } } }
|
|
1042
|
+
},
|
|
1043
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1044
|
+
}
|
|
1045
|
+
} },
|
|
1046
|
+
"/v1/pool/shutdown-prewarmed": { post: {
|
|
1047
|
+
operationId: "shutdownPrewarmed",
|
|
1048
|
+
summary: "Shutdown pre-warmed containers",
|
|
1049
|
+
description: "Stops all idle (unassigned) warm containers. Does not affect containers assigned to sandbox sessions.",
|
|
1050
|
+
"x-codeSamples": [{
|
|
1051
|
+
lang: "curl",
|
|
1052
|
+
label: "cURL",
|
|
1053
|
+
source: "curl -X POST https://$HOST/v1/pool/shutdown-prewarmed \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
1054
|
+
}],
|
|
1055
|
+
responses: {
|
|
1056
|
+
"200": {
|
|
1057
|
+
description: "All pre-warmed containers stopped.",
|
|
1058
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
1059
|
+
},
|
|
1060
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1061
|
+
}
|
|
1062
|
+
} },
|
|
1063
|
+
"/v1/pool/prime": { post: {
|
|
1064
|
+
operationId: "primePool",
|
|
1065
|
+
summary: "Prime the warm pool",
|
|
1066
|
+
description: "Pushes the current pool configuration and starts the alarm loop. Called automatically by the cron trigger; can also be called manually after deploy.",
|
|
1067
|
+
"x-codeSamples": [{
|
|
1068
|
+
lang: "curl",
|
|
1069
|
+
label: "cURL",
|
|
1070
|
+
source: "curl -X POST https://$HOST/v1/pool/prime \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
1071
|
+
}],
|
|
1072
|
+
responses: {
|
|
1073
|
+
"200": {
|
|
1074
|
+
description: "Pool primed successfully.",
|
|
1075
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
1076
|
+
},
|
|
1077
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1078
|
+
}
|
|
1079
|
+
} }
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region src/bridge/openapi-html.ts
|
|
1085
|
+
/**
|
|
1086
|
+
* Renders an OpenAPI 3.x schema object as a self-contained HTML page.
|
|
1087
|
+
*
|
|
1088
|
+
* No external dependencies — pure HTML/CSS/JS generated server-side.
|
|
1089
|
+
* The schema is embedded as JSON and rendered client-side via a small
|
|
1090
|
+
* inline script.
|
|
1091
|
+
*/
|
|
1092
|
+
function renderOpenApiHtml(schema) {
|
|
1093
|
+
const json = JSON.stringify(schema);
|
|
1094
|
+
return `<!doctype html>
|
|
1095
|
+
<html lang="en">
|
|
1096
|
+
<head>
|
|
1097
|
+
<meta charset="utf-8" />
|
|
1098
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1099
|
+
<title>${esc(schema.info?.title ?? "API Reference")}</title>
|
|
1100
|
+
<style>
|
|
1101
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1102
|
+
|
|
1103
|
+
:root {
|
|
1104
|
+
--bg: #0f1117;
|
|
1105
|
+
--surface: #1a1d27;
|
|
1106
|
+
--border: #2a2d3a;
|
|
1107
|
+
--text: #e2e8f0;
|
|
1108
|
+
--muted: #8892a4;
|
|
1109
|
+
--accent: #6366f1;
|
|
1110
|
+
--get: #22c55e;
|
|
1111
|
+
--post: #3b82f6;
|
|
1112
|
+
--put: #f59e0b;
|
|
1113
|
+
--delete: #ef4444;
|
|
1114
|
+
--patch: #a855f7;
|
|
1115
|
+
--radius: 6px;
|
|
1116
|
+
--font-mono: ui-monospace, "Cascadia Code", "Fira Code", monospace;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
body {
|
|
1120
|
+
background: var(--bg);
|
|
1121
|
+
color: var(--text);
|
|
1122
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1123
|
+
font-size: 14px;
|
|
1124
|
+
line-height: 1.6;
|
|
1125
|
+
display: flex;
|
|
1126
|
+
min-height: 100vh;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/* ── Sidebar ── */
|
|
1130
|
+
#sidebar {
|
|
1131
|
+
width: 260px;
|
|
1132
|
+
min-width: 260px;
|
|
1133
|
+
background: var(--surface);
|
|
1134
|
+
border-right: 1px solid var(--border);
|
|
1135
|
+
padding: 24px 0;
|
|
1136
|
+
position: sticky;
|
|
1137
|
+
top: 0;
|
|
1138
|
+
height: 100vh;
|
|
1139
|
+
overflow-y: auto;
|
|
1140
|
+
}
|
|
1141
|
+
#sidebar h1 {
|
|
1142
|
+
font-size: 13px;
|
|
1143
|
+
font-weight: 600;
|
|
1144
|
+
color: var(--text);
|
|
1145
|
+
padding: 0 20px 16px;
|
|
1146
|
+
border-bottom: 1px solid var(--border);
|
|
1147
|
+
margin-bottom: 12px;
|
|
1148
|
+
white-space: nowrap;
|
|
1149
|
+
overflow: hidden;
|
|
1150
|
+
text-overflow: ellipsis;
|
|
1151
|
+
}
|
|
1152
|
+
#sidebar .version {
|
|
1153
|
+
font-size: 11px;
|
|
1154
|
+
color: var(--muted);
|
|
1155
|
+
font-weight: 400;
|
|
1156
|
+
}
|
|
1157
|
+
.nav-item {
|
|
1158
|
+
display: flex;
|
|
1159
|
+
align-items: center;
|
|
1160
|
+
gap: 10px;
|
|
1161
|
+
padding: 6px 20px;
|
|
1162
|
+
cursor: pointer;
|
|
1163
|
+
border-left: 2px solid transparent;
|
|
1164
|
+
transition: background 0.15s, border-color 0.15s;
|
|
1165
|
+
text-decoration: none;
|
|
1166
|
+
color: var(--muted);
|
|
1167
|
+
font-size: 13px;
|
|
1168
|
+
}
|
|
1169
|
+
.nav-item:hover { background: rgba(255,255,255,0.04); color: var(--text); }
|
|
1170
|
+
.nav-item.active { border-left-color: var(--accent); color: var(--text); background: rgba(99,102,241,0.08); }
|
|
1171
|
+
|
|
1172
|
+
/* ── Main ── */
|
|
1173
|
+
#main {
|
|
1174
|
+
flex: 1;
|
|
1175
|
+
padding: 40px 48px;
|
|
1176
|
+
max-width: 900px;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/* ── Info block ── */
|
|
1180
|
+
#info { margin-bottom: 48px; }
|
|
1181
|
+
#info h2 { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
|
|
1182
|
+
#info p { color: var(--muted); max-width: 680px; }
|
|
1183
|
+
#info .meta { display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; }
|
|
1184
|
+
#info .badge {
|
|
1185
|
+
font-size: 11px;
|
|
1186
|
+
padding: 2px 8px;
|
|
1187
|
+
border-radius: 99px;
|
|
1188
|
+
border: 1px solid var(--border);
|
|
1189
|
+
color: var(--muted);
|
|
1190
|
+
font-family: var(--font-mono);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/* ── Endpoint card ── */
|
|
1194
|
+
.endpoint {
|
|
1195
|
+
border: 1px solid var(--border);
|
|
1196
|
+
border-radius: var(--radius);
|
|
1197
|
+
margin-bottom: 16px;
|
|
1198
|
+
overflow: hidden;
|
|
1199
|
+
}
|
|
1200
|
+
.endpoint-header {
|
|
1201
|
+
display: flex;
|
|
1202
|
+
align-items: center;
|
|
1203
|
+
gap: 14px;
|
|
1204
|
+
padding: 14px 18px;
|
|
1205
|
+
cursor: pointer;
|
|
1206
|
+
background: var(--surface);
|
|
1207
|
+
user-select: none;
|
|
1208
|
+
transition: background 0.15s;
|
|
1209
|
+
}
|
|
1210
|
+
.endpoint-header:hover { background: #1e2130; }
|
|
1211
|
+
.endpoint-header.open { background: #1e2130; border-bottom: 1px solid var(--border); }
|
|
1212
|
+
|
|
1213
|
+
.method {
|
|
1214
|
+
font-family: var(--font-mono);
|
|
1215
|
+
font-size: 11px;
|
|
1216
|
+
font-weight: 700;
|
|
1217
|
+
padding: 3px 8px;
|
|
1218
|
+
border-radius: 4px;
|
|
1219
|
+
min-width: 58px;
|
|
1220
|
+
text-align: center;
|
|
1221
|
+
text-transform: uppercase;
|
|
1222
|
+
letter-spacing: 0.05em;
|
|
1223
|
+
}
|
|
1224
|
+
.method-get { background: rgba(34,197,94,.15); color: var(--get); }
|
|
1225
|
+
.method-post { background: rgba(59,130,246,.15); color: var(--post); }
|
|
1226
|
+
.method-put { background: rgba(245,158,11,.15); color: var(--put); }
|
|
1227
|
+
.method-delete { background: rgba(239,68,68,.15); color: var(--delete); }
|
|
1228
|
+
.method-patch { background: rgba(168,85,247,.15); color: var(--patch); }
|
|
1229
|
+
|
|
1230
|
+
.endpoint-path {
|
|
1231
|
+
font-family: var(--font-mono);
|
|
1232
|
+
font-size: 13px;
|
|
1233
|
+
color: var(--text);
|
|
1234
|
+
flex: 1;
|
|
1235
|
+
}
|
|
1236
|
+
.endpoint-summary { font-size: 13px; color: var(--muted); }
|
|
1237
|
+
.chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; }
|
|
1238
|
+
.endpoint-header.open .chevron { transform: rotate(90deg); }
|
|
1239
|
+
|
|
1240
|
+
/* ── Endpoint body ── */
|
|
1241
|
+
.endpoint-body { padding: 20px 18px; display: none; }
|
|
1242
|
+
.endpoint-body.open { display: block; }
|
|
1243
|
+
.endpoint-desc { color: var(--muted); margin-bottom: 16px; font-size: 13px; }
|
|
1244
|
+
|
|
1245
|
+
/* ── Section labels ── */
|
|
1246
|
+
.section-label {
|
|
1247
|
+
font-size: 11px;
|
|
1248
|
+
font-weight: 600;
|
|
1249
|
+
text-transform: uppercase;
|
|
1250
|
+
letter-spacing: 0.08em;
|
|
1251
|
+
color: var(--muted);
|
|
1252
|
+
margin: 20px 0 10px;
|
|
1253
|
+
}
|
|
1254
|
+
.section-label:first-child { margin-top: 0; }
|
|
1255
|
+
|
|
1256
|
+
/* ── Params table ── */
|
|
1257
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
1258
|
+
th {
|
|
1259
|
+
text-align: left;
|
|
1260
|
+
padding: 6px 10px;
|
|
1261
|
+
font-weight: 600;
|
|
1262
|
+
font-size: 11px;
|
|
1263
|
+
text-transform: uppercase;
|
|
1264
|
+
letter-spacing: 0.06em;
|
|
1265
|
+
color: var(--muted);
|
|
1266
|
+
border-bottom: 1px solid var(--border);
|
|
1267
|
+
}
|
|
1268
|
+
td {
|
|
1269
|
+
padding: 8px 10px;
|
|
1270
|
+
border-bottom: 1px solid var(--border);
|
|
1271
|
+
vertical-align: top;
|
|
1272
|
+
}
|
|
1273
|
+
tr:last-child td { border-bottom: none; }
|
|
1274
|
+
.param-name { font-family: var(--font-mono); color: var(--text); }
|
|
1275
|
+
.param-in { font-size: 11px; color: var(--muted); }
|
|
1276
|
+
.param-type { font-family: var(--font-mono); font-size: 11px; color: var(--accent); }
|
|
1277
|
+
.required { color: var(--delete); font-size: 10px; font-weight: 700; margin-left: 4px; }
|
|
1278
|
+
|
|
1279
|
+
/* ── Response rows ── */
|
|
1280
|
+
.response-group { margin-bottom: 4px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
1281
|
+
.response-header {
|
|
1282
|
+
display: flex;
|
|
1283
|
+
align-items: center;
|
|
1284
|
+
gap: 14px;
|
|
1285
|
+
padding: 10px 14px;
|
|
1286
|
+
cursor: pointer;
|
|
1287
|
+
background: var(--surface);
|
|
1288
|
+
user-select: none;
|
|
1289
|
+
transition: background 0.15s;
|
|
1290
|
+
font-size: 13px;
|
|
1291
|
+
}
|
|
1292
|
+
.response-header:hover { background: #1e2130; }
|
|
1293
|
+
.response-header.open { border-bottom: 1px solid var(--border); }
|
|
1294
|
+
.response-detail { padding: 12px 14px; display: none; font-size: 13px; }
|
|
1295
|
+
.response-detail.open { display: block; }
|
|
1296
|
+
.response-detail .section-label { margin-top: 14px; }
|
|
1297
|
+
.response-detail .section-label:first-child { margin-top: 0; }
|
|
1298
|
+
.status-code {
|
|
1299
|
+
font-family: var(--font-mono);
|
|
1300
|
+
font-size: 12px;
|
|
1301
|
+
font-weight: 700;
|
|
1302
|
+
min-width: 40px;
|
|
1303
|
+
}
|
|
1304
|
+
.s2xx { color: var(--get); }
|
|
1305
|
+
.s4xx { color: var(--put); }
|
|
1306
|
+
.s5xx { color: var(--delete); }
|
|
1307
|
+
.response-desc { color: var(--muted); flex: 1; }
|
|
1308
|
+
.response-content-type { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
|
|
1309
|
+
.resp-chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; margin-left: auto; }
|
|
1310
|
+
.response-header.open .resp-chevron { transform: rotate(90deg); }
|
|
1311
|
+
|
|
1312
|
+
/* ── Code block ── */
|
|
1313
|
+
pre {
|
|
1314
|
+
background: #0a0c12;
|
|
1315
|
+
border: 1px solid var(--border);
|
|
1316
|
+
border-radius: var(--radius);
|
|
1317
|
+
padding: 14px 16px;
|
|
1318
|
+
font-family: var(--font-mono);
|
|
1319
|
+
font-size: 12px;
|
|
1320
|
+
overflow-x: auto;
|
|
1321
|
+
color: var(--text);
|
|
1322
|
+
margin-top: 8px;
|
|
1323
|
+
}
|
|
1324
|
+
pre.example-block { margin-top: 4px; }
|
|
1325
|
+
|
|
1326
|
+
/* ── Code samples ── */
|
|
1327
|
+
.code-samples { margin-top: 4px; }
|
|
1328
|
+
.code-sample-label {
|
|
1329
|
+
font-size: 11px;
|
|
1330
|
+
font-weight: 600;
|
|
1331
|
+
color: var(--muted);
|
|
1332
|
+
font-family: var(--font-mono);
|
|
1333
|
+
margin-bottom: 2px;
|
|
1334
|
+
}
|
|
1335
|
+
</style>
|
|
1336
|
+
</head>
|
|
1337
|
+
<body>
|
|
1338
|
+
<nav id="sidebar"><h1 id="nav-title"></h1></nav>
|
|
1339
|
+
<main id="main"><div id="info"></div><div id="endpoints"></div></main>
|
|
1340
|
+
|
|
1341
|
+
<script>
|
|
1342
|
+
const schema = ${json};
|
|
1343
|
+
|
|
1344
|
+
// ── helpers ──────────────────────────────────────────────────────
|
|
1345
|
+
function esc(s) {
|
|
1346
|
+
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
1347
|
+
}
|
|
1348
|
+
function el(tag, attrs, ...children) {
|
|
1349
|
+
const e = document.createElement(tag);
|
|
1350
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
1351
|
+
if (k === 'class') e.className = v;
|
|
1352
|
+
else if (k === 'html') e.innerHTML = v;
|
|
1353
|
+
else e.setAttribute(k, v);
|
|
1354
|
+
}
|
|
1355
|
+
for (const c of children) {
|
|
1356
|
+
if (c == null) continue;
|
|
1357
|
+
e.append(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
1358
|
+
}
|
|
1359
|
+
return e;
|
|
1360
|
+
}
|
|
1361
|
+
function statusClass(code) {
|
|
1362
|
+
const n = parseInt(code);
|
|
1363
|
+
if (n >= 500) return 's5xx';
|
|
1364
|
+
if (n >= 400) return 's4xx';
|
|
1365
|
+
return 's2xx';
|
|
1366
|
+
}
|
|
1367
|
+
function schemaType(s) {
|
|
1368
|
+
if (!s) return '';
|
|
1369
|
+
if (s.$ref) return s.$ref.split('/').pop();
|
|
1370
|
+
if (s.type === 'array') return s.items ? schemaType(s.items) + '[]' : 'array';
|
|
1371
|
+
return s.format ? s.type + '<' + s.format + '>' : (s.type ?? '');
|
|
1372
|
+
}
|
|
1373
|
+
function resolveRef(ref) {
|
|
1374
|
+
const parts = ref.replace(/^#\\//, '').split('/');
|
|
1375
|
+
return parts.reduce((o, k) => o?.[k], schema);
|
|
1376
|
+
}
|
|
1377
|
+
function resolveResponse(resp) {
|
|
1378
|
+
return resp?.$ref ? resolveRef(resp.$ref) : resp;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ── info ─────────────────────────────────────────────────────────
|
|
1382
|
+
const info = schema.info ?? {};
|
|
1383
|
+
document.title = info.title ?? 'API Reference';
|
|
1384
|
+
document.getElementById('nav-title').innerHTML =
|
|
1385
|
+
esc(info.title ?? 'API Reference') +
|
|
1386
|
+
(info.version ? \` <span class="version">v\${esc(info.version)}</span>\` : '');
|
|
1387
|
+
|
|
1388
|
+
const infoEl = document.getElementById('info');
|
|
1389
|
+
infoEl.append(
|
|
1390
|
+
el('h2', {}, info.title ?? 'API Reference'),
|
|
1391
|
+
el('div', {class:'meta'},
|
|
1392
|
+
info.version ? el('span', {class:'badge'}, 'v' + info.version) : null,
|
|
1393
|
+
el('span', {class:'badge'}, 'OpenAPI ' + (schema.openapi ?? '3.x')),
|
|
1394
|
+
),
|
|
1395
|
+
info.description ? el('p', {html: esc(info.description).replace(/\`([^\`]+)\`/g, '<code>$1</code>')}) : null,
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
// ── endpoints ────────────────────────────────────────────────────
|
|
1399
|
+
const container = document.getElementById('endpoints');
|
|
1400
|
+
const nav = document.getElementById('sidebar');
|
|
1401
|
+
const methods = ['get','post','put','patch','delete'];
|
|
1402
|
+
|
|
1403
|
+
for (const [path, pathItem] of Object.entries(schema.paths ?? {})) {
|
|
1404
|
+
for (const method of methods) {
|
|
1405
|
+
const op = pathItem[method];
|
|
1406
|
+
if (!op) continue;
|
|
1407
|
+
|
|
1408
|
+
const id = method + '_' + path.replace(/[^a-z0-9]/gi, '_');
|
|
1409
|
+
|
|
1410
|
+
// nav link
|
|
1411
|
+
const navLink = el('a', {class:'nav-item', href:'#'+id});
|
|
1412
|
+
navLink.append(
|
|
1413
|
+
el('span', {class:'method method-'+method}, method),
|
|
1414
|
+
el('span', {}, path),
|
|
1415
|
+
);
|
|
1416
|
+
nav.append(navLink);
|
|
1417
|
+
|
|
1418
|
+
// card header
|
|
1419
|
+
const header = el('div', {class:'endpoint-header', id});
|
|
1420
|
+
header.append(
|
|
1421
|
+
el('span', {class:'method method-'+method}, method),
|
|
1422
|
+
el('span', {class:'endpoint-path'}, path),
|
|
1423
|
+
op.summary ? el('span', {class:'endpoint-summary'}, op.summary) : null,
|
|
1424
|
+
el('span', {class:'chevron'}, '▶'),
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1427
|
+
// card body
|
|
1428
|
+
const body = el('div', {class:'endpoint-body'});
|
|
1429
|
+
|
|
1430
|
+
if (op.description) {
|
|
1431
|
+
body.append(el('p', {class:'endpoint-desc', html:
|
|
1432
|
+
esc(op.description).replace(/\`([^\`]+)\`/g,'<code>$1</code>')}));
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// parameters
|
|
1436
|
+
const params = op.parameters ?? [];
|
|
1437
|
+
if (params.length) {
|
|
1438
|
+
body.append(el('div', {class:'section-label'}, 'Parameters'));
|
|
1439
|
+
const tbl = el('table', {});
|
|
1440
|
+
tbl.append(el('thead', {}, el('tr', {},
|
|
1441
|
+
el('th',{},'Name'), el('th',{},'In'), el('th',{},'Type'), el('th',{},'Description')
|
|
1442
|
+
)));
|
|
1443
|
+
const tbody = el('tbody', {});
|
|
1444
|
+
for (const p of params) {
|
|
1445
|
+
const nameCell = el('td', {class:'param-name'}, p.name);
|
|
1446
|
+
if (p.required) nameCell.append(el('span', {class:'required'}, '*'));
|
|
1447
|
+
tbody.append(el('tr', {},
|
|
1448
|
+
nameCell,
|
|
1449
|
+
el('td', {class:'param-in'}, p.in),
|
|
1450
|
+
el('td', {class:'param-type'}, schemaType(p.schema)),
|
|
1451
|
+
el('td', {class:'response-desc'}, p.description ?? ''),
|
|
1452
|
+
));
|
|
1453
|
+
}
|
|
1454
|
+
tbl.append(tbody);
|
|
1455
|
+
body.append(tbl);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// request body
|
|
1459
|
+
if (op.requestBody) {
|
|
1460
|
+
body.append(el('div', {class:'section-label'}, 'Request Body'));
|
|
1461
|
+
const content = op.requestBody.content ?? {};
|
|
1462
|
+
for (const [ct, media] of Object.entries(content)) {
|
|
1463
|
+
body.append(el('div', {class:'param-in'}, ct));
|
|
1464
|
+
if (media.schema) {
|
|
1465
|
+
const resolved = media.schema.$ref ? resolveRef(media.schema.$ref) : media.schema;
|
|
1466
|
+
if (resolved?.properties) {
|
|
1467
|
+
const tbl = el('table', {});
|
|
1468
|
+
tbl.append(el('thead', {}, el('tr', {},
|
|
1469
|
+
el('th',{},'Field'), el('th',{},'Type'), el('th',{},'Description')
|
|
1470
|
+
)));
|
|
1471
|
+
const tbody = el('tbody', {});
|
|
1472
|
+
const required = new Set(resolved.required ?? []);
|
|
1473
|
+
for (const [name, prop] of Object.entries(resolved.properties)) {
|
|
1474
|
+
const nameCell = el('td', {class:'param-name'}, name);
|
|
1475
|
+
if (required.has(name)) nameCell.append(el('span', {class:'required'}, '*'));
|
|
1476
|
+
tbody.append(el('tr', {},
|
|
1477
|
+
nameCell,
|
|
1478
|
+
el('td', {class:'param-type'}, schemaType(prop)),
|
|
1479
|
+
el('td', {class:'response-desc'}, prop.description ?? ''),
|
|
1480
|
+
));
|
|
1481
|
+
}
|
|
1482
|
+
tbl.append(tbody);
|
|
1483
|
+
body.append(tbl);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// responses
|
|
1490
|
+
if (op.responses) {
|
|
1491
|
+
body.append(el('div', {class:'section-label'}, 'Responses'));
|
|
1492
|
+
const wrap = el('div', {});
|
|
1493
|
+
for (const [code, resp] of Object.entries(op.responses)) {
|
|
1494
|
+
const resolved = resolveResponse(resp);
|
|
1495
|
+
const hasContent = resolved?.content && Object.keys(resolved.content).length > 0;
|
|
1496
|
+
|
|
1497
|
+
const group = el('div', {class:'response-group'});
|
|
1498
|
+
const respHeader = el('div', {class:'response-header'});
|
|
1499
|
+
respHeader.append(
|
|
1500
|
+
el('span', {class:'status-code ' + statusClass(code)}, code),
|
|
1501
|
+
el('span', {class:'response-desc'}, resolved?.description ?? ''),
|
|
1502
|
+
hasContent ? el('span', {class:'resp-chevron'}, '\u25B6') : null,
|
|
1503
|
+
);
|
|
1504
|
+
group.append(respHeader);
|
|
1505
|
+
|
|
1506
|
+
if (hasContent) {
|
|
1507
|
+
const detail = el('div', {class:'response-detail'});
|
|
1508
|
+
for (const [ct, media] of Object.entries(resolved.content)) {
|
|
1509
|
+
detail.append(el('div', {class:'response-content-type'}, ct));
|
|
1510
|
+
|
|
1511
|
+
// Render response schema fields
|
|
1512
|
+
if (media.schema) {
|
|
1513
|
+
const resolvedSchema = media.schema.$ref ? resolveRef(media.schema.$ref) : media.schema;
|
|
1514
|
+
if (resolvedSchema?.properties) {
|
|
1515
|
+
detail.append(el('div', {class:'section-label'}, 'Schema'));
|
|
1516
|
+
const tbl = el('table', {});
|
|
1517
|
+
tbl.append(el('thead', {}, el('tr', {},
|
|
1518
|
+
el('th',{},'Field'), el('th',{},'Type'), el('th',{},'Description')
|
|
1519
|
+
)));
|
|
1520
|
+
const tbody = el('tbody', {});
|
|
1521
|
+
const required = new Set(resolvedSchema.required ?? []);
|
|
1522
|
+
for (const [name, prop] of Object.entries(resolvedSchema.properties)) {
|
|
1523
|
+
const nameCell = el('td', {class:'param-name'}, name);
|
|
1524
|
+
if (required.has(name)) nameCell.append(el('span', {class:'required'}, '*'));
|
|
1525
|
+
tbody.append(el('tr', {},
|
|
1526
|
+
nameCell,
|
|
1527
|
+
el('td', {class:'param-type'}, schemaType(prop)),
|
|
1528
|
+
el('td', {class:'response-desc'}, prop.description ?? ''),
|
|
1529
|
+
));
|
|
1530
|
+
}
|
|
1531
|
+
tbl.append(tbody);
|
|
1532
|
+
detail.append(tbl);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Render response example
|
|
1537
|
+
if (media.example !== undefined) {
|
|
1538
|
+
detail.append(el('div', {class:'section-label'}, 'Example'));
|
|
1539
|
+
detail.append(el('pre', {class:'example-block'}, JSON.stringify(media.example, null, 2)));
|
|
1540
|
+
}
|
|
1541
|
+
if (media.examples) {
|
|
1542
|
+
detail.append(el('div', {class:'section-label'}, 'Examples'));
|
|
1543
|
+
for (const [exName, exObj] of Object.entries(media.examples)) {
|
|
1544
|
+
const val = exObj?.value ?? exObj;
|
|
1545
|
+
detail.append(el('pre', {class:'example-block'}, '// ' + exName + '\\n' + JSON.stringify(val, null, 2)));
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
respHeader.addEventListener('click', () => {
|
|
1551
|
+
const open = respHeader.classList.toggle('open');
|
|
1552
|
+
detail.classList.toggle('open', open);
|
|
1553
|
+
});
|
|
1554
|
+
group.append(detail);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
wrap.append(group);
|
|
1558
|
+
}
|
|
1559
|
+
body.append(wrap);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// x-codeSamples
|
|
1563
|
+
const samples = op['x-codeSamples'];
|
|
1564
|
+
if (samples && samples.length) {
|
|
1565
|
+
body.append(el('div', {class:'section-label'}, 'Example'));
|
|
1566
|
+
const samplesWrap = el('div', {class:'code-samples'});
|
|
1567
|
+
for (const sample of samples) {
|
|
1568
|
+
samplesWrap.append(
|
|
1569
|
+
el('div', {class:'code-sample-label'}, sample.label ?? sample.lang ?? 'Example'),
|
|
1570
|
+
el('pre', {}, sample.source),
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
body.append(samplesWrap);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// toggle
|
|
1577
|
+
header.addEventListener('click', () => {
|
|
1578
|
+
const open = header.classList.toggle('open');
|
|
1579
|
+
body.classList.toggle('open', open);
|
|
1580
|
+
// sync nav highlight
|
|
1581
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
1582
|
+
if (open) navLink.classList.add('active');
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
const card = el('div', {class:'endpoint'});
|
|
1586
|
+
card.append(header, body);
|
|
1587
|
+
container.append(card);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// highlight nav on scroll
|
|
1592
|
+
const observer = new IntersectionObserver(entries => {
|
|
1593
|
+
for (const entry of entries) {
|
|
1594
|
+
if (entry.isIntersecting) {
|
|
1595
|
+
const id = entry.target.id;
|
|
1596
|
+
document.querySelectorAll('.nav-item').forEach(n => {
|
|
1597
|
+
n.classList.toggle('active', n.getAttribute('href') === '#' + id);
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}, { threshold: 0.5 });
|
|
1602
|
+
document.querySelectorAll('.endpoint-header[id]').forEach(h => observer.observe(h));
|
|
1603
|
+
<\/script>
|
|
1604
|
+
</body>
|
|
1605
|
+
</html>`;
|
|
1606
|
+
}
|
|
1607
|
+
function esc(s) {
|
|
1608
|
+
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
//#endregion
|
|
1612
|
+
//#region src/bridge/routes.ts
|
|
1613
|
+
/** Typed wrapper around the SDK's getSandbox() that returns a BridgeSandbox. */
|
|
1614
|
+
function getSandbox$1(ns, containerUUID) {
|
|
1615
|
+
return getSandbox(ns, containerUUID);
|
|
1616
|
+
}
|
|
1617
|
+
function createBridgeApp(config) {
|
|
1618
|
+
const app = new Hono();
|
|
1619
|
+
const { sandboxBinding, warmPoolBinding, apiPrefix } = config;
|
|
1620
|
+
function getSandboxNs(env$1) {
|
|
1621
|
+
return env$1[sandboxBinding];
|
|
1622
|
+
}
|
|
1623
|
+
function getWarmPoolNs(env$1) {
|
|
1624
|
+
return env$1[warmPoolBinding];
|
|
1625
|
+
}
|
|
1626
|
+
app.use(`${apiPrefix}/sandbox/*`, async (c, next) => {
|
|
1627
|
+
const sandboxId = new URL(c.req.url).pathname.split("/")[apiPrefix.split("/").filter(Boolean).length + 2];
|
|
1628
|
+
if (sandboxId && !/^[a-z2-7]{1,128}$/.test(sandboxId)) return errorJson("Invalid sandbox ID format", "invalid_request", 400);
|
|
1629
|
+
const token = c.env.SANDBOX_API_KEY;
|
|
1630
|
+
if (token) {
|
|
1631
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
1632
|
+
if ((authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
1633
|
+
}
|
|
1634
|
+
return next();
|
|
1635
|
+
});
|
|
1636
|
+
app.post(`${apiPrefix}/sandbox`, async (c) => {
|
|
1637
|
+
const token = c.env.SANDBOX_API_KEY;
|
|
1638
|
+
if (token) {
|
|
1639
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
1640
|
+
if ((authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
1641
|
+
}
|
|
1642
|
+
const bytes = new Uint8Array(16);
|
|
1643
|
+
crypto.getRandomValues(bytes);
|
|
1644
|
+
const id = base32Encode(bytes);
|
|
1645
|
+
return c.json({ id });
|
|
1646
|
+
});
|
|
1647
|
+
app.use(`${apiPrefix}/sandbox/:id/*`, async (c, next) => {
|
|
1648
|
+
const sandboxId = c.req.param("id");
|
|
1649
|
+
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
1650
|
+
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
1651
|
+
const poolNs = getWarmPoolNs(c.env);
|
|
1652
|
+
const poolId = poolNs.idFromName("global-pool");
|
|
1653
|
+
const poolStub = poolNs.get(poolId);
|
|
1654
|
+
try {
|
|
1655
|
+
await poolStub.configure({
|
|
1656
|
+
warmTarget,
|
|
1657
|
+
refreshInterval
|
|
1658
|
+
});
|
|
1659
|
+
const containerUUID = await poolStub.getContainer(sandboxId);
|
|
1660
|
+
c.set("containerUUID", containerUUID);
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1663
|
+
if (msg.includes("instance limit reached")) return errorJson(msg, "capacity_exceeded", 503);
|
|
1664
|
+
return errorJson(`pool error: ${msg}`, "pool_error", 502);
|
|
1665
|
+
}
|
|
1666
|
+
return next();
|
|
1667
|
+
});
|
|
1668
|
+
app.use(`${apiPrefix}/sandbox/:id`, async (c, next) => {
|
|
1669
|
+
if (c.req.method !== "DELETE") return next();
|
|
1670
|
+
const sandboxId = c.req.param("id");
|
|
1671
|
+
const poolNs = getWarmPoolNs(c.env);
|
|
1672
|
+
const poolId = poolNs.idFromName("global-pool");
|
|
1673
|
+
const poolStub = poolNs.get(poolId);
|
|
1674
|
+
try {
|
|
1675
|
+
const containerUUID = await poolStub.lookupContainer(sandboxId);
|
|
1676
|
+
if (!containerUUID) return new Response(null, { status: 204 });
|
|
1677
|
+
c.set("containerUUID", containerUUID);
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
return errorJson(`pool error: ${err instanceof Error ? err.message : String(err)}`, "pool_error", 502);
|
|
1680
|
+
}
|
|
1681
|
+
return next();
|
|
1682
|
+
});
|
|
1683
|
+
app.post(`${apiPrefix}/sandbox/:id/exec`, async (c) => {
|
|
1684
|
+
let body;
|
|
1685
|
+
try {
|
|
1686
|
+
body = await c.req.json();
|
|
1687
|
+
} catch {
|
|
1688
|
+
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
1689
|
+
}
|
|
1690
|
+
if (!Array.isArray(body.argv) || body.argv.length === 0) return errorJson("argv must be a non-empty array", "invalid_request", 400);
|
|
1691
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1692
|
+
const rawSessionId = c.req.header("Session-Id");
|
|
1693
|
+
let executor = sandbox;
|
|
1694
|
+
if (rawSessionId) {
|
|
1695
|
+
const sessionId = validateSessionId(rawSessionId);
|
|
1696
|
+
if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
1697
|
+
executor = await sandbox.getSession(sessionId);
|
|
1698
|
+
}
|
|
1699
|
+
const command = body.argv.map(shellQuote).join(" ");
|
|
1700
|
+
const opts = {};
|
|
1701
|
+
if (typeof body.timeout_ms === "number") opts.timeout = body.timeout_ms;
|
|
1702
|
+
if (typeof body.cwd === "string") {
|
|
1703
|
+
const resolvedCwd = resolveWorkspacePath(body.cwd);
|
|
1704
|
+
if (!resolvedCwd) return errorJson("cwd must resolve to a location within /workspace", "invalid_request", 403);
|
|
1705
|
+
opts.cwd = resolvedCwd;
|
|
1706
|
+
}
|
|
1707
|
+
const { readable, writable } = new TransformStream();
|
|
1708
|
+
const writer = writable.getWriter();
|
|
1709
|
+
const encoder = new TextEncoder();
|
|
1710
|
+
let closed = false;
|
|
1711
|
+
let lastWrite = Promise.resolve();
|
|
1712
|
+
/** Write a single SSE event. Chains on the previous write to respect backpressure. */
|
|
1713
|
+
function writeSSE(event, data) {
|
|
1714
|
+
if (closed) return;
|
|
1715
|
+
const payload = data.split("\n").map((line) => `data: ${line}`).join("\n");
|
|
1716
|
+
lastWrite = lastWrite.then(() => writer.write(encoder.encode(`event: ${event}\n${payload}\n\n`)));
|
|
1717
|
+
}
|
|
1718
|
+
function closeStream() {
|
|
1719
|
+
if (closed) return;
|
|
1720
|
+
closed = true;
|
|
1721
|
+
lastWrite.then(() => writer.close()).catch(() => {});
|
|
1722
|
+
}
|
|
1723
|
+
executor.exec(command, {
|
|
1724
|
+
...opts,
|
|
1725
|
+
stream: true,
|
|
1726
|
+
onOutput(stream, data) {
|
|
1727
|
+
writeSSE(stream, toBase64(data));
|
|
1728
|
+
},
|
|
1729
|
+
onComplete(result) {
|
|
1730
|
+
writeSSE("exit", JSON.stringify({ exit_code: result.exitCode }));
|
|
1731
|
+
closeStream();
|
|
1732
|
+
},
|
|
1733
|
+
onError(err) {
|
|
1734
|
+
writeSSE("error", JSON.stringify({
|
|
1735
|
+
error: err.message,
|
|
1736
|
+
code: "exec_error"
|
|
1737
|
+
}));
|
|
1738
|
+
closeStream();
|
|
1739
|
+
}
|
|
1740
|
+
}).catch((err) => {
|
|
1741
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1742
|
+
writeSSE("error", JSON.stringify({
|
|
1743
|
+
error: `exec failed: ${msg}`,
|
|
1744
|
+
code: "exec_transport_error"
|
|
1745
|
+
}));
|
|
1746
|
+
closeStream();
|
|
1747
|
+
});
|
|
1748
|
+
return new Response(readable, {
|
|
1749
|
+
status: 200,
|
|
1750
|
+
headers: {
|
|
1751
|
+
"Content-Type": "text/event-stream",
|
|
1752
|
+
"Cache-Control": "no-cache"
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
});
|
|
1756
|
+
app.get(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
|
|
1757
|
+
const sandboxId = c.req.param("id");
|
|
1758
|
+
const fullPath = c.req.path;
|
|
1759
|
+
const marker = `${apiPrefix}/sandbox/${sandboxId}/file/`;
|
|
1760
|
+
const relativePath = fullPath.slice(marker.length);
|
|
1761
|
+
if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
|
|
1762
|
+
const resolvedPath = resolveWorkspacePath("/" + relativePath);
|
|
1763
|
+
if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
|
|
1764
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1765
|
+
const rawSessionId = c.req.header("Session-Id");
|
|
1766
|
+
let executor = sandbox;
|
|
1767
|
+
if (rawSessionId) {
|
|
1768
|
+
const sessionId = validateSessionId(rawSessionId);
|
|
1769
|
+
if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
1770
|
+
executor = await sandbox.getSession(sessionId);
|
|
1771
|
+
}
|
|
1772
|
+
try {
|
|
1773
|
+
const stream = await executor.readFileStream(resolvedPath);
|
|
1774
|
+
return new Response(sseToByteStream(stream), {
|
|
1775
|
+
status: 200,
|
|
1776
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
1777
|
+
});
|
|
1778
|
+
} catch (err) {
|
|
1779
|
+
if (err.code === "FILE_NOT_FOUND") return errorJson(`File not found: ${resolvedPath}`, "workspace_read_not_found", 404);
|
|
1780
|
+
return errorJson(`read failed: ${err instanceof Error ? err.message : String(err)}`, "exec_transport_error", 502);
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
app.put(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
|
|
1784
|
+
const sandboxId = c.req.param("id");
|
|
1785
|
+
const fullPath = c.req.path;
|
|
1786
|
+
const marker = `${apiPrefix}/sandbox/${sandboxId}/file/`;
|
|
1787
|
+
const relativePath = fullPath.slice(marker.length);
|
|
1788
|
+
if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
|
|
1789
|
+
const resolvedPath = resolveWorkspacePath("/" + relativePath);
|
|
1790
|
+
if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
|
|
1791
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1792
|
+
const rawSessionId = c.req.header("Session-Id");
|
|
1793
|
+
let executor = sandbox;
|
|
1794
|
+
if (rawSessionId) {
|
|
1795
|
+
const sessionId = validateSessionId(rawSessionId);
|
|
1796
|
+
if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
1797
|
+
executor = await sandbox.getSession(sessionId);
|
|
1798
|
+
}
|
|
1799
|
+
try {
|
|
1800
|
+
const buffer = await c.req.arrayBuffer();
|
|
1801
|
+
const MAX_WRITE_BYTES = 32 * 1024 * 1024;
|
|
1802
|
+
if (buffer.byteLength > MAX_WRITE_BYTES) return errorJson(`payload too large: ${buffer.byteLength} bytes exceeds the ${MAX_WRITE_BYTES}-byte limit`, "payload_too_large", 413);
|
|
1803
|
+
const bytes = new Uint8Array(buffer);
|
|
1804
|
+
let b64 = "";
|
|
1805
|
+
const CHUNK = 6144;
|
|
1806
|
+
for (let i = 0; i < bytes.length; i += CHUNK) b64 += btoa(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
|
|
1807
|
+
await executor.writeFile(resolvedPath, b64, { encoding: "base64" });
|
|
1808
|
+
return c.json({ ok: true });
|
|
1809
|
+
} catch (err) {
|
|
1810
|
+
return errorJson(`write failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
app.get(`${apiPrefix}/sandbox/:id/running`, async (c) => {
|
|
1814
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1815
|
+
try {
|
|
1816
|
+
await sandbox.exec("true");
|
|
1817
|
+
return c.json({ running: true });
|
|
1818
|
+
} catch {
|
|
1819
|
+
return c.json({ running: false });
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
app.get(`${apiPrefix}/sandbox/:id/pty`, async (c) => {
|
|
1823
|
+
const upgrade = c.req.header("Upgrade");
|
|
1824
|
+
if (!upgrade || upgrade.toLowerCase() !== "websocket") return errorJson("WebSocket upgrade required", "invalid_request", 400);
|
|
1825
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1826
|
+
const colsParam = c.req.query("cols");
|
|
1827
|
+
const rowsParam = c.req.query("rows");
|
|
1828
|
+
const shell = c.req.query("shell");
|
|
1829
|
+
const sessionId = c.req.header("Session-Id") || c.req.query("session");
|
|
1830
|
+
const cols = colsParam ? Number(colsParam) : 80;
|
|
1831
|
+
const rows = rowsParam ? Number(rowsParam) : 24;
|
|
1832
|
+
if (Number.isNaN(cols) || Number.isNaN(rows)) return errorJson("cols and rows must be valid numbers", "invalid_request", 400);
|
|
1833
|
+
const opts = {
|
|
1834
|
+
cols,
|
|
1835
|
+
rows
|
|
1836
|
+
};
|
|
1837
|
+
if (shell) opts.shell = shell;
|
|
1838
|
+
try {
|
|
1839
|
+
if (sessionId) {
|
|
1840
|
+
const validatedId = validateSessionId(sessionId);
|
|
1841
|
+
if (!validatedId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
1842
|
+
return await (await sandbox.getSession(validatedId)).terminal(c.req.raw, opts);
|
|
1843
|
+
}
|
|
1844
|
+
return await sandbox.terminal(c.req.raw, opts);
|
|
1845
|
+
} catch (err) {
|
|
1846
|
+
return errorJson(`terminal failed: ${err instanceof Error ? err.message : String(err)}`, "exec_transport_error", 502);
|
|
1847
|
+
}
|
|
1848
|
+
});
|
|
1849
|
+
app.post(`${apiPrefix}/sandbox/:id/persist`, async (c) => {
|
|
1850
|
+
const root = "/workspace";
|
|
1851
|
+
const excludesParam = c.req.query("excludes") ?? "";
|
|
1852
|
+
const excludes = excludesParam ? excludesParam.split(",").filter((s) => s.length > 0) : [];
|
|
1853
|
+
for (const ex of excludes) if (ex.includes("..")) return errorJson("exclude paths must not contain \"..\"", "invalid_request", 400);
|
|
1854
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1855
|
+
const tmpPath = `/tmp/sandbox-persist-${Date.now()}.tar`;
|
|
1856
|
+
const excludeArgs = excludes.map((rel) => `--exclude ${shellQuote("./" + rel.replace(/^\.\//, ""))}`).join(" ");
|
|
1857
|
+
const tarCmd = excludeArgs ? `tar cf ${shellQuote(tmpPath)} ${excludeArgs} -C ${shellQuote(root)} .` : `tar cf ${shellQuote(tmpPath)} -C ${shellQuote(root)} .`;
|
|
1858
|
+
try {
|
|
1859
|
+
const result = await sandbox.exec(tarCmd);
|
|
1860
|
+
if (result.exitCode !== 0) return errorJson(`tar failed (exit ${result.exitCode}): ${result.stderr}`, "workspace_archive_read_error", 502);
|
|
1861
|
+
const stream = await sandbox.readFileStream(tmpPath);
|
|
1862
|
+
sandbox.exec(`rm -f ${shellQuote(tmpPath)}`).catch(() => {});
|
|
1863
|
+
return new Response(sseToByteStream(stream), {
|
|
1864
|
+
status: 200,
|
|
1865
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
1866
|
+
});
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
return errorJson(`persist failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_read_error", 502);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
app.post(`${apiPrefix}/sandbox/:id/hydrate`, async (c) => {
|
|
1872
|
+
const root = "/workspace";
|
|
1873
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1874
|
+
let tarBytes;
|
|
1875
|
+
try {
|
|
1876
|
+
const buffer = await c.req.arrayBuffer();
|
|
1877
|
+
tarBytes = new Uint8Array(buffer);
|
|
1878
|
+
} catch {
|
|
1879
|
+
return errorJson("Could not read request body", "invalid_request", 400);
|
|
1880
|
+
}
|
|
1881
|
+
if (tarBytes.byteLength === 0) return errorJson("Empty tar payload", "invalid_request", 400);
|
|
1882
|
+
const MAX_HYDRATE_BYTES = 32 * 1024 * 1024;
|
|
1883
|
+
if (tarBytes.byteLength > MAX_HYDRATE_BYTES) return errorJson(`tar payload too large: ${tarBytes.byteLength} bytes exceeds the ${MAX_HYDRATE_BYTES}-byte limit`, "invalid_request", 400);
|
|
1884
|
+
try {
|
|
1885
|
+
await sandbox.exec(`mkdir -p ${shellQuote(root)}`);
|
|
1886
|
+
const tmpPath = `/tmp/sandbox-hydrate-${Date.now()}.tar`;
|
|
1887
|
+
let b64 = "";
|
|
1888
|
+
const CHUNK = 6144;
|
|
1889
|
+
for (let i = 0; i < tarBytes.length; i += CHUNK) b64 += btoa(String.fromCharCode(...tarBytes.subarray(i, i + CHUNK)));
|
|
1890
|
+
await sandbox.writeFile(tmpPath, b64, { encoding: "base64" });
|
|
1891
|
+
const extractResult = await sandbox.exec(`tar xf ${shellQuote(tmpPath)} -C ${shellQuote(root)} && rm -f ${shellQuote(tmpPath)}`);
|
|
1892
|
+
if (extractResult.exitCode !== 0) return errorJson(`tar extract failed (exit ${extractResult.exitCode}): ${extractResult.stderr}`, "workspace_archive_write_error", 502);
|
|
1893
|
+
return c.json({ ok: true });
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
return errorJson(`hydrate failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
app.post(`${apiPrefix}/sandbox/:id/mount`, async (c) => {
|
|
1899
|
+
let body;
|
|
1900
|
+
try {
|
|
1901
|
+
body = await c.req.json();
|
|
1902
|
+
} catch {
|
|
1903
|
+
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
1904
|
+
}
|
|
1905
|
+
if (!body.bucket || typeof body.bucket !== "string") return errorJson("bucket must be a non-empty string", "invalid_request", 400);
|
|
1906
|
+
if (!body.mountPath || typeof body.mountPath !== "string") return errorJson("mountPath must be a non-empty string", "invalid_request", 400);
|
|
1907
|
+
if (!body.mountPath.startsWith("/")) return errorJson("mountPath must be an absolute path (start with /)", "invalid_request", 400);
|
|
1908
|
+
if (!body.options || typeof body.options !== "object") return errorJson("options must be an object", "invalid_request", 400);
|
|
1909
|
+
if (!body.options.endpoint || typeof body.options.endpoint !== "string") return errorJson("options.endpoint must be a non-empty string", "invalid_request", 400);
|
|
1910
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1911
|
+
const sdkOptions = { endpoint: body.options.endpoint };
|
|
1912
|
+
if (body.options.readOnly !== void 0) sdkOptions.readOnly = body.options.readOnly;
|
|
1913
|
+
if (body.options.prefix !== void 0) sdkOptions.prefix = body.options.prefix;
|
|
1914
|
+
if (body.options.credentials) sdkOptions.credentials = {
|
|
1915
|
+
accessKeyId: body.options.credentials.accessKeyId,
|
|
1916
|
+
secretAccessKey: body.options.credentials.secretAccessKey
|
|
1917
|
+
};
|
|
1918
|
+
try {
|
|
1919
|
+
await sandbox.mountBucket(body.bucket, body.mountPath, sdkOptions);
|
|
1920
|
+
return c.json({ ok: true });
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
return errorJson(`mount failed: ${err instanceof Error ? err.message : String(err)}`, "mount_error", 502);
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
app.post(`${apiPrefix}/sandbox/:id/unmount`, async (c) => {
|
|
1926
|
+
let body;
|
|
1927
|
+
try {
|
|
1928
|
+
body = await c.req.json();
|
|
1929
|
+
} catch {
|
|
1930
|
+
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
1931
|
+
}
|
|
1932
|
+
if (!body.mountPath || typeof body.mountPath !== "string") return errorJson("mountPath must be a non-empty string", "invalid_request", 400);
|
|
1933
|
+
if (!body.mountPath.startsWith("/")) return errorJson("mountPath must be an absolute path (start with /)", "invalid_request", 400);
|
|
1934
|
+
const normalizedPath = new URL(body.mountPath, "file:///").pathname;
|
|
1935
|
+
if (normalizedPath === "/") return errorJson("mountPath must not resolve to / (filesystem root)", "invalid_request", 400);
|
|
1936
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1937
|
+
try {
|
|
1938
|
+
await sandbox.unmountBucket(normalizedPath);
|
|
1939
|
+
const quoted = shellQuote(normalizedPath);
|
|
1940
|
+
try {
|
|
1941
|
+
await sandbox.exec(`mountpoint -q ${quoted} || rmdir ${quoted}`);
|
|
1942
|
+
} catch {}
|
|
1943
|
+
return c.json({ ok: true });
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
return errorJson(`unmount failed: ${err instanceof Error ? err.message : String(err)}`, "unmount_error", 502);
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
app.post(`${apiPrefix}/sandbox/:id/session`, async (c) => {
|
|
1949
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1950
|
+
let body = {};
|
|
1951
|
+
try {
|
|
1952
|
+
body = await c.req.json();
|
|
1953
|
+
} catch {}
|
|
1954
|
+
try {
|
|
1955
|
+
const session = await sandbox.createSession(body);
|
|
1956
|
+
return c.json({ id: session.id });
|
|
1957
|
+
} catch (err) {
|
|
1958
|
+
return errorJson(`session create failed: ${err instanceof Error ? err.message : String(err)}`, "session_error", 502);
|
|
1959
|
+
}
|
|
1960
|
+
});
|
|
1961
|
+
app.delete(`${apiPrefix}/sandbox/:id/session/:sid`, async (c) => {
|
|
1962
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1963
|
+
const sid = c.req.param("sid");
|
|
1964
|
+
try {
|
|
1965
|
+
const result = await sandbox.deleteSession(sid);
|
|
1966
|
+
return c.json(result);
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
return errorJson(`session delete failed: ${err instanceof Error ? err.message : String(err)}`, "session_error", 502);
|
|
1969
|
+
}
|
|
1970
|
+
});
|
|
1971
|
+
app.delete(`${apiPrefix}/sandbox/:id`, async (c) => {
|
|
1972
|
+
const containerUUID = c.get("containerUUID");
|
|
1973
|
+
const sandbox = getSandbox$1(getSandboxNs(c.env), containerUUID);
|
|
1974
|
+
try {
|
|
1975
|
+
await sandbox.destroy();
|
|
1976
|
+
} catch {}
|
|
1977
|
+
try {
|
|
1978
|
+
const poolNs = getWarmPoolNs(c.env);
|
|
1979
|
+
const poolId = poolNs.idFromName("global-pool");
|
|
1980
|
+
await poolNs.get(poolId).reportStopped(containerUUID);
|
|
1981
|
+
} catch {}
|
|
1982
|
+
return new Response(null, { status: 204 });
|
|
1983
|
+
});
|
|
1984
|
+
app.get(config.healthPath, (c) => {
|
|
1985
|
+
const errors = [];
|
|
1986
|
+
if (!c.env[sandboxBinding]) errors.push(`Missing required Durable Object binding "${sandboxBinding}". Ensure your wrangler.jsonc has a binding named "${sandboxBinding}".`);
|
|
1987
|
+
if (!c.env[warmPoolBinding]) errors.push(`Missing required Durable Object binding "${warmPoolBinding}". Ensure your wrangler.jsonc has a binding named "${warmPoolBinding}".`);
|
|
1988
|
+
if (errors.length > 0) return c.json({
|
|
1989
|
+
ok: false,
|
|
1990
|
+
errors
|
|
1991
|
+
}, 503);
|
|
1992
|
+
return c.json({ ok: true });
|
|
1993
|
+
});
|
|
1994
|
+
app.use(`${apiPrefix}/pool/*`, async (c, next) => {
|
|
1995
|
+
const token = c.env.SANDBOX_API_KEY;
|
|
1996
|
+
if (token) {
|
|
1997
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
1998
|
+
if ((authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
1999
|
+
}
|
|
2000
|
+
return next();
|
|
2001
|
+
});
|
|
2002
|
+
app.get(`${apiPrefix}/pool/stats`, async (c) => {
|
|
2003
|
+
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2004
|
+
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2005
|
+
const poolNs = getWarmPoolNs(c.env);
|
|
2006
|
+
const poolId = poolNs.idFromName("global-pool");
|
|
2007
|
+
const poolStub = poolNs.get(poolId);
|
|
2008
|
+
try {
|
|
2009
|
+
await poolStub.configure({
|
|
2010
|
+
warmTarget,
|
|
2011
|
+
refreshInterval
|
|
2012
|
+
});
|
|
2013
|
+
} catch {}
|
|
2014
|
+
const stats = await poolStub.getStats();
|
|
2015
|
+
return c.json(stats);
|
|
2016
|
+
});
|
|
2017
|
+
app.post(`${apiPrefix}/pool/shutdown-prewarmed`, async (c) => {
|
|
2018
|
+
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2019
|
+
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2020
|
+
const poolNs = getWarmPoolNs(c.env);
|
|
2021
|
+
const poolId = poolNs.idFromName("global-pool");
|
|
2022
|
+
const poolStub = poolNs.get(poolId);
|
|
2023
|
+
try {
|
|
2024
|
+
await poolStub.configure({
|
|
2025
|
+
warmTarget,
|
|
2026
|
+
refreshInterval
|
|
2027
|
+
});
|
|
2028
|
+
} catch {}
|
|
2029
|
+
await poolStub.shutdownPrewarmed();
|
|
2030
|
+
return c.json({ ok: true });
|
|
2031
|
+
});
|
|
2032
|
+
app.post(`${apiPrefix}/pool/prime`, async (c) => {
|
|
2033
|
+
await primePool(c.env, warmPoolBinding);
|
|
2034
|
+
return c.json({ ok: true });
|
|
2035
|
+
});
|
|
2036
|
+
const openapiAuth = async (c, next) => {
|
|
2037
|
+
const token = c.env.SANDBOX_API_KEY;
|
|
2038
|
+
if (token) {
|
|
2039
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
2040
|
+
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
2041
|
+
const queryToken = c.req.query("token") ?? "";
|
|
2042
|
+
if (provided !== token && queryToken !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
2043
|
+
}
|
|
2044
|
+
return next();
|
|
2045
|
+
};
|
|
2046
|
+
const openapiHtmlHandler = () => new Response(renderOpenApiHtml(OPENAPI_SCHEMA), { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
2047
|
+
app.get(`${apiPrefix}/openapi.json`, openapiAuth, (c) => c.json(OPENAPI_SCHEMA));
|
|
2048
|
+
app.get(`${apiPrefix}/openapi.html`, openapiAuth, openapiHtmlHandler);
|
|
2049
|
+
app.get(`${apiPrefix}/openapi`, openapiAuth, openapiHtmlHandler);
|
|
2050
|
+
return app;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
//#endregion
|
|
2054
|
+
//#region src/bridge/warm-pool.ts
|
|
2055
|
+
/**
|
|
2056
|
+
* WarmPool — Durable Object that maintains a pool of pre-started sandbox containers.
|
|
2057
|
+
*
|
|
2058
|
+
* Adapted from https://github.com/mikenomitch/cf-container-warm-pool
|
|
2059
|
+
* Inlined and tailored for the @cloudflare/sandbox SDK.
|
|
2060
|
+
*
|
|
2061
|
+
* The pool keeps N idle containers standing by so new sandbox sessions boot
|
|
2062
|
+
* instantly. Once a container is assigned to a sandbox ID it is consumed and
|
|
2063
|
+
* never returned to the pool.
|
|
2064
|
+
*
|
|
2065
|
+
* Configuration is pushed in via `configure()` on every request (idempotent)
|
|
2066
|
+
* so changes to wrangler vars take effect without manual intervention.
|
|
2067
|
+
*/
|
|
2068
|
+
const DEFAULT_CONFIG = {
|
|
2069
|
+
warmTarget: 0,
|
|
2070
|
+
refreshInterval: 1e4
|
|
2071
|
+
};
|
|
2072
|
+
var WarmPool = class extends DurableObject {
|
|
2073
|
+
config = { ...DEFAULT_CONFIG };
|
|
2074
|
+
/** Container UUIDs that are warm and available for assignment */
|
|
2075
|
+
warmContainers = /* @__PURE__ */ new Set();
|
|
2076
|
+
/** Maps caller-provided sandbox IDs to container UUIDs (1:1, no sharing) */
|
|
2077
|
+
assignments = /* @__PURE__ */ new Map();
|
|
2078
|
+
/** Containers currently starting — excluded from health checks */
|
|
2079
|
+
startingContainers = /* @__PURE__ */ new Set();
|
|
2080
|
+
/** Inferred max_instances limit learned from Cloudflare errors, or null */
|
|
2081
|
+
knownMaxInstances = null;
|
|
2082
|
+
capacityExhausted = false;
|
|
2083
|
+
initialized = false;
|
|
2084
|
+
/**
|
|
2085
|
+
* Get a container UUID for the given sandbox ID.
|
|
2086
|
+
* - If this ID already has an assigned container that's still running, return it.
|
|
2087
|
+
* - Otherwise assign a warm container (or start a new one).
|
|
2088
|
+
*/
|
|
2089
|
+
async getContainer(sandboxId) {
|
|
2090
|
+
await this.init();
|
|
2091
|
+
const existing = this.assignments.get(sandboxId);
|
|
2092
|
+
if (existing) {
|
|
2093
|
+
if (await this.isContainerRunning(existing)) return existing;
|
|
2094
|
+
this.assignments.delete(sandboxId);
|
|
2095
|
+
await this.persist();
|
|
2096
|
+
}
|
|
2097
|
+
if (this.warmContainers.size > 0) {
|
|
2098
|
+
const containerUUID$1 = this.warmContainers.values().next().value;
|
|
2099
|
+
this.warmContainers.delete(containerUUID$1);
|
|
2100
|
+
this.assignments.set(sandboxId, containerUUID$1);
|
|
2101
|
+
await this.persist();
|
|
2102
|
+
return containerUUID$1;
|
|
2103
|
+
}
|
|
2104
|
+
if (this.remainingCapacity() <= 0) this.throwCapacityError();
|
|
2105
|
+
const containerUUID = await this.startContainer();
|
|
2106
|
+
if (containerUUID) {
|
|
2107
|
+
this.assignments.set(sandboxId, containerUUID);
|
|
2108
|
+
await this.persist();
|
|
2109
|
+
return containerUUID;
|
|
2110
|
+
}
|
|
2111
|
+
if (this.capacityExhausted) this.throwCapacityError();
|
|
2112
|
+
throw new Error("Failed to start container");
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Look up an existing container assignment without allocating.
|
|
2116
|
+
* Returns the container UUID if the sandbox ID has an active assignment, null otherwise.
|
|
2117
|
+
* Used by DELETE to avoid starting a container just to destroy it.
|
|
2118
|
+
*/
|
|
2119
|
+
async lookupContainer(sandboxId) {
|
|
2120
|
+
await this.init();
|
|
2121
|
+
const existing = this.assignments.get(sandboxId);
|
|
2122
|
+
if (existing) return existing;
|
|
2123
|
+
return null;
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Report that a container has stopped — removes it from tracking.
|
|
2127
|
+
*/
|
|
2128
|
+
async reportStopped(containerUUID) {
|
|
2129
|
+
await this.init();
|
|
2130
|
+
this.removeContainer(containerUUID);
|
|
2131
|
+
await this.persist();
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Get current pool statistics.
|
|
2135
|
+
*/
|
|
2136
|
+
async getStats() {
|
|
2137
|
+
await this.init();
|
|
2138
|
+
return {
|
|
2139
|
+
warm: this.warmContainers.size,
|
|
2140
|
+
assigned: this.assignments.size,
|
|
2141
|
+
total: this.warmContainers.size + this.assignments.size,
|
|
2142
|
+
config: this.config,
|
|
2143
|
+
maxInstances: this.knownMaxInstances
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* Update pool configuration. Idempotent — called on every request to keep
|
|
2148
|
+
* config in sync with wrangler vars across deploys.
|
|
2149
|
+
*/
|
|
2150
|
+
async configure(config) {
|
|
2151
|
+
await this.init();
|
|
2152
|
+
this.config = {
|
|
2153
|
+
...DEFAULT_CONFIG,
|
|
2154
|
+
...config
|
|
2155
|
+
};
|
|
2156
|
+
await this.ctx.storage.put("config", this.config);
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Shutdown all pre-warmed (unassigned) containers.
|
|
2160
|
+
* Does not affect containers that are assigned to sandbox IDs.
|
|
2161
|
+
*/
|
|
2162
|
+
async shutdownPrewarmed() {
|
|
2163
|
+
await this.init();
|
|
2164
|
+
for (const containerUUID of [...this.warmContainers]) try {
|
|
2165
|
+
await this.getSandboxStub(containerUUID).stop();
|
|
2166
|
+
this.warmContainers.delete(containerUUID);
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
console.error({
|
|
2169
|
+
message: "Failed to stop container",
|
|
2170
|
+
component: "warm-pool",
|
|
2171
|
+
containerUUID,
|
|
2172
|
+
error
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
await this.persist();
|
|
2176
|
+
}
|
|
2177
|
+
async alarm() {
|
|
2178
|
+
await this.init();
|
|
2179
|
+
this.capacityExhausted = false;
|
|
2180
|
+
try {
|
|
2181
|
+
await this.checkContainerHealth();
|
|
2182
|
+
await this.adjustPool();
|
|
2183
|
+
await this.keepWarmContainersAlive();
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
console.error({
|
|
2186
|
+
message: "Alarm handler error",
|
|
2187
|
+
component: "warm-pool",
|
|
2188
|
+
error
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
await this.ctx.storage.setAlarm(Date.now() + this.config.refreshInterval);
|
|
2192
|
+
}
|
|
2193
|
+
async init() {
|
|
2194
|
+
if (this.initialized) return;
|
|
2195
|
+
const storedWarm = await this.ctx.storage.get("warmContainers");
|
|
2196
|
+
if (storedWarm) this.warmContainers = new Set(storedWarm);
|
|
2197
|
+
const storedAssignments = await this.ctx.storage.get("assignments");
|
|
2198
|
+
if (storedAssignments) this.assignments = new Map(storedAssignments);
|
|
2199
|
+
const storedConfig = await this.ctx.storage.get("config");
|
|
2200
|
+
if (storedConfig) this.config = {
|
|
2201
|
+
...DEFAULT_CONFIG,
|
|
2202
|
+
...storedConfig
|
|
2203
|
+
};
|
|
2204
|
+
const storedMax = await this.ctx.storage.get("knownMaxInstances");
|
|
2205
|
+
if (storedMax !== void 0) this.knownMaxInstances = storedMax;
|
|
2206
|
+
this.initialized = true;
|
|
2207
|
+
await this.scheduleRefresh();
|
|
2208
|
+
}
|
|
2209
|
+
async persist() {
|
|
2210
|
+
await this.ctx.storage.put("warmContainers", this.warmContainers);
|
|
2211
|
+
await this.ctx.storage.put("assignments", this.assignments);
|
|
2212
|
+
if (this.knownMaxInstances !== null) await this.ctx.storage.put("knownMaxInstances", this.knownMaxInstances);
|
|
2213
|
+
else await this.ctx.storage.delete("knownMaxInstances");
|
|
2214
|
+
}
|
|
2215
|
+
async scheduleRefresh() {
|
|
2216
|
+
if (!await this.ctx.storage.getAlarm()) await this.ctx.storage.setAlarm(Date.now() + this.config.refreshInterval);
|
|
2217
|
+
}
|
|
2218
|
+
async startContainer() {
|
|
2219
|
+
const containerUUID = crypto.randomUUID();
|
|
2220
|
+
this.startingContainers.add(containerUUID);
|
|
2221
|
+
try {
|
|
2222
|
+
await this.getSandboxStub(containerUUID).startAndWaitForPorts();
|
|
2223
|
+
console.info({
|
|
2224
|
+
message: "Container started",
|
|
2225
|
+
component: "warm-pool",
|
|
2226
|
+
containerUUID
|
|
2227
|
+
});
|
|
2228
|
+
return containerUUID;
|
|
2229
|
+
} catch (error) {
|
|
2230
|
+
if (this.isMaxInstancesError(error)) await this.recordCapacityLimit();
|
|
2231
|
+
else console.error({
|
|
2232
|
+
message: "Failed to start container",
|
|
2233
|
+
component: "warm-pool",
|
|
2234
|
+
containerUUID,
|
|
2235
|
+
error
|
|
2236
|
+
});
|
|
2237
|
+
return null;
|
|
2238
|
+
} finally {
|
|
2239
|
+
this.startingContainers.delete(containerUUID);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
async isContainerRunning(containerUUID) {
|
|
2243
|
+
if (this.startingContainers.has(containerUUID)) return true;
|
|
2244
|
+
try {
|
|
2245
|
+
const state = await this.getSandboxStub(containerUUID).getState();
|
|
2246
|
+
return state.status === "running" || state.status === "healthy";
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
console.warn({
|
|
2249
|
+
message: "Failed to check container status, assuming stopped",
|
|
2250
|
+
component: "warm-pool",
|
|
2251
|
+
containerUUID,
|
|
2252
|
+
error
|
|
2253
|
+
});
|
|
2254
|
+
return false;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
async checkContainerHealth() {
|
|
2258
|
+
const allUUIDs = [...this.warmContainers, ...this.assignments.values()];
|
|
2259
|
+
let anyRemoved = false;
|
|
2260
|
+
for (const uuid of allUUIDs) if (!await this.isContainerRunning(uuid)) {
|
|
2261
|
+
console.info({
|
|
2262
|
+
message: "Container not running, removing from pool",
|
|
2263
|
+
component: "warm-pool",
|
|
2264
|
+
containerUUID: uuid
|
|
2265
|
+
});
|
|
2266
|
+
if (this.removeContainer(uuid)) anyRemoved = true;
|
|
2267
|
+
}
|
|
2268
|
+
if (anyRemoved) await this.persist();
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Renew activity timeout on all warm containers to prevent them from sleeping.
|
|
2272
|
+
*/
|
|
2273
|
+
async keepWarmContainersAlive() {
|
|
2274
|
+
for (const containerUUID of this.warmContainers) try {
|
|
2275
|
+
this.getSandboxStub(containerUUID).renewActivityTimeout();
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
console.error({
|
|
2278
|
+
message: "Failed to renew activity timeout",
|
|
2279
|
+
component: "warm-pool",
|
|
2280
|
+
containerUUID,
|
|
2281
|
+
error
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Scale the warm pool towards warmTarget, respecting inferred max_instances.
|
|
2287
|
+
*/
|
|
2288
|
+
async adjustPool() {
|
|
2289
|
+
let diff = this.config.warmTarget - this.warmContainers.size;
|
|
2290
|
+
if (diff > 0) {
|
|
2291
|
+
if (this.remainingCapacity() === 0 && this.knownMaxInstances !== null) {
|
|
2292
|
+
console.info({
|
|
2293
|
+
message: "Pool at inferred limit, probing for capacity changes",
|
|
2294
|
+
component: "warm-pool",
|
|
2295
|
+
knownMaxInstances: this.knownMaxInstances
|
|
2296
|
+
});
|
|
2297
|
+
const probeUUID = await this.startContainer();
|
|
2298
|
+
if (probeUUID) {
|
|
2299
|
+
console.info({
|
|
2300
|
+
message: "Probe succeeded, clearing cached limit",
|
|
2301
|
+
component: "warm-pool"
|
|
2302
|
+
});
|
|
2303
|
+
this.knownMaxInstances = null;
|
|
2304
|
+
this.warmContainers.add(probeUUID);
|
|
2305
|
+
diff--;
|
|
2306
|
+
await this.persist();
|
|
2307
|
+
} else {
|
|
2308
|
+
await this.persist();
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const toStart = Math.min(diff, this.remainingCapacity());
|
|
2313
|
+
if (toStart <= 0) {
|
|
2314
|
+
console.log({
|
|
2315
|
+
message: "Cannot scale up pool",
|
|
2316
|
+
component: "warm-pool",
|
|
2317
|
+
needed: diff,
|
|
2318
|
+
available: this.remainingCapacity(),
|
|
2319
|
+
warm: this.warmContainers.size,
|
|
2320
|
+
assigned: this.assignments.size,
|
|
2321
|
+
knownMaxInstances: this.knownMaxInstances ?? "unknown"
|
|
2322
|
+
});
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
console.info({
|
|
2326
|
+
message: "Scaling up pool",
|
|
2327
|
+
component: "warm-pool",
|
|
2328
|
+
starting: toStart,
|
|
2329
|
+
needed: diff,
|
|
2330
|
+
capacity: this.remainingCapacity()
|
|
2331
|
+
});
|
|
2332
|
+
for (let i = 0; i < toStart; i++) {
|
|
2333
|
+
if (this.capacityExhausted) {
|
|
2334
|
+
console.log({
|
|
2335
|
+
message: "Capacity exhausted mid-loop, stopping further starts",
|
|
2336
|
+
component: "warm-pool"
|
|
2337
|
+
});
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
const uuid = await this.startContainer();
|
|
2341
|
+
if (uuid) this.warmContainers.add(uuid);
|
|
2342
|
+
}
|
|
2343
|
+
await this.persist();
|
|
2344
|
+
} else if (diff < 0) {
|
|
2345
|
+
const excess = -diff;
|
|
2346
|
+
console.info({
|
|
2347
|
+
message: "Scaling down pool",
|
|
2348
|
+
component: "warm-pool",
|
|
2349
|
+
stopping: excess
|
|
2350
|
+
});
|
|
2351
|
+
const toStop = [...this.warmContainers].slice(0, excess);
|
|
2352
|
+
const stopped = [];
|
|
2353
|
+
for (const uuid of toStop) try {
|
|
2354
|
+
await this.getSandboxStub(uuid).stop();
|
|
2355
|
+
stopped.push(uuid);
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
console.error({
|
|
2358
|
+
message: "Failed to stop container",
|
|
2359
|
+
component: "warm-pool",
|
|
2360
|
+
containerUUID: uuid,
|
|
2361
|
+
error
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
for (const uuid of stopped) this.warmContainers.delete(uuid);
|
|
2365
|
+
await this.persist();
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
removeContainer(containerUUID) {
|
|
2369
|
+
let removed = false;
|
|
2370
|
+
if (this.warmContainers.delete(containerUUID)) removed = true;
|
|
2371
|
+
for (const [sandboxId, uuid] of this.assignments) if (uuid === containerUUID) {
|
|
2372
|
+
this.assignments.delete(sandboxId);
|
|
2373
|
+
removed = true;
|
|
2374
|
+
break;
|
|
2375
|
+
}
|
|
2376
|
+
return removed;
|
|
2377
|
+
}
|
|
2378
|
+
remainingCapacity() {
|
|
2379
|
+
if (this.knownMaxInstances === null) return Infinity;
|
|
2380
|
+
return Math.max(0, this.knownMaxInstances - (this.warmContainers.size + this.assignments.size));
|
|
2381
|
+
}
|
|
2382
|
+
isMaxInstancesError(error) {
|
|
2383
|
+
return (error instanceof Error ? error.message : String(error)).includes("Maximum number of running container instances exceeded");
|
|
2384
|
+
}
|
|
2385
|
+
async recordCapacityLimit() {
|
|
2386
|
+
const currentTotal = this.warmContainers.size + this.assignments.size;
|
|
2387
|
+
this.knownMaxInstances = currentTotal;
|
|
2388
|
+
this.capacityExhausted = true;
|
|
2389
|
+
console.warn({
|
|
2390
|
+
message: "Hit max_instances limit",
|
|
2391
|
+
component: "warm-pool",
|
|
2392
|
+
inferredCeiling: currentTotal,
|
|
2393
|
+
warm: this.warmContainers.size,
|
|
2394
|
+
assigned: this.assignments.size
|
|
2395
|
+
});
|
|
2396
|
+
await this.ctx.storage.put("knownMaxInstances", this.knownMaxInstances);
|
|
2397
|
+
}
|
|
2398
|
+
throwCapacityError() {
|
|
2399
|
+
const total = this.warmContainers.size + this.assignments.size;
|
|
2400
|
+
throw new Error(`Cannot start container: instance limit reached (${total}/${this.knownMaxInstances}). All container slots are in use. Wait for existing containers to stop.`);
|
|
2401
|
+
}
|
|
2402
|
+
getSandboxStub(containerUUID) {
|
|
2403
|
+
const id = this.env.Sandbox.idFromName(containerUUID);
|
|
2404
|
+
return this.env.Sandbox.get(id);
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
|
|
2408
|
+
//#endregion
|
|
2409
|
+
//#region src/bridge/index.ts
|
|
2410
|
+
/**
|
|
2411
|
+
* @cloudflare/sandbox/bridge — Bridge factory for Cloudflare Sandbox Workers.
|
|
2412
|
+
*
|
|
2413
|
+
* Usage:
|
|
2414
|
+
* ```ts
|
|
2415
|
+
* import { bridge } from "@cloudflare/sandbox/bridge";
|
|
2416
|
+
* export { Sandbox } from "@cloudflare/sandbox";
|
|
2417
|
+
* export { WarmPool } from "@cloudflare/sandbox/bridge";
|
|
2418
|
+
*
|
|
2419
|
+
* export default bridge({
|
|
2420
|
+
* async fetch(request, env, ctx) {
|
|
2421
|
+
* return new Response("OK");
|
|
2422
|
+
* },
|
|
2423
|
+
* async scheduled(controller, env, ctx) {
|
|
2424
|
+
* // custom scheduled logic
|
|
2425
|
+
* }
|
|
2426
|
+
* });
|
|
2427
|
+
* ```
|
|
2428
|
+
*/
|
|
2429
|
+
/**
|
|
2430
|
+
* Log an error if the required Durable Object bindings are missing from the
|
|
2431
|
+
* module-level environment. The `cloudflare:workers` env exposes binding stubs
|
|
2432
|
+
* at module evaluation time, so a missing key means the wrangler.jsonc
|
|
2433
|
+
* configuration is wrong. The health endpoint performs the same validation at
|
|
2434
|
+
* request time and returns a 503.
|
|
2435
|
+
*/
|
|
2436
|
+
function checkBindings(env$1, options) {
|
|
2437
|
+
const { sandboxBinding, warmPoolBinding } = options;
|
|
2438
|
+
const missing = [];
|
|
2439
|
+
if (!env$1[sandboxBinding]) missing.push(sandboxBinding);
|
|
2440
|
+
if (!env$1[warmPoolBinding]) missing.push(warmPoolBinding);
|
|
2441
|
+
if (missing.length > 0) for (const binding of missing) console.error({
|
|
2442
|
+
message: `Missing required binding "${binding}"`,
|
|
2443
|
+
component: "bridge",
|
|
2444
|
+
binding,
|
|
2445
|
+
hint: `Ensure your wrangler.jsonc has a Durable Object binding named "${binding}".`
|
|
2446
|
+
});
|
|
2447
|
+
if (!env$1.SANDBOX_API_KEY) console.warn({
|
|
2448
|
+
message: "SANDBOX_API_KEY is not set — auth is disabled",
|
|
2449
|
+
component: "bridge",
|
|
2450
|
+
hint: "Set via `wrangler secret put SANDBOX_API_KEY`."
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Create a Worker export that wraps user handlers with bridge functionality.
|
|
2455
|
+
*
|
|
2456
|
+
* The bridge:
|
|
2457
|
+
* 1. Checks that required Durable Object bindings exist (logs errors if missing).
|
|
2458
|
+
* 2. Wraps `fetch()` to serve bridge API routes first, then falls through to the user handler.
|
|
2459
|
+
* 3. Wraps `scheduled()` to prime the warm pool, then calls the user handler.
|
|
2460
|
+
* 4. Passes through all other properties unchanged.
|
|
2461
|
+
*
|
|
2462
|
+
* @param worker - The user's worker handlers (fetch, scheduled, and any others).
|
|
2463
|
+
* @param config - Optional configuration for binding names and route paths.
|
|
2464
|
+
*/
|
|
2465
|
+
function bridge(worker, config) {
|
|
2466
|
+
const sandboxBinding = config?.bindings?.sandbox ?? "Sandbox";
|
|
2467
|
+
const warmPoolBinding = config?.bindings?.warmPool ?? "WarmPool";
|
|
2468
|
+
const apiPrefix = config?.apiRoutePrefix ?? "/v1";
|
|
2469
|
+
const healthPath = config?.healthRoute ?? "/health";
|
|
2470
|
+
checkBindings(env, {
|
|
2471
|
+
sandboxBinding,
|
|
2472
|
+
warmPoolBinding
|
|
2473
|
+
});
|
|
2474
|
+
const app = createBridgeApp({
|
|
2475
|
+
sandboxBinding,
|
|
2476
|
+
warmPoolBinding,
|
|
2477
|
+
apiPrefix,
|
|
2478
|
+
healthPath
|
|
2479
|
+
});
|
|
2480
|
+
const passThrough = {};
|
|
2481
|
+
for (const key of Object.keys(worker)) if (key !== "fetch" && key !== "scheduled") passThrough[key] = worker[key];
|
|
2482
|
+
return {
|
|
2483
|
+
...passThrough,
|
|
2484
|
+
async fetch(request, env$1, ctx) {
|
|
2485
|
+
const url = new URL(request.url);
|
|
2486
|
+
if (url.pathname.startsWith(apiPrefix + "/") || url.pathname === apiPrefix || url.pathname === healthPath) return app.fetch(request, env$1, ctx);
|
|
2487
|
+
if (worker.fetch) return worker.fetch(request, env$1, ctx);
|
|
2488
|
+
return new Response("Not Found", { status: 404 });
|
|
2489
|
+
},
|
|
2490
|
+
async scheduled(controller, env$1, ctx) {
|
|
2491
|
+
await primePool(env$1, warmPoolBinding);
|
|
2492
|
+
if (worker.scheduled) await worker.scheduled(controller, env$1, ctx);
|
|
2493
|
+
}
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
//#endregion
|
|
2498
|
+
export { WarmPool, bridge, resolveWorkspacePath, shellQuote };
|
|
2499
|
+
//# sourceMappingURL=index.js.map
|