@cloudflare/sandbox 0.13.0-next.769.1 → 1.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -219
- package/dist/index.d.mts +548 -0
- package/dist/index.mjs +2452 -0
- package/package.json +17 -118
- package/Dockerfile +0 -327
- package/dist/bridge/index.d.ts +0 -181
- package/dist/bridge/index.d.ts.map +0 -1
- package/dist/bridge/index.js +0 -3053
- package/dist/bridge/index.js.map +0 -1
- package/dist/contexts-1EsLHByO.d.ts +0 -463
- package/dist/contexts-1EsLHByO.d.ts.map +0 -1
- package/dist/dist-Duor5GbS.js +0 -752
- package/dist/dist-Duor5GbS.js.map +0 -1
- package/dist/errors/index.d.ts +0 -4
- package/dist/errors/index.js +0 -4
- package/dist/errors-CXR0xBpw.js +0 -285
- package/dist/errors-CXR0xBpw.js.map +0 -1
- package/dist/errors-QYlSkVGz.js +0 -893
- package/dist/errors-QYlSkVGz.js.map +0 -1
- package/dist/extensions/index.d.ts +0 -4
- package/dist/extensions/index.js +0 -6
- package/dist/extensions-CFB2xHqY.js +0 -1023
- package/dist/extensions-CFB2xHqY.js.map +0 -1
- package/dist/filesystem-BWAZCZER.d.ts +0 -732
- package/dist/filesystem-BWAZCZER.d.ts.map +0 -1
- package/dist/git/index.d.ts +0 -63
- package/dist/git/index.d.ts.map +0 -1
- package/dist/git/index.js +0 -338
- package/dist/git/index.js.map +0 -1
- package/dist/index-Bs4bqXDR.d.ts +0 -438
- package/dist/index-Bs4bqXDR.d.ts.map +0 -1
- package/dist/index-HNYBk-az.d.ts +0 -444
- package/dist/index-HNYBk-az.d.ts.map +0 -1
- package/dist/index.d.ts +0 -576
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -33
- package/dist/index.js.map +0 -1
- package/dist/interpreter/index.d.ts +0 -311
- package/dist/interpreter/index.d.ts.map +0 -1
- package/dist/interpreter/index.js +0 -292
- package/dist/interpreter/index.js.map +0 -1
- package/dist/openai/index.d.ts +0 -68
- package/dist/openai/index.d.ts.map +0 -1
- package/dist/openai/index.js +0 -367
- package/dist/openai/index.js.map +0 -1
- package/dist/opencode/index.d.ts +0 -182
- package/dist/opencode/index.d.ts.map +0 -1
- package/dist/opencode/index.js +0 -454
- package/dist/opencode/index.js.map +0 -1
- package/dist/process-types-GStiZ8f8.d.ts +0 -73
- package/dist/process-types-GStiZ8f8.d.ts.map +0 -1
- package/dist/sandbox-BbAabq93.d.ts +0 -42
- package/dist/sandbox-BbAabq93.d.ts.map +0 -1
- package/dist/sandbox-cmlgGVYX.js +0 -10056
- package/dist/sandbox-cmlgGVYX.js.map +0 -1
- package/dist/sidecar/index.d.ts +0 -77
- package/dist/sidecar/index.d.ts.map +0 -1
- package/dist/sidecar/index.js +0 -201
- package/dist/sidecar/index.js.map +0 -1
- package/dist/xterm/index.d.ts +0 -93
- package/dist/xterm/index.d.ts.map +0 -1
- package/dist/xterm/index.js +0 -220
- package/dist/xterm/index.js.map +0 -1
package/dist/bridge/index.js
DELETED
|
@@ -1,3053 +0,0 @@
|
|
|
1
|
-
import "../errors-CXR0xBpw.js";
|
|
2
|
-
import "../errors-QYlSkVGz.js";
|
|
3
|
-
import "../dist-Duor5GbS.js";
|
|
4
|
-
import { g as validateTunnelName, h as validatePort, m as SandboxSecurityError, n as getSandbox$1, x as streamFile } from "../sandbox-cmlgGVYX.js";
|
|
5
|
-
import "../extensions-CFB2xHqY.js";
|
|
6
|
-
import { DurableObject, env } from "cloudflare:workers";
|
|
7
|
-
import { Hono } from "hono";
|
|
8
|
-
|
|
9
|
-
//#region src/bridge/pool.ts
|
|
10
|
-
/**
|
|
11
|
-
* Prime the warm pool — pushes current configuration to the WarmPool
|
|
12
|
-
* Durable Object so it starts its alarm loop.
|
|
13
|
-
*
|
|
14
|
-
* Called by the scheduled() handler and by POST /pool/prime.
|
|
15
|
-
*/
|
|
16
|
-
async function primePool(env$1, warmPoolBinding) {
|
|
17
|
-
const warmTarget = Number.parseInt(env$1.WARM_POOL_TARGET || "0", 10) || 0;
|
|
18
|
-
const refreshInterval = Number.parseInt(env$1.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
19
|
-
const ns = env$1[warmPoolBinding];
|
|
20
|
-
const poolId = ns.idFromName("global-pool");
|
|
21
|
-
await ns.get(poolId).configure({
|
|
22
|
-
warmTarget,
|
|
23
|
-
refreshInterval
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region src/bridge/helpers.ts
|
|
29
|
-
/**
|
|
30
|
-
* Utility functions used by the bridge routes.
|
|
31
|
-
*/
|
|
32
|
-
/** RFC 4648 base32 encoding (lowercase). Returns only [a-z2-7]. */
|
|
33
|
-
function base32Encode(data) {
|
|
34
|
-
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
35
|
-
let bits = 0;
|
|
36
|
-
let value = 0;
|
|
37
|
-
let out = "";
|
|
38
|
-
for (const byte of data) {
|
|
39
|
-
value = value << 8 | byte;
|
|
40
|
-
bits += 8;
|
|
41
|
-
while (bits >= 5) {
|
|
42
|
-
out += alphabet[value >>> bits - 5 & 31];
|
|
43
|
-
bits -= 5;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
if (bits > 0) out += alphabet[value << 5 - bits & 31];
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
function errorJson(error, code, status) {
|
|
50
|
-
const body = {
|
|
51
|
-
error,
|
|
52
|
-
code
|
|
53
|
-
};
|
|
54
|
-
return new Response(JSON.stringify(body), {
|
|
55
|
-
status,
|
|
56
|
-
headers: { "Content-Type": "application/json" }
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Shell-quote a single argv token so it is safe to embed in a sh command
|
|
61
|
-
* string. Tokens that contain only safe characters are returned unchanged
|
|
62
|
-
* for readability. All others are wrapped in ANSI-C $'...' quoting which
|
|
63
|
-
* can represent newlines, tabs, and other control characters as escape
|
|
64
|
-
* sequences — unlike plain single quotes which pass content literally and
|
|
65
|
-
* break when the value contains a real newline.
|
|
66
|
-
*/
|
|
67
|
-
function shellQuote(arg) {
|
|
68
|
-
if (/^[A-Za-z0-9@%+=:,./-]+$/.test(arg)) return arg;
|
|
69
|
-
return `$'${arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}'`;
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* POSIX-normalise a path (resolve `.` / `..` segments) and verify it lives
|
|
73
|
-
* under /workspace. Returns the resolved absolute path on success, or null
|
|
74
|
-
* if the path escapes the workspace.
|
|
75
|
-
*/
|
|
76
|
-
function resolveWorkspacePath(userPath) {
|
|
77
|
-
const abs = userPath.startsWith("/") ? userPath : `/workspace/${userPath}`;
|
|
78
|
-
const parts = [];
|
|
79
|
-
for (const seg of abs.split("/")) {
|
|
80
|
-
if (seg === "" || seg === ".") continue;
|
|
81
|
-
if (seg === "..") parts.pop();
|
|
82
|
-
else parts.push(seg);
|
|
83
|
-
}
|
|
84
|
-
const resolved = `/${parts.join("/")}`;
|
|
85
|
-
if (resolved === "/workspace" || resolved.startsWith("/workspace/")) return resolved;
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Validate a terminal ID. Rejects path traversal, control chars, and excessive length.
|
|
90
|
-
* Returns the validated ID or null if invalid.
|
|
91
|
-
*/
|
|
92
|
-
function validateTerminalId(id) {
|
|
93
|
-
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(id)) return null;
|
|
94
|
-
if (id.includes("..")) return null;
|
|
95
|
-
return id;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Convert an SSE stream from readFileStream() into a raw byte stream.
|
|
99
|
-
* Decodes base64 chunks for binary files and UTF-8-encodes text chunks.
|
|
100
|
-
*/
|
|
101
|
-
function sseToByteStream(sse) {
|
|
102
|
-
const encoder = new TextEncoder();
|
|
103
|
-
const iterator = streamFile(sse)[Symbol.asyncIterator]();
|
|
104
|
-
let finished = false;
|
|
105
|
-
async function finish(cancelSource) {
|
|
106
|
-
if (finished) return;
|
|
107
|
-
finished = true;
|
|
108
|
-
if (!cancelSource) return;
|
|
109
|
-
await iterator.return?.().catch(() => void 0);
|
|
110
|
-
}
|
|
111
|
-
return new ReadableStream({
|
|
112
|
-
async pull(controller) {
|
|
113
|
-
try {
|
|
114
|
-
const result = await iterator.next();
|
|
115
|
-
if (result.done) {
|
|
116
|
-
await finish(false);
|
|
117
|
-
controller.close();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
controller.enqueue(result.value instanceof Uint8Array ? result.value : encoder.encode(result.value));
|
|
121
|
-
} catch (error) {
|
|
122
|
-
await finish(true);
|
|
123
|
-
controller.error(error);
|
|
124
|
-
}
|
|
125
|
-
},
|
|
126
|
-
async cancel() {
|
|
127
|
-
await finish(true);
|
|
128
|
-
}
|
|
129
|
-
}, { highWaterMark: 0 });
|
|
130
|
-
}
|
|
131
|
-
function withStreamCleanup(source, cleanup) {
|
|
132
|
-
const reader = source.getReader();
|
|
133
|
-
let finished = false;
|
|
134
|
-
async function finish(cancelSource) {
|
|
135
|
-
if (finished) return;
|
|
136
|
-
finished = true;
|
|
137
|
-
if (cancelSource) await reader.cancel().catch(() => void 0);
|
|
138
|
-
reader.releaseLock();
|
|
139
|
-
await cleanup().catch(() => void 0);
|
|
140
|
-
}
|
|
141
|
-
return new ReadableStream({
|
|
142
|
-
async pull(controller) {
|
|
143
|
-
try {
|
|
144
|
-
const result = await reader.read();
|
|
145
|
-
if (result.done) {
|
|
146
|
-
await finish(false);
|
|
147
|
-
controller.close();
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
controller.enqueue(result.value);
|
|
151
|
-
} catch (error) {
|
|
152
|
-
await finish(true);
|
|
153
|
-
controller.error(error);
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
async cancel() {
|
|
157
|
-
await finish(true);
|
|
158
|
-
}
|
|
159
|
-
}, { highWaterMark: 0 });
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
//#endregion
|
|
163
|
-
//#region src/bridge/openapi/common.ts
|
|
164
|
-
/**
|
|
165
|
-
* OpenAPI 3.1 schema for the Cloudflare Sandbox Service API.
|
|
166
|
-
*
|
|
167
|
-
* Served at GET /v1/openapi.json (requires Bearer token auth).
|
|
168
|
-
*/
|
|
169
|
-
const OPENAPI_BASE = {
|
|
170
|
-
openapi: "3.1.0",
|
|
171
|
-
info: {
|
|
172
|
-
title: "Cloudflare Sandbox Service API",
|
|
173
|
-
version: "1.0.0",
|
|
174
|
-
description: "HTTP API consumed by the Python `CloudflareSandboxClient`. Forwards each operation to a named Cloudflare Sandbox Durable Object via the `@cloudflare/sandbox` SDK."
|
|
175
|
-
},
|
|
176
|
-
components: {
|
|
177
|
-
securitySchemes: { BearerAuth: {
|
|
178
|
-
type: "http",
|
|
179
|
-
scheme: "bearer",
|
|
180
|
-
description: "API token set via `wrangler secret put SANDBOX_API_KEY`. The /openapi.* routes also accept the token as a `?token=` query parameter."
|
|
181
|
-
} },
|
|
182
|
-
schemas: {
|
|
183
|
-
ProcessCreateRequest: {
|
|
184
|
-
type: "object",
|
|
185
|
-
required: ["argv"],
|
|
186
|
-
properties: {
|
|
187
|
-
argv: {
|
|
188
|
-
type: "array",
|
|
189
|
-
items: { type: "string" },
|
|
190
|
-
minItems: 1,
|
|
191
|
-
description: "Argv array to launch directly.",
|
|
192
|
-
example: [
|
|
193
|
-
"sh",
|
|
194
|
-
"-lc",
|
|
195
|
-
"echo hello"
|
|
196
|
-
]
|
|
197
|
-
},
|
|
198
|
-
timeout: {
|
|
199
|
-
type: "integer",
|
|
200
|
-
description: "Remote process lifetime deadline in milliseconds. When reached, the sandbox may stop the process and the process exit outcome reports `timedOut: true`; this is not caller-local observation cancellation.",
|
|
201
|
-
example: 3e4
|
|
202
|
-
},
|
|
203
|
-
cwd: {
|
|
204
|
-
type: "string",
|
|
205
|
-
description: "Working directory for the command (defaults to sandbox cwd).",
|
|
206
|
-
example: "/workspace"
|
|
207
|
-
},
|
|
208
|
-
env: {
|
|
209
|
-
type: "object",
|
|
210
|
-
additionalProperties: { type: "string" },
|
|
211
|
-
description: "Environment overrides for the process."
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
ProcessStatus: {
|
|
216
|
-
oneOf: [
|
|
217
|
-
{
|
|
218
|
-
type: "object",
|
|
219
|
-
required: [
|
|
220
|
-
"id",
|
|
221
|
-
"pid",
|
|
222
|
-
"command",
|
|
223
|
-
"state",
|
|
224
|
-
"startedAt"
|
|
225
|
-
],
|
|
226
|
-
properties: {
|
|
227
|
-
id: { type: "string" },
|
|
228
|
-
pid: { type: "integer" },
|
|
229
|
-
command: {
|
|
230
|
-
type: "array",
|
|
231
|
-
items: { type: "string" }
|
|
232
|
-
},
|
|
233
|
-
cwd: { type: "string" },
|
|
234
|
-
state: { const: "running" },
|
|
235
|
-
startedAt: { type: "string" }
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
type: "object",
|
|
240
|
-
required: [
|
|
241
|
-
"id",
|
|
242
|
-
"pid",
|
|
243
|
-
"command",
|
|
244
|
-
"state",
|
|
245
|
-
"startedAt",
|
|
246
|
-
"endedAt",
|
|
247
|
-
"exit"
|
|
248
|
-
],
|
|
249
|
-
properties: {
|
|
250
|
-
id: { type: "string" },
|
|
251
|
-
pid: { type: "integer" },
|
|
252
|
-
command: {
|
|
253
|
-
type: "array",
|
|
254
|
-
items: { type: "string" }
|
|
255
|
-
},
|
|
256
|
-
cwd: { type: "string" },
|
|
257
|
-
state: { const: "exited" },
|
|
258
|
-
startedAt: { type: "string" },
|
|
259
|
-
endedAt: { type: "string" },
|
|
260
|
-
exit: {
|
|
261
|
-
type: "object",
|
|
262
|
-
required: ["code", "timedOut"],
|
|
263
|
-
properties: {
|
|
264
|
-
code: { type: "integer" },
|
|
265
|
-
signal: { type: "integer" },
|
|
266
|
-
timedOut: { type: "boolean" }
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
type: "object",
|
|
273
|
-
required: [
|
|
274
|
-
"id",
|
|
275
|
-
"pid",
|
|
276
|
-
"command",
|
|
277
|
-
"state",
|
|
278
|
-
"startedAt",
|
|
279
|
-
"endedAt",
|
|
280
|
-
"error"
|
|
281
|
-
],
|
|
282
|
-
properties: {
|
|
283
|
-
id: { type: "string" },
|
|
284
|
-
pid: { type: "integer" },
|
|
285
|
-
command: {
|
|
286
|
-
type: "array",
|
|
287
|
-
items: { type: "string" }
|
|
288
|
-
},
|
|
289
|
-
cwd: { type: "string" },
|
|
290
|
-
state: { const: "error" },
|
|
291
|
-
startedAt: { type: "string" },
|
|
292
|
-
endedAt: { type: "string" },
|
|
293
|
-
error: {
|
|
294
|
-
type: "object",
|
|
295
|
-
required: ["code", "message"],
|
|
296
|
-
properties: {
|
|
297
|
-
code: { type: "string" },
|
|
298
|
-
message: { type: "string" }
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
],
|
|
304
|
-
discriminator: { propertyName: "state" }
|
|
305
|
-
},
|
|
306
|
-
WriteResponse: {
|
|
307
|
-
type: "object",
|
|
308
|
-
required: ["ok"],
|
|
309
|
-
properties: { ok: {
|
|
310
|
-
type: "boolean",
|
|
311
|
-
enum: [true],
|
|
312
|
-
description: "Always `true` on success."
|
|
313
|
-
} }
|
|
314
|
-
},
|
|
315
|
-
RunningResponse: {
|
|
316
|
-
type: "object",
|
|
317
|
-
required: ["running"],
|
|
318
|
-
properties: { running: {
|
|
319
|
-
type: "boolean",
|
|
320
|
-
description: "`true` if the sandbox container is alive and responding."
|
|
321
|
-
} }
|
|
322
|
-
},
|
|
323
|
-
OkResponse: {
|
|
324
|
-
type: "object",
|
|
325
|
-
required: ["ok"],
|
|
326
|
-
properties: { ok: {
|
|
327
|
-
type: "boolean",
|
|
328
|
-
enum: [true],
|
|
329
|
-
description: "Always `true` on success."
|
|
330
|
-
} }
|
|
331
|
-
},
|
|
332
|
-
MountBucketCredentials: {
|
|
333
|
-
type: "object",
|
|
334
|
-
required: ["accessKeyId", "secretAccessKey"],
|
|
335
|
-
properties: {
|
|
336
|
-
accessKeyId: {
|
|
337
|
-
type: "string",
|
|
338
|
-
description: "S3-compatible access key ID."
|
|
339
|
-
},
|
|
340
|
-
secretAccessKey: {
|
|
341
|
-
type: "string",
|
|
342
|
-
description: "S3-compatible secret access key."
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
MountBucketRequestOptions: {
|
|
347
|
-
type: "object",
|
|
348
|
-
properties: {
|
|
349
|
-
endpoint: {
|
|
350
|
-
type: "string",
|
|
351
|
-
description: "S3-compatible endpoint URL for remote mounts. Mutually exclusive with top-level `binding`.",
|
|
352
|
-
example: "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com"
|
|
353
|
-
},
|
|
354
|
-
readOnly: {
|
|
355
|
-
type: "boolean",
|
|
356
|
-
description: "Mount filesystem as read-only (default: false).",
|
|
357
|
-
default: false
|
|
358
|
-
},
|
|
359
|
-
prefix: {
|
|
360
|
-
type: "string",
|
|
361
|
-
description: "Optional prefix/subdirectory within the bucket to mount. Must start with `/`. Trailing slashes are stripped automatically.",
|
|
362
|
-
example: "/uploads/images"
|
|
363
|
-
},
|
|
364
|
-
credentials: {
|
|
365
|
-
$ref: "#/components/schemas/MountBucketCredentials",
|
|
366
|
-
description: "Explicit credentials. When omitted, the SDK auto-detects from Worker secrets (R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY or AWS equivalents)."
|
|
367
|
-
},
|
|
368
|
-
credentialProxy: {
|
|
369
|
-
type: "boolean",
|
|
370
|
-
description: "Keep credentials in the Durable Object and sign intercepted s3fs requests from the Worker. Credentials may be explicit or auto-detected from Worker secrets.",
|
|
371
|
-
default: false
|
|
372
|
-
},
|
|
373
|
-
s3fsOptions: {
|
|
374
|
-
type: "array",
|
|
375
|
-
items: { type: "string" },
|
|
376
|
-
description: "Advanced: Override or extend s3fs mount options for both remote mounts and R2 binding mounts.",
|
|
377
|
-
example: ["nomultipart"]
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
},
|
|
381
|
-
MountBucketRequest: {
|
|
382
|
-
type: "object",
|
|
383
|
-
required: ["mountPath", "options"],
|
|
384
|
-
properties: {
|
|
385
|
-
bucket: {
|
|
386
|
-
type: "string",
|
|
387
|
-
description: "Remote bucket name for endpoint-based S3-compatible mounts.",
|
|
388
|
-
example: "my-r2-bucket"
|
|
389
|
-
},
|
|
390
|
-
binding: {
|
|
391
|
-
type: "string",
|
|
392
|
-
description: "Worker R2 binding name for credential-less R2 binding mounts. Mutually exclusive with `options.endpoint`.",
|
|
393
|
-
example: "MY_BUCKET"
|
|
394
|
-
},
|
|
395
|
-
mountPath: {
|
|
396
|
-
type: "string",
|
|
397
|
-
description: "Absolute path in the container to mount at.",
|
|
398
|
-
example: "/mnt/data"
|
|
399
|
-
},
|
|
400
|
-
options: { $ref: "#/components/schemas/MountBucketRequestOptions" }
|
|
401
|
-
}
|
|
402
|
-
},
|
|
403
|
-
UnmountBucketRequest: {
|
|
404
|
-
type: "object",
|
|
405
|
-
required: ["mountPath"],
|
|
406
|
-
properties: { mountPath: {
|
|
407
|
-
type: "string",
|
|
408
|
-
description: "Absolute path where the bucket is currently mounted.",
|
|
409
|
-
example: "/mnt/data"
|
|
410
|
-
} }
|
|
411
|
-
},
|
|
412
|
-
TunnelRequest: {
|
|
413
|
-
type: "object",
|
|
414
|
-
properties: { name: {
|
|
415
|
-
type: "string",
|
|
416
|
-
description: "Subdomain prefix for a named tunnel, such as `app`. Do not pass a full hostname. Omit to create or reuse an ephemeral tunnel.",
|
|
417
|
-
example: "app"
|
|
418
|
-
} }
|
|
419
|
-
},
|
|
420
|
-
Tunnel: {
|
|
421
|
-
type: "object",
|
|
422
|
-
required: [
|
|
423
|
-
"id",
|
|
424
|
-
"port",
|
|
425
|
-
"url",
|
|
426
|
-
"hostname",
|
|
427
|
-
"createdAt"
|
|
428
|
-
],
|
|
429
|
-
properties: {
|
|
430
|
-
id: { type: "string" },
|
|
431
|
-
port: {
|
|
432
|
-
type: "integer",
|
|
433
|
-
description: "Container port served by the tunnel.",
|
|
434
|
-
example: 8080
|
|
435
|
-
},
|
|
436
|
-
url: {
|
|
437
|
-
type: "string",
|
|
438
|
-
format: "uri",
|
|
439
|
-
example: "https://app.example.com"
|
|
440
|
-
},
|
|
441
|
-
hostname: {
|
|
442
|
-
type: "string",
|
|
443
|
-
example: "app.example.com"
|
|
444
|
-
},
|
|
445
|
-
createdAt: {
|
|
446
|
-
type: "string",
|
|
447
|
-
format: "date-time"
|
|
448
|
-
},
|
|
449
|
-
name: {
|
|
450
|
-
type: "string",
|
|
451
|
-
description: "Present for named tunnels only.",
|
|
452
|
-
example: "app"
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
},
|
|
456
|
-
ErrorResponse: {
|
|
457
|
-
type: "object",
|
|
458
|
-
required: ["error", "code"],
|
|
459
|
-
properties: {
|
|
460
|
-
error: {
|
|
461
|
-
type: "string",
|
|
462
|
-
description: "Human-readable error description."
|
|
463
|
-
},
|
|
464
|
-
code: {
|
|
465
|
-
type: "string",
|
|
466
|
-
description: "Stable machine-readable error code.",
|
|
467
|
-
enum: [
|
|
468
|
-
"unauthorized",
|
|
469
|
-
"invalid_request",
|
|
470
|
-
"exec_error",
|
|
471
|
-
"exec_transport_error",
|
|
472
|
-
"workspace_read_not_found",
|
|
473
|
-
"workspace_archive_read_error",
|
|
474
|
-
"workspace_archive_write_error",
|
|
475
|
-
"capacity_exceeded",
|
|
476
|
-
"pool_error",
|
|
477
|
-
"mount_error",
|
|
478
|
-
"unmount_error",
|
|
479
|
-
"tunnel_error"
|
|
480
|
-
]
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
},
|
|
484
|
-
PoolStats: {
|
|
485
|
-
type: "object",
|
|
486
|
-
required: [
|
|
487
|
-
"warm",
|
|
488
|
-
"assigned",
|
|
489
|
-
"total",
|
|
490
|
-
"config",
|
|
491
|
-
"maxInstances"
|
|
492
|
-
],
|
|
493
|
-
properties: {
|
|
494
|
-
warm: {
|
|
495
|
-
type: "integer",
|
|
496
|
-
description: "Number of warm (unassigned) containers ready for use."
|
|
497
|
-
},
|
|
498
|
-
assigned: {
|
|
499
|
-
type: "integer",
|
|
500
|
-
description: "Number of containers assigned to sandbox IDs."
|
|
501
|
-
},
|
|
502
|
-
total: {
|
|
503
|
-
type: "integer",
|
|
504
|
-
description: "Total containers tracked by the pool."
|
|
505
|
-
},
|
|
506
|
-
config: {
|
|
507
|
-
type: "object",
|
|
508
|
-
properties: {
|
|
509
|
-
warmTarget: { type: "integer" },
|
|
510
|
-
refreshInterval: { type: "integer" }
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
maxInstances: {
|
|
514
|
-
type: ["integer", "null"],
|
|
515
|
-
description: "Inferred max_instances limit, or null if not yet known."
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
},
|
|
520
|
-
responses: {
|
|
521
|
-
Unauthorized: {
|
|
522
|
-
description: "Missing or invalid Bearer token.",
|
|
523
|
-
content: { "application/json": {
|
|
524
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
525
|
-
example: {
|
|
526
|
-
error: "Unauthorized",
|
|
527
|
-
code: "unauthorized"
|
|
528
|
-
}
|
|
529
|
-
} }
|
|
530
|
-
},
|
|
531
|
-
InvalidRequest: {
|
|
532
|
-
description: "Malformed request body or missing required fields.",
|
|
533
|
-
content: { "application/json": {
|
|
534
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
535
|
-
example: {
|
|
536
|
-
error: "argv must be a non-empty array",
|
|
537
|
-
code: "invalid_request"
|
|
538
|
-
}
|
|
539
|
-
} }
|
|
540
|
-
},
|
|
541
|
-
NotFound: {
|
|
542
|
-
description: "Requested resource was not found.",
|
|
543
|
-
content: { "application/json": {
|
|
544
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
545
|
-
example: {
|
|
546
|
-
error: "Process not found",
|
|
547
|
-
code: "not_found"
|
|
548
|
-
}
|
|
549
|
-
} }
|
|
550
|
-
},
|
|
551
|
-
BadGateway: {
|
|
552
|
-
description: "Sandbox operation failed.",
|
|
553
|
-
content: { "application/json": {
|
|
554
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
555
|
-
example: {
|
|
556
|
-
error: "process launch failed",
|
|
557
|
-
code: "process_error"
|
|
558
|
-
}
|
|
559
|
-
} }
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
},
|
|
563
|
-
security: [{ BearerAuth: [] }]
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
//#endregion
|
|
567
|
-
//#region src/bridge/openapi/files.ts
|
|
568
|
-
const FILESPaths = {
|
|
569
|
-
"/v1/sandbox/{id}/file/{path}": {
|
|
570
|
-
get: {
|
|
571
|
-
operationId: "readFile",
|
|
572
|
-
summary: "Read a file from the sandbox filesystem",
|
|
573
|
-
"x-codeSamples": [{
|
|
574
|
-
lang: "curl",
|
|
575
|
-
label: "cURL",
|
|
576
|
-
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"
|
|
577
|
-
}],
|
|
578
|
-
parameters: [{
|
|
579
|
-
name: "id",
|
|
580
|
-
in: "path",
|
|
581
|
-
required: true,
|
|
582
|
-
schema: { type: "string" },
|
|
583
|
-
description: "Sandbox instance name."
|
|
584
|
-
}, {
|
|
585
|
-
name: "path",
|
|
586
|
-
in: "path",
|
|
587
|
-
required: true,
|
|
588
|
-
schema: { type: "string" },
|
|
589
|
-
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
590
|
-
}],
|
|
591
|
-
responses: {
|
|
592
|
-
"200": {
|
|
593
|
-
description: "Raw file bytes.",
|
|
594
|
-
content: { "application/octet-stream": { schema: {
|
|
595
|
-
type: "string",
|
|
596
|
-
format: "binary"
|
|
597
|
-
} } }
|
|
598
|
-
},
|
|
599
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
600
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
601
|
-
"403": {
|
|
602
|
-
description: "Path resolves outside /workspace.",
|
|
603
|
-
content: { "application/json": {
|
|
604
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
605
|
-
example: {
|
|
606
|
-
error: "path must resolve to a location within /workspace",
|
|
607
|
-
code: "invalid_request"
|
|
608
|
-
}
|
|
609
|
-
} }
|
|
610
|
-
},
|
|
611
|
-
"404": {
|
|
612
|
-
description: "File not found in the sandbox.",
|
|
613
|
-
content: { "application/json": {
|
|
614
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
615
|
-
example: {
|
|
616
|
-
error: "File not found: /workspace/foo.txt",
|
|
617
|
-
code: "workspace_read_not_found"
|
|
618
|
-
}
|
|
619
|
-
} }
|
|
620
|
-
},
|
|
621
|
-
"502": {
|
|
622
|
-
description: "SDK read call failed.",
|
|
623
|
-
content: { "application/json": {
|
|
624
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
625
|
-
example: {
|
|
626
|
-
error: "read failed: connection reset",
|
|
627
|
-
code: "exec_transport_error"
|
|
628
|
-
}
|
|
629
|
-
} }
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
},
|
|
633
|
-
put: {
|
|
634
|
-
operationId: "writeFile",
|
|
635
|
-
summary: "Write a file into the sandbox filesystem",
|
|
636
|
-
"x-codeSamples": [{
|
|
637
|
-
lang: "curl",
|
|
638
|
-
label: "cURL",
|
|
639
|
-
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"
|
|
640
|
-
}],
|
|
641
|
-
parameters: [{
|
|
642
|
-
name: "id",
|
|
643
|
-
in: "path",
|
|
644
|
-
required: true,
|
|
645
|
-
schema: { type: "string" },
|
|
646
|
-
description: "Sandbox instance name."
|
|
647
|
-
}, {
|
|
648
|
-
name: "path",
|
|
649
|
-
in: "path",
|
|
650
|
-
required: true,
|
|
651
|
-
schema: { type: "string" },
|
|
652
|
-
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
653
|
-
}],
|
|
654
|
-
requestBody: {
|
|
655
|
-
required: true,
|
|
656
|
-
description: "Raw file content to write.",
|
|
657
|
-
content: { "application/octet-stream": { schema: {
|
|
658
|
-
type: "string",
|
|
659
|
-
format: "binary"
|
|
660
|
-
} } }
|
|
661
|
-
},
|
|
662
|
-
responses: {
|
|
663
|
-
"200": {
|
|
664
|
-
description: "File written successfully.",
|
|
665
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/WriteResponse" } } }
|
|
666
|
-
},
|
|
667
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
668
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
669
|
-
"403": {
|
|
670
|
-
description: "Path resolves outside /workspace.",
|
|
671
|
-
content: { "application/json": {
|
|
672
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
673
|
-
example: {
|
|
674
|
-
error: "path must resolve to a location within /workspace",
|
|
675
|
-
code: "invalid_request"
|
|
676
|
-
}
|
|
677
|
-
} }
|
|
678
|
-
},
|
|
679
|
-
"502": {
|
|
680
|
-
description: "SDK write call failed.",
|
|
681
|
-
content: { "application/json": {
|
|
682
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
683
|
-
example: {
|
|
684
|
-
error: "write failed: connection reset",
|
|
685
|
-
code: "workspace_archive_write_error"
|
|
686
|
-
}
|
|
687
|
-
} }
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
},
|
|
692
|
-
"/v1/sandbox/{id}/persist": { post: {
|
|
693
|
-
operationId: "persistWorkspace",
|
|
694
|
-
summary: "Serialize the sandbox workspace to a tar archive",
|
|
695
|
-
description: "Archives the /workspace directory inside the sandbox and streams the resulting tar back as raw bytes.",
|
|
696
|
-
"x-codeSamples": [{
|
|
697
|
-
lang: "curl",
|
|
698
|
-
label: "cURL",
|
|
699
|
-
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/persist \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o workspace.tar"
|
|
700
|
-
}],
|
|
701
|
-
parameters: [{
|
|
702
|
-
name: "id",
|
|
703
|
-
in: "path",
|
|
704
|
-
required: true,
|
|
705
|
-
schema: { type: "string" },
|
|
706
|
-
description: "Sandbox instance name."
|
|
707
|
-
}, {
|
|
708
|
-
name: "excludes",
|
|
709
|
-
in: "query",
|
|
710
|
-
required: false,
|
|
711
|
-
schema: { type: "string" },
|
|
712
|
-
description: "Comma-separated list of relative paths to exclude from the archive.",
|
|
713
|
-
example: "__pycache__,.venv"
|
|
714
|
-
}],
|
|
715
|
-
responses: {
|
|
716
|
-
"200": {
|
|
717
|
-
description: "Raw tar archive bytes.",
|
|
718
|
-
content: { "application/octet-stream": { schema: {
|
|
719
|
-
type: "string",
|
|
720
|
-
format: "binary"
|
|
721
|
-
} } }
|
|
722
|
-
},
|
|
723
|
-
"400": {
|
|
724
|
-
description: "Invalid exclude paths (e.g. path traversal).",
|
|
725
|
-
content: { "application/json": {
|
|
726
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
727
|
-
example: {
|
|
728
|
-
error: "exclude paths must not contain \"..\"",
|
|
729
|
-
code: "invalid_request"
|
|
730
|
-
}
|
|
731
|
-
} }
|
|
732
|
-
},
|
|
733
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
734
|
-
"502": {
|
|
735
|
-
description: "tar command failed inside the sandbox.",
|
|
736
|
-
content: { "application/json": {
|
|
737
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
738
|
-
example: {
|
|
739
|
-
error: "tar failed (exit 1): ...",
|
|
740
|
-
code: "workspace_archive_read_error"
|
|
741
|
-
}
|
|
742
|
-
} }
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
} },
|
|
746
|
-
"/v1/sandbox/{id}/hydrate": { post: {
|
|
747
|
-
operationId: "hydrateWorkspace",
|
|
748
|
-
summary: "Populate the sandbox workspace from a tar archive",
|
|
749
|
-
description: "Accepts a raw tar archive as the request body and extracts it into /workspace inside the sandbox.",
|
|
750
|
-
"x-codeSamples": [{
|
|
751
|
-
lang: "curl",
|
|
752
|
-
label: "cURL",
|
|
753
|
-
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"
|
|
754
|
-
}],
|
|
755
|
-
parameters: [{
|
|
756
|
-
name: "id",
|
|
757
|
-
in: "path",
|
|
758
|
-
required: true,
|
|
759
|
-
schema: { type: "string" },
|
|
760
|
-
description: "Sandbox instance name."
|
|
761
|
-
}],
|
|
762
|
-
requestBody: {
|
|
763
|
-
required: true,
|
|
764
|
-
description: "Raw tar archive bytes.",
|
|
765
|
-
content: { "application/octet-stream": { schema: {
|
|
766
|
-
type: "string",
|
|
767
|
-
format: "binary"
|
|
768
|
-
} } }
|
|
769
|
-
},
|
|
770
|
-
responses: {
|
|
771
|
-
"200": {
|
|
772
|
-
description: "Archive extracted successfully.",
|
|
773
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
774
|
-
},
|
|
775
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
776
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
777
|
-
"502": {
|
|
778
|
-
description: "tar extract failed inside the sandbox.",
|
|
779
|
-
content: { "application/json": {
|
|
780
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
781
|
-
example: {
|
|
782
|
-
error: "tar extract failed (exit 1): ...",
|
|
783
|
-
code: "workspace_archive_write_error"
|
|
784
|
-
}
|
|
785
|
-
} }
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
} },
|
|
789
|
-
"/v1/sandbox/{id}/mount": { post: {
|
|
790
|
-
operationId: "mountBucket",
|
|
791
|
-
summary: "Mount an S3-compatible bucket into the container",
|
|
792
|
-
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.",
|
|
793
|
-
"x-codeSamples": [{
|
|
794
|
-
lang: "curl",
|
|
795
|
-
label: "cURL",
|
|
796
|
-
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\"}}'"
|
|
797
|
-
}],
|
|
798
|
-
parameters: [{
|
|
799
|
-
name: "id",
|
|
800
|
-
in: "path",
|
|
801
|
-
required: true,
|
|
802
|
-
schema: { type: "string" },
|
|
803
|
-
description: "Sandbox instance name."
|
|
804
|
-
}],
|
|
805
|
-
requestBody: {
|
|
806
|
-
required: true,
|
|
807
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/MountBucketRequest" } } }
|
|
808
|
-
},
|
|
809
|
-
responses: {
|
|
810
|
-
"200": {
|
|
811
|
-
description: "Bucket mounted successfully.",
|
|
812
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
813
|
-
},
|
|
814
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
815
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
816
|
-
"502": {
|
|
817
|
-
description: "SDK mount call failed (invalid config, duplicate mount, or s3fs error).",
|
|
818
|
-
content: { "application/json": {
|
|
819
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
820
|
-
example: {
|
|
821
|
-
error: "mount failed: Mount path already in use",
|
|
822
|
-
code: "mount_error"
|
|
823
|
-
}
|
|
824
|
-
} }
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
} },
|
|
828
|
-
"/v1/sandbox/{id}/unmount": { post: {
|
|
829
|
-
operationId: "unmountBucket",
|
|
830
|
-
summary: "Unmount a previously mounted bucket",
|
|
831
|
-
description: "Unmounts a bucket filesystem that was previously mounted via `/v1/sandbox/{id}/mount`.",
|
|
832
|
-
"x-codeSamples": [{
|
|
833
|
-
lang: "curl",
|
|
834
|
-
label: "cURL",
|
|
835
|
-
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\"}'"
|
|
836
|
-
}],
|
|
837
|
-
parameters: [{
|
|
838
|
-
name: "id",
|
|
839
|
-
in: "path",
|
|
840
|
-
required: true,
|
|
841
|
-
schema: { type: "string" },
|
|
842
|
-
description: "Sandbox instance name."
|
|
843
|
-
}],
|
|
844
|
-
requestBody: {
|
|
845
|
-
required: true,
|
|
846
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/UnmountBucketRequest" } } }
|
|
847
|
-
},
|
|
848
|
-
responses: {
|
|
849
|
-
"200": {
|
|
850
|
-
description: "Bucket unmounted successfully.",
|
|
851
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
852
|
-
},
|
|
853
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
854
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
855
|
-
"502": {
|
|
856
|
-
description: "SDK unmount call failed (no active mount or unmount error).",
|
|
857
|
-
content: { "application/json": {
|
|
858
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
859
|
-
example: {
|
|
860
|
-
error: "unmount failed: No active mount found",
|
|
861
|
-
code: "unmount_error"
|
|
862
|
-
}
|
|
863
|
-
} }
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
} }
|
|
867
|
-
};
|
|
868
|
-
|
|
869
|
-
//#endregion
|
|
870
|
-
//#region src/bridge/openapi/lifecycle.ts
|
|
871
|
-
const LIFECYCLEPaths = {
|
|
872
|
-
"/v1/sandbox": { post: {
|
|
873
|
-
operationId: "createSandbox",
|
|
874
|
-
summary: "Create a new sandbox",
|
|
875
|
-
description: "Generates a new unique sandbox ID. Use this ID with all `/v1/sandbox/{id}/*` routes.",
|
|
876
|
-
"x-codeSamples": [{
|
|
877
|
-
lang: "curl",
|
|
878
|
-
label: "cURL",
|
|
879
|
-
source: "curl -X POST https://$HOST/v1/sandbox \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
880
|
-
}],
|
|
881
|
-
responses: {
|
|
882
|
-
"200": {
|
|
883
|
-
description: "New sandbox created.",
|
|
884
|
-
content: { "application/json": { schema: {
|
|
885
|
-
type: "object",
|
|
886
|
-
required: ["id"],
|
|
887
|
-
properties: { id: {
|
|
888
|
-
type: "string",
|
|
889
|
-
description: "Unique sandbox ID for use with `/v1/sandbox/{id}/*` routes.",
|
|
890
|
-
example: "mfrggzdfmy2tqnrzgezdgnbv"
|
|
891
|
-
} }
|
|
892
|
-
} } }
|
|
893
|
-
},
|
|
894
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
895
|
-
}
|
|
896
|
-
} },
|
|
897
|
-
"/v1/sandbox/{id}": { delete: {
|
|
898
|
-
operationId: "deleteSandbox",
|
|
899
|
-
summary: "Destroy a sandbox instance (best-effort)",
|
|
900
|
-
description: "Calls destroy() on the sandbox Durable Object to release container resources. Best-effort: unknown sandbox IDs return 204 without allocating a container.",
|
|
901
|
-
"x-codeSamples": [{
|
|
902
|
-
lang: "curl",
|
|
903
|
-
label: "cURL",
|
|
904
|
-
source: "curl -X DELETE https://$HOST/v1/sandbox/my-sandbox \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
905
|
-
}],
|
|
906
|
-
parameters: [{
|
|
907
|
-
name: "id",
|
|
908
|
-
in: "path",
|
|
909
|
-
required: true,
|
|
910
|
-
schema: { type: "string" },
|
|
911
|
-
description: "Sandbox instance name."
|
|
912
|
-
}],
|
|
913
|
-
responses: {
|
|
914
|
-
"204": { description: "Sandbox destroyed (best-effort). Container resources are released." },
|
|
915
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
916
|
-
}
|
|
917
|
-
} },
|
|
918
|
-
"/health": { get: {
|
|
919
|
-
operationId: "healthCheck",
|
|
920
|
-
summary: "Worker health check",
|
|
921
|
-
description: "Simple liveness probe. Not protected by authentication.",
|
|
922
|
-
"x-codeSamples": [{
|
|
923
|
-
lang: "curl",
|
|
924
|
-
label: "cURL",
|
|
925
|
-
source: "curl https://$HOST/health"
|
|
926
|
-
}],
|
|
927
|
-
security: [],
|
|
928
|
-
responses: { "200": {
|
|
929
|
-
description: "Worker is up.",
|
|
930
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
931
|
-
} }
|
|
932
|
-
} },
|
|
933
|
-
"/v1/openapi.json": { get: {
|
|
934
|
-
operationId: "getOpenApiSchema",
|
|
935
|
-
summary: "OpenAPI schema",
|
|
936
|
-
description: "Returns this OpenAPI 3.1 schema document.",
|
|
937
|
-
"x-codeSamples": [{
|
|
938
|
-
lang: "curl",
|
|
939
|
-
label: "cURL",
|
|
940
|
-
source: "curl https://$HOST/v1/openapi.json \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
941
|
-
}],
|
|
942
|
-
responses: {
|
|
943
|
-
"200": {
|
|
944
|
-
description: "OpenAPI schema document.",
|
|
945
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
946
|
-
},
|
|
947
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
948
|
-
}
|
|
949
|
-
} },
|
|
950
|
-
"/v1/pool/stats": { get: {
|
|
951
|
-
operationId: "getPoolStats",
|
|
952
|
-
summary: "Pool statistics",
|
|
953
|
-
description: "Returns current warm pool statistics including warm/assigned counts and configuration.",
|
|
954
|
-
"x-codeSamples": [{
|
|
955
|
-
lang: "curl",
|
|
956
|
-
label: "cURL",
|
|
957
|
-
source: "curl https://$HOST/v1/pool/stats \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
958
|
-
}],
|
|
959
|
-
responses: {
|
|
960
|
-
"200": {
|
|
961
|
-
description: "Pool statistics.",
|
|
962
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/PoolStats" } } }
|
|
963
|
-
},
|
|
964
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
965
|
-
}
|
|
966
|
-
} },
|
|
967
|
-
"/v1/pool/shutdown-prewarmed": { post: {
|
|
968
|
-
operationId: "shutdownPrewarmed",
|
|
969
|
-
summary: "Shutdown pre-warmed containers",
|
|
970
|
-
description: "Destroys all idle (unassigned) warm containers. Does not affect containers assigned to sandbox instances.",
|
|
971
|
-
"x-codeSamples": [{
|
|
972
|
-
lang: "curl",
|
|
973
|
-
label: "cURL",
|
|
974
|
-
source: "curl -X POST https://$HOST/v1/pool/shutdown-prewarmed \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
975
|
-
}],
|
|
976
|
-
responses: {
|
|
977
|
-
"200": {
|
|
978
|
-
description: "All pre-warmed containers destroyed.",
|
|
979
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
980
|
-
},
|
|
981
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
982
|
-
}
|
|
983
|
-
} },
|
|
984
|
-
"/v1/pool/prime": { post: {
|
|
985
|
-
operationId: "primePool",
|
|
986
|
-
summary: "Prime the warm pool",
|
|
987
|
-
description: "Pushes the current pool configuration and starts the alarm loop. Called automatically by the cron trigger; can also be called manually after deploy.",
|
|
988
|
-
"x-codeSamples": [{
|
|
989
|
-
lang: "curl",
|
|
990
|
-
label: "cURL",
|
|
991
|
-
source: "curl -X POST https://$HOST/v1/pool/prime \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
992
|
-
}],
|
|
993
|
-
responses: {
|
|
994
|
-
"200": {
|
|
995
|
-
description: "Pool primed successfully.",
|
|
996
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
997
|
-
},
|
|
998
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
999
|
-
}
|
|
1000
|
-
} }
|
|
1001
|
-
};
|
|
1002
|
-
|
|
1003
|
-
//#endregion
|
|
1004
|
-
//#region src/bridge/openapi/processes.ts
|
|
1005
|
-
const sandboxIdParameter$1 = {
|
|
1006
|
-
name: "id",
|
|
1007
|
-
in: "path",
|
|
1008
|
-
required: true,
|
|
1009
|
-
schema: { type: "string" },
|
|
1010
|
-
description: "Sandbox instance name."
|
|
1011
|
-
};
|
|
1012
|
-
const processIdParameter = {
|
|
1013
|
-
name: "processId",
|
|
1014
|
-
in: "path",
|
|
1015
|
-
required: true,
|
|
1016
|
-
schema: { type: "string" }
|
|
1017
|
-
};
|
|
1018
|
-
const processStatusResponse = {
|
|
1019
|
-
description: "Process status.",
|
|
1020
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ProcessStatus" } } }
|
|
1021
|
-
};
|
|
1022
|
-
const PROCESSESPaths = {
|
|
1023
|
-
"/v1/sandbox/{id}/processes": {
|
|
1024
|
-
post: {
|
|
1025
|
-
operationId: "createProcess",
|
|
1026
|
-
summary: "Launch a sandbox process",
|
|
1027
|
-
parameters: [sandboxIdParameter$1],
|
|
1028
|
-
requestBody: {
|
|
1029
|
-
required: true,
|
|
1030
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ProcessCreateRequest" } } }
|
|
1031
|
-
},
|
|
1032
|
-
responses: {
|
|
1033
|
-
"200": processStatusResponse,
|
|
1034
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1035
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1036
|
-
"403": { $ref: "#/components/responses/InvalidRequest" },
|
|
1037
|
-
"502": { $ref: "#/components/responses/BadGateway" }
|
|
1038
|
-
}
|
|
1039
|
-
},
|
|
1040
|
-
get: {
|
|
1041
|
-
operationId: "listProcesses",
|
|
1042
|
-
summary: "List sandbox processes",
|
|
1043
|
-
parameters: [sandboxIdParameter$1],
|
|
1044
|
-
responses: {
|
|
1045
|
-
"200": {
|
|
1046
|
-
description: "Process statuses.",
|
|
1047
|
-
content: { "application/json": { schema: {
|
|
1048
|
-
type: "array",
|
|
1049
|
-
items: { $ref: "#/components/schemas/ProcessStatus" }
|
|
1050
|
-
} } }
|
|
1051
|
-
},
|
|
1052
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
},
|
|
1056
|
-
"/v1/sandbox/{id}/processes/{processId}": { get: {
|
|
1057
|
-
operationId: "getProcess",
|
|
1058
|
-
summary: "Get a sandbox process",
|
|
1059
|
-
parameters: [sandboxIdParameter$1, processIdParameter],
|
|
1060
|
-
responses: {
|
|
1061
|
-
"200": processStatusResponse,
|
|
1062
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1063
|
-
"404": { $ref: "#/components/responses/NotFound" }
|
|
1064
|
-
}
|
|
1065
|
-
} },
|
|
1066
|
-
"/v1/sandbox/{id}/processes/{processId}/logs": { get: {
|
|
1067
|
-
operationId: "readProcessLogs",
|
|
1068
|
-
summary: "Stream sandbox process logs",
|
|
1069
|
-
description: "Returns Server-Sent Events. stdout/stderr event data is base64-encoded at the bridge boundary only.",
|
|
1070
|
-
parameters: [
|
|
1071
|
-
sandboxIdParameter$1,
|
|
1072
|
-
processIdParameter,
|
|
1073
|
-
{
|
|
1074
|
-
name: "since",
|
|
1075
|
-
in: "query",
|
|
1076
|
-
schema: { type: "string" }
|
|
1077
|
-
},
|
|
1078
|
-
{
|
|
1079
|
-
name: "replay",
|
|
1080
|
-
in: "query",
|
|
1081
|
-
schema: { type: "boolean" }
|
|
1082
|
-
},
|
|
1083
|
-
{
|
|
1084
|
-
name: "follow",
|
|
1085
|
-
in: "query",
|
|
1086
|
-
schema: { type: "boolean" }
|
|
1087
|
-
}
|
|
1088
|
-
],
|
|
1089
|
-
responses: {
|
|
1090
|
-
"200": {
|
|
1091
|
-
description: "SSE stream of process log events.",
|
|
1092
|
-
content: { "text/event-stream": { schema: { type: "string" } } }
|
|
1093
|
-
},
|
|
1094
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1095
|
-
"404": { $ref: "#/components/responses/NotFound" }
|
|
1096
|
-
}
|
|
1097
|
-
} },
|
|
1098
|
-
"/v1/sandbox/{id}/processes/{processId}/kill": { post: {
|
|
1099
|
-
operationId: "signalSandboxProcess",
|
|
1100
|
-
summary: "Signal a sandbox process",
|
|
1101
|
-
parameters: [sandboxIdParameter$1, processIdParameter],
|
|
1102
|
-
requestBody: {
|
|
1103
|
-
required: false,
|
|
1104
|
-
content: { "application/json": { schema: {
|
|
1105
|
-
type: "object",
|
|
1106
|
-
properties: { signal: {
|
|
1107
|
-
type: "integer",
|
|
1108
|
-
minimum: 1,
|
|
1109
|
-
maximum: 64,
|
|
1110
|
-
default: 15
|
|
1111
|
-
} }
|
|
1112
|
-
} } }
|
|
1113
|
-
},
|
|
1114
|
-
responses: {
|
|
1115
|
-
"204": { description: "Signal sent." },
|
|
1116
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1117
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1118
|
-
"404": { $ref: "#/components/responses/NotFound" }
|
|
1119
|
-
}
|
|
1120
|
-
} },
|
|
1121
|
-
"/v1/sandbox/{id}/tunnel/{port}": {
|
|
1122
|
-
post: {
|
|
1123
|
-
operationId: "createTunnel",
|
|
1124
|
-
summary: "Create or reuse a tunnel for a sandbox port",
|
|
1125
|
-
parameters: [sandboxIdParameter$1, {
|
|
1126
|
-
name: "port",
|
|
1127
|
-
in: "path",
|
|
1128
|
-
required: true,
|
|
1129
|
-
schema: {
|
|
1130
|
-
type: "integer",
|
|
1131
|
-
minimum: 1024,
|
|
1132
|
-
maximum: 65535
|
|
1133
|
-
}
|
|
1134
|
-
}],
|
|
1135
|
-
responses: {
|
|
1136
|
-
"200": { description: "Tunnel created or reused." },
|
|
1137
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1138
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1139
|
-
"502": { $ref: "#/components/responses/BadGateway" }
|
|
1140
|
-
}
|
|
1141
|
-
},
|
|
1142
|
-
delete: {
|
|
1143
|
-
operationId: "deleteTunnel",
|
|
1144
|
-
summary: "Delete the tunnel for a sandbox port",
|
|
1145
|
-
parameters: [sandboxIdParameter$1, {
|
|
1146
|
-
name: "port",
|
|
1147
|
-
in: "path",
|
|
1148
|
-
required: true,
|
|
1149
|
-
schema: {
|
|
1150
|
-
type: "integer",
|
|
1151
|
-
minimum: 1024,
|
|
1152
|
-
maximum: 65535
|
|
1153
|
-
}
|
|
1154
|
-
}],
|
|
1155
|
-
responses: {
|
|
1156
|
-
"204": { description: "Tunnel deleted or already absent." },
|
|
1157
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1158
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1159
|
-
"502": { $ref: "#/components/responses/BadGateway" }
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
},
|
|
1163
|
-
"/v1/sandbox/{id}/running": { get: {
|
|
1164
|
-
operationId: "isSandboxRunning",
|
|
1165
|
-
summary: "Check whether the sandbox container is alive",
|
|
1166
|
-
parameters: [sandboxIdParameter$1],
|
|
1167
|
-
responses: {
|
|
1168
|
-
"200": {
|
|
1169
|
-
description: "Liveness status.",
|
|
1170
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/RunningResponse" } } }
|
|
1171
|
-
},
|
|
1172
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1173
|
-
}
|
|
1174
|
-
} }
|
|
1175
|
-
};
|
|
1176
|
-
|
|
1177
|
-
//#endregion
|
|
1178
|
-
//#region src/bridge/openapi/terminals.ts
|
|
1179
|
-
const terminalSnapshotSchema = {
|
|
1180
|
-
type: "object",
|
|
1181
|
-
required: [
|
|
1182
|
-
"id",
|
|
1183
|
-
"command",
|
|
1184
|
-
"status"
|
|
1185
|
-
],
|
|
1186
|
-
properties: {
|
|
1187
|
-
id: { type: "string" },
|
|
1188
|
-
pid: { type: "integer" },
|
|
1189
|
-
command: {
|
|
1190
|
-
type: "array",
|
|
1191
|
-
items: { type: "string" }
|
|
1192
|
-
},
|
|
1193
|
-
cwd: { type: "string" },
|
|
1194
|
-
status: {
|
|
1195
|
-
type: "string",
|
|
1196
|
-
enum: ["running", "exited"]
|
|
1197
|
-
},
|
|
1198
|
-
exit: {
|
|
1199
|
-
type: "object",
|
|
1200
|
-
properties: {
|
|
1201
|
-
code: {
|
|
1202
|
-
type: "integer",
|
|
1203
|
-
nullable: true
|
|
1204
|
-
},
|
|
1205
|
-
signal: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
|
1206
|
-
timedOut: { type: "boolean" }
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
};
|
|
1211
|
-
const terminalIdParameter = {
|
|
1212
|
-
name: "terminalId",
|
|
1213
|
-
in: "path",
|
|
1214
|
-
required: true,
|
|
1215
|
-
schema: {
|
|
1216
|
-
type: "string",
|
|
1217
|
-
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
1218
|
-
}
|
|
1219
|
-
};
|
|
1220
|
-
const sandboxIdParameter = {
|
|
1221
|
-
name: "id",
|
|
1222
|
-
in: "path",
|
|
1223
|
-
required: true,
|
|
1224
|
-
schema: { type: "string" },
|
|
1225
|
-
description: "Sandbox instance name."
|
|
1226
|
-
};
|
|
1227
|
-
const terminalErrorResponses = {
|
|
1228
|
-
"400": {
|
|
1229
|
-
description: "Invalid request.",
|
|
1230
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
1231
|
-
},
|
|
1232
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1233
|
-
"502": {
|
|
1234
|
-
description: "Terminal operation failed.",
|
|
1235
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
1236
|
-
}
|
|
1237
|
-
};
|
|
1238
|
-
const TERMINALSPaths = {
|
|
1239
|
-
"/v1/sandbox/{id}/terminals": {
|
|
1240
|
-
post: {
|
|
1241
|
-
operationId: "createTerminal",
|
|
1242
|
-
summary: "Create a terminal",
|
|
1243
|
-
requestBody: {
|
|
1244
|
-
required: true,
|
|
1245
|
-
content: { "application/json": { schema: {
|
|
1246
|
-
type: "object",
|
|
1247
|
-
required: ["argv"],
|
|
1248
|
-
properties: {
|
|
1249
|
-
argv: {
|
|
1250
|
-
type: "array",
|
|
1251
|
-
items: { type: "string" },
|
|
1252
|
-
minItems: 1
|
|
1253
|
-
},
|
|
1254
|
-
cwd: { type: "string" },
|
|
1255
|
-
env: {
|
|
1256
|
-
type: "object",
|
|
1257
|
-
additionalProperties: { type: "string" }
|
|
1258
|
-
},
|
|
1259
|
-
cols: { type: "integer" },
|
|
1260
|
-
rows: { type: "integer" }
|
|
1261
|
-
}
|
|
1262
|
-
} } }
|
|
1263
|
-
},
|
|
1264
|
-
parameters: [sandboxIdParameter],
|
|
1265
|
-
responses: {
|
|
1266
|
-
"200": {
|
|
1267
|
-
description: "Terminal created with a generated ID.",
|
|
1268
|
-
content: { "application/json": { schema: terminalSnapshotSchema } }
|
|
1269
|
-
},
|
|
1270
|
-
...terminalErrorResponses
|
|
1271
|
-
}
|
|
1272
|
-
},
|
|
1273
|
-
get: {
|
|
1274
|
-
operationId: "listTerminals",
|
|
1275
|
-
summary: "List terminals",
|
|
1276
|
-
parameters: [sandboxIdParameter],
|
|
1277
|
-
responses: {
|
|
1278
|
-
"200": {
|
|
1279
|
-
description: "Terminal snapshots.",
|
|
1280
|
-
content: { "application/json": { schema: {
|
|
1281
|
-
type: "array",
|
|
1282
|
-
items: terminalSnapshotSchema
|
|
1283
|
-
} } }
|
|
1284
|
-
},
|
|
1285
|
-
...terminalErrorResponses
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
},
|
|
1289
|
-
"/v1/sandbox/{id}/terminals/{terminalId}": { get: {
|
|
1290
|
-
operationId: "getTerminal",
|
|
1291
|
-
summary: "Get a terminal snapshot",
|
|
1292
|
-
parameters: [sandboxIdParameter, terminalIdParameter],
|
|
1293
|
-
responses: {
|
|
1294
|
-
"200": {
|
|
1295
|
-
description: "Terminal snapshot.",
|
|
1296
|
-
content: { "application/json": { schema: terminalSnapshotSchema } }
|
|
1297
|
-
},
|
|
1298
|
-
"404": { description: "Terminal not found." },
|
|
1299
|
-
...terminalErrorResponses
|
|
1300
|
-
}
|
|
1301
|
-
} },
|
|
1302
|
-
"/v1/sandbox/{id}/terminals/{terminalId}/connect": { get: {
|
|
1303
|
-
operationId: "connectTerminal",
|
|
1304
|
-
summary: "Connect to a terminal WebSocket",
|
|
1305
|
-
description: "WebSocket binary client frames are terminal input bytes. JSON client controls are resize, interrupt, and terminate. Server output is a JSON chunk control immediately followed by one binary frame. Cursor advances after the binary frame is consumed.",
|
|
1306
|
-
parameters: [
|
|
1307
|
-
sandboxIdParameter,
|
|
1308
|
-
terminalIdParameter,
|
|
1309
|
-
{
|
|
1310
|
-
name: "cursor",
|
|
1311
|
-
in: "query",
|
|
1312
|
-
required: false,
|
|
1313
|
-
schema: { type: "string" }
|
|
1314
|
-
},
|
|
1315
|
-
{
|
|
1316
|
-
name: "cols",
|
|
1317
|
-
in: "query",
|
|
1318
|
-
required: false,
|
|
1319
|
-
schema: { type: "integer" }
|
|
1320
|
-
},
|
|
1321
|
-
{
|
|
1322
|
-
name: "rows",
|
|
1323
|
-
in: "query",
|
|
1324
|
-
required: false,
|
|
1325
|
-
schema: { type: "integer" }
|
|
1326
|
-
}
|
|
1327
|
-
],
|
|
1328
|
-
responses: {
|
|
1329
|
-
"101": { description: "WebSocket upgrade successful." },
|
|
1330
|
-
"404": { description: "Terminal not found." },
|
|
1331
|
-
...terminalErrorResponses
|
|
1332
|
-
}
|
|
1333
|
-
} },
|
|
1334
|
-
"/v1/sandbox/{id}/terminals/{terminalId}/interrupt": { post: {
|
|
1335
|
-
operationId: "interruptTerminal",
|
|
1336
|
-
summary: "Interrupt a terminal",
|
|
1337
|
-
parameters: [sandboxIdParameter, terminalIdParameter],
|
|
1338
|
-
responses: {
|
|
1339
|
-
"204": { description: "Interrupt signal sent." },
|
|
1340
|
-
"404": { description: "Terminal not found." },
|
|
1341
|
-
...terminalErrorResponses
|
|
1342
|
-
}
|
|
1343
|
-
} },
|
|
1344
|
-
"/v1/sandbox/{id}/terminals/{terminalId}/terminate": { post: {
|
|
1345
|
-
operationId: "terminateTerminal",
|
|
1346
|
-
summary: "Terminate a terminal",
|
|
1347
|
-
parameters: [sandboxIdParameter, terminalIdParameter],
|
|
1348
|
-
responses: {
|
|
1349
|
-
"204": { description: "Terminate signal sent." },
|
|
1350
|
-
"404": { description: "Terminal not found." },
|
|
1351
|
-
...terminalErrorResponses
|
|
1352
|
-
}
|
|
1353
|
-
} }
|
|
1354
|
-
};
|
|
1355
|
-
|
|
1356
|
-
//#endregion
|
|
1357
|
-
//#region src/bridge/openapi.ts
|
|
1358
|
-
const OPENAPI_SCHEMA = {
|
|
1359
|
-
...OPENAPI_BASE,
|
|
1360
|
-
paths: {
|
|
1361
|
-
...LIFECYCLEPaths,
|
|
1362
|
-
...PROCESSESPaths,
|
|
1363
|
-
...FILESPaths,
|
|
1364
|
-
...TERMINALSPaths
|
|
1365
|
-
}
|
|
1366
|
-
};
|
|
1367
|
-
|
|
1368
|
-
//#endregion
|
|
1369
|
-
//#region src/bridge/openapi-html.ts
|
|
1370
|
-
/**
|
|
1371
|
-
* Renders an OpenAPI 3.x schema object as a self-contained HTML page.
|
|
1372
|
-
*
|
|
1373
|
-
* No external dependencies — pure HTML/CSS/JS generated server-side.
|
|
1374
|
-
* The schema is embedded as JSON and rendered client-side via a small
|
|
1375
|
-
* inline script.
|
|
1376
|
-
*/
|
|
1377
|
-
function renderOpenApiHtml(schema) {
|
|
1378
|
-
const json = JSON.stringify(schema);
|
|
1379
|
-
return `<!doctype html>
|
|
1380
|
-
<html lang="en">
|
|
1381
|
-
<head>
|
|
1382
|
-
<meta charset="utf-8" />
|
|
1383
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1384
|
-
<title>${esc(schema.info?.title ?? "API Reference")}</title>
|
|
1385
|
-
<style>
|
|
1386
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1387
|
-
|
|
1388
|
-
:root {
|
|
1389
|
-
--bg: #0f1117;
|
|
1390
|
-
--surface: #1a1d27;
|
|
1391
|
-
--border: #2a2d3a;
|
|
1392
|
-
--text: #e2e8f0;
|
|
1393
|
-
--muted: #8892a4;
|
|
1394
|
-
--accent: #6366f1;
|
|
1395
|
-
--get: #22c55e;
|
|
1396
|
-
--post: #3b82f6;
|
|
1397
|
-
--put: #f59e0b;
|
|
1398
|
-
--delete: #ef4444;
|
|
1399
|
-
--patch: #a855f7;
|
|
1400
|
-
--radius: 6px;
|
|
1401
|
-
--font-mono: ui-monospace, "Cascadia Code", "Fira Code", monospace;
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
body {
|
|
1405
|
-
background: var(--bg);
|
|
1406
|
-
color: var(--text);
|
|
1407
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1408
|
-
font-size: 14px;
|
|
1409
|
-
line-height: 1.6;
|
|
1410
|
-
display: flex;
|
|
1411
|
-
min-height: 100vh;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
/* ── Sidebar ── */
|
|
1415
|
-
#sidebar {
|
|
1416
|
-
width: 260px;
|
|
1417
|
-
min-width: 260px;
|
|
1418
|
-
background: var(--surface);
|
|
1419
|
-
border-right: 1px solid var(--border);
|
|
1420
|
-
padding: 24px 0;
|
|
1421
|
-
position: sticky;
|
|
1422
|
-
top: 0;
|
|
1423
|
-
height: 100vh;
|
|
1424
|
-
overflow-y: auto;
|
|
1425
|
-
}
|
|
1426
|
-
#sidebar h1 {
|
|
1427
|
-
font-size: 13px;
|
|
1428
|
-
font-weight: 600;
|
|
1429
|
-
color: var(--text);
|
|
1430
|
-
padding: 0 20px 16px;
|
|
1431
|
-
border-bottom: 1px solid var(--border);
|
|
1432
|
-
margin-bottom: 12px;
|
|
1433
|
-
white-space: nowrap;
|
|
1434
|
-
overflow: hidden;
|
|
1435
|
-
text-overflow: ellipsis;
|
|
1436
|
-
}
|
|
1437
|
-
#sidebar .version {
|
|
1438
|
-
font-size: 11px;
|
|
1439
|
-
color: var(--muted);
|
|
1440
|
-
font-weight: 400;
|
|
1441
|
-
}
|
|
1442
|
-
.nav-item {
|
|
1443
|
-
display: flex;
|
|
1444
|
-
align-items: center;
|
|
1445
|
-
gap: 10px;
|
|
1446
|
-
padding: 6px 20px;
|
|
1447
|
-
cursor: pointer;
|
|
1448
|
-
border-left: 2px solid transparent;
|
|
1449
|
-
transition: background 0.15s, border-color 0.15s;
|
|
1450
|
-
text-decoration: none;
|
|
1451
|
-
color: var(--muted);
|
|
1452
|
-
font-size: 13px;
|
|
1453
|
-
}
|
|
1454
|
-
.nav-item:hover { background: rgba(255,255,255,0.04); color: var(--text); }
|
|
1455
|
-
.nav-item.active { border-left-color: var(--accent); color: var(--text); background: rgba(99,102,241,0.08); }
|
|
1456
|
-
|
|
1457
|
-
/* ── Main ── */
|
|
1458
|
-
#main {
|
|
1459
|
-
flex: 1;
|
|
1460
|
-
padding: 40px 48px;
|
|
1461
|
-
max-width: 900px;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/* ── Info block ── */
|
|
1465
|
-
#info { margin-bottom: 48px; }
|
|
1466
|
-
#info h2 { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
|
|
1467
|
-
#info p { color: var(--muted); max-width: 680px; }
|
|
1468
|
-
#info .meta { display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; }
|
|
1469
|
-
#info .badge {
|
|
1470
|
-
font-size: 11px;
|
|
1471
|
-
padding: 2px 8px;
|
|
1472
|
-
border-radius: 99px;
|
|
1473
|
-
border: 1px solid var(--border);
|
|
1474
|
-
color: var(--muted);
|
|
1475
|
-
font-family: var(--font-mono);
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
/* ── Endpoint card ── */
|
|
1479
|
-
.endpoint {
|
|
1480
|
-
border: 1px solid var(--border);
|
|
1481
|
-
border-radius: var(--radius);
|
|
1482
|
-
margin-bottom: 16px;
|
|
1483
|
-
overflow: hidden;
|
|
1484
|
-
}
|
|
1485
|
-
.endpoint-header {
|
|
1486
|
-
display: flex;
|
|
1487
|
-
align-items: center;
|
|
1488
|
-
gap: 14px;
|
|
1489
|
-
padding: 14px 18px;
|
|
1490
|
-
cursor: pointer;
|
|
1491
|
-
background: var(--surface);
|
|
1492
|
-
user-select: none;
|
|
1493
|
-
transition: background 0.15s;
|
|
1494
|
-
}
|
|
1495
|
-
.endpoint-header:hover { background: #1e2130; }
|
|
1496
|
-
.endpoint-header.open { background: #1e2130; border-bottom: 1px solid var(--border); }
|
|
1497
|
-
|
|
1498
|
-
.method {
|
|
1499
|
-
font-family: var(--font-mono);
|
|
1500
|
-
font-size: 11px;
|
|
1501
|
-
font-weight: 700;
|
|
1502
|
-
padding: 3px 8px;
|
|
1503
|
-
border-radius: 4px;
|
|
1504
|
-
min-width: 58px;
|
|
1505
|
-
text-align: center;
|
|
1506
|
-
text-transform: uppercase;
|
|
1507
|
-
letter-spacing: 0.05em;
|
|
1508
|
-
}
|
|
1509
|
-
.method-get { background: rgba(34,197,94,.15); color: var(--get); }
|
|
1510
|
-
.method-post { background: rgba(59,130,246,.15); color: var(--post); }
|
|
1511
|
-
.method-put { background: rgba(245,158,11,.15); color: var(--put); }
|
|
1512
|
-
.method-delete { background: rgba(239,68,68,.15); color: var(--delete); }
|
|
1513
|
-
.method-patch { background: rgba(168,85,247,.15); color: var(--patch); }
|
|
1514
|
-
|
|
1515
|
-
.endpoint-path {
|
|
1516
|
-
font-family: var(--font-mono);
|
|
1517
|
-
font-size: 13px;
|
|
1518
|
-
color: var(--text);
|
|
1519
|
-
flex: 1;
|
|
1520
|
-
}
|
|
1521
|
-
.endpoint-summary { font-size: 13px; color: var(--muted); }
|
|
1522
|
-
.chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; }
|
|
1523
|
-
.endpoint-header.open .chevron { transform: rotate(90deg); }
|
|
1524
|
-
|
|
1525
|
-
/* ── Endpoint body ── */
|
|
1526
|
-
.endpoint-body { padding: 20px 18px; display: none; }
|
|
1527
|
-
.endpoint-body.open { display: block; }
|
|
1528
|
-
.endpoint-desc { color: var(--muted); margin-bottom: 16px; font-size: 13px; }
|
|
1529
|
-
|
|
1530
|
-
/* ── Section labels ── */
|
|
1531
|
-
.section-label {
|
|
1532
|
-
font-size: 11px;
|
|
1533
|
-
font-weight: 600;
|
|
1534
|
-
text-transform: uppercase;
|
|
1535
|
-
letter-spacing: 0.08em;
|
|
1536
|
-
color: var(--muted);
|
|
1537
|
-
margin: 20px 0 10px;
|
|
1538
|
-
}
|
|
1539
|
-
.section-label:first-child { margin-top: 0; }
|
|
1540
|
-
|
|
1541
|
-
/* ── Params table ── */
|
|
1542
|
-
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
1543
|
-
th {
|
|
1544
|
-
text-align: left;
|
|
1545
|
-
padding: 6px 10px;
|
|
1546
|
-
font-weight: 600;
|
|
1547
|
-
font-size: 11px;
|
|
1548
|
-
text-transform: uppercase;
|
|
1549
|
-
letter-spacing: 0.06em;
|
|
1550
|
-
color: var(--muted);
|
|
1551
|
-
border-bottom: 1px solid var(--border);
|
|
1552
|
-
}
|
|
1553
|
-
td {
|
|
1554
|
-
padding: 8px 10px;
|
|
1555
|
-
border-bottom: 1px solid var(--border);
|
|
1556
|
-
vertical-align: top;
|
|
1557
|
-
}
|
|
1558
|
-
tr:last-child td { border-bottom: none; }
|
|
1559
|
-
.param-name { font-family: var(--font-mono); color: var(--text); }
|
|
1560
|
-
.param-in { font-size: 11px; color: var(--muted); }
|
|
1561
|
-
.param-type { font-family: var(--font-mono); font-size: 11px; color: var(--accent); }
|
|
1562
|
-
.required { color: var(--delete); font-size: 10px; font-weight: 700; margin-left: 4px; }
|
|
1563
|
-
|
|
1564
|
-
/* ── Response rows ── */
|
|
1565
|
-
.response-group { margin-bottom: 4px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
1566
|
-
.response-header {
|
|
1567
|
-
display: flex;
|
|
1568
|
-
align-items: center;
|
|
1569
|
-
gap: 14px;
|
|
1570
|
-
padding: 10px 14px;
|
|
1571
|
-
cursor: pointer;
|
|
1572
|
-
background: var(--surface);
|
|
1573
|
-
user-select: none;
|
|
1574
|
-
transition: background 0.15s;
|
|
1575
|
-
font-size: 13px;
|
|
1576
|
-
}
|
|
1577
|
-
.response-header:hover { background: #1e2130; }
|
|
1578
|
-
.response-header.open { border-bottom: 1px solid var(--border); }
|
|
1579
|
-
.response-detail { padding: 12px 14px; display: none; font-size: 13px; }
|
|
1580
|
-
.response-detail.open { display: block; }
|
|
1581
|
-
.response-detail .section-label { margin-top: 14px; }
|
|
1582
|
-
.response-detail .section-label:first-child { margin-top: 0; }
|
|
1583
|
-
.status-code {
|
|
1584
|
-
font-family: var(--font-mono);
|
|
1585
|
-
font-size: 12px;
|
|
1586
|
-
font-weight: 700;
|
|
1587
|
-
min-width: 40px;
|
|
1588
|
-
}
|
|
1589
|
-
.s2xx { color: var(--get); }
|
|
1590
|
-
.s4xx { color: var(--put); }
|
|
1591
|
-
.s5xx { color: var(--delete); }
|
|
1592
|
-
.response-desc { color: var(--muted); flex: 1; }
|
|
1593
|
-
.response-content-type { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
|
|
1594
|
-
.resp-chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; margin-left: auto; }
|
|
1595
|
-
.response-header.open .resp-chevron { transform: rotate(90deg); }
|
|
1596
|
-
|
|
1597
|
-
/* ── Code block ── */
|
|
1598
|
-
pre {
|
|
1599
|
-
background: #0a0c12;
|
|
1600
|
-
border: 1px solid var(--border);
|
|
1601
|
-
border-radius: var(--radius);
|
|
1602
|
-
padding: 14px 16px;
|
|
1603
|
-
font-family: var(--font-mono);
|
|
1604
|
-
font-size: 12px;
|
|
1605
|
-
overflow-x: auto;
|
|
1606
|
-
color: var(--text);
|
|
1607
|
-
margin-top: 8px;
|
|
1608
|
-
}
|
|
1609
|
-
pre.example-block { margin-top: 4px; }
|
|
1610
|
-
|
|
1611
|
-
/* ── Code samples ── */
|
|
1612
|
-
.code-samples { margin-top: 4px; }
|
|
1613
|
-
.code-sample-label {
|
|
1614
|
-
font-size: 11px;
|
|
1615
|
-
font-weight: 600;
|
|
1616
|
-
color: var(--muted);
|
|
1617
|
-
font-family: var(--font-mono);
|
|
1618
|
-
margin-bottom: 2px;
|
|
1619
|
-
}
|
|
1620
|
-
</style>
|
|
1621
|
-
</head>
|
|
1622
|
-
<body>
|
|
1623
|
-
<nav id="sidebar"><h1 id="nav-title"></h1></nav>
|
|
1624
|
-
<main id="main"><div id="info"></div><div id="endpoints"></div></main>
|
|
1625
|
-
|
|
1626
|
-
<script>
|
|
1627
|
-
const schema = ${json};
|
|
1628
|
-
|
|
1629
|
-
// ── helpers ──────────────────────────────────────────────────────
|
|
1630
|
-
function esc(s) {
|
|
1631
|
-
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
1632
|
-
}
|
|
1633
|
-
function el(tag, attrs, ...children) {
|
|
1634
|
-
const e = document.createElement(tag);
|
|
1635
|
-
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
1636
|
-
if (k === 'class') e.className = v;
|
|
1637
|
-
else if (k === 'html') e.innerHTML = v;
|
|
1638
|
-
else e.setAttribute(k, v);
|
|
1639
|
-
}
|
|
1640
|
-
for (const c of children) {
|
|
1641
|
-
if (c == null) continue;
|
|
1642
|
-
e.append(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
1643
|
-
}
|
|
1644
|
-
return e;
|
|
1645
|
-
}
|
|
1646
|
-
function statusClass(code) {
|
|
1647
|
-
const n = parseInt(code);
|
|
1648
|
-
if (n >= 500) return 's5xx';
|
|
1649
|
-
if (n >= 400) return 's4xx';
|
|
1650
|
-
return 's2xx';
|
|
1651
|
-
}
|
|
1652
|
-
function schemaType(s) {
|
|
1653
|
-
if (!s) return '';
|
|
1654
|
-
if (s.$ref) return s.$ref.split('/').pop();
|
|
1655
|
-
if (s.type === 'array') return s.items ? schemaType(s.items) + '[]' : 'array';
|
|
1656
|
-
return s.format ? s.type + '<' + s.format + '>' : (s.type ?? '');
|
|
1657
|
-
}
|
|
1658
|
-
function resolveRef(ref) {
|
|
1659
|
-
const parts = ref.replace(/^#\\//, '').split('/');
|
|
1660
|
-
return parts.reduce((o, k) => o?.[k], schema);
|
|
1661
|
-
}
|
|
1662
|
-
function resolveResponse(resp) {
|
|
1663
|
-
return resp?.$ref ? resolveRef(resp.$ref) : resp;
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
// ── info ─────────────────────────────────────────────────────────
|
|
1667
|
-
const info = schema.info ?? {};
|
|
1668
|
-
document.title = info.title ?? 'API Reference';
|
|
1669
|
-
document.getElementById('nav-title').innerHTML =
|
|
1670
|
-
esc(info.title ?? 'API Reference') +
|
|
1671
|
-
(info.version ? \` <span class="version">v\${esc(info.version)}</span>\` : '');
|
|
1672
|
-
|
|
1673
|
-
const infoEl = document.getElementById('info');
|
|
1674
|
-
infoEl.append(
|
|
1675
|
-
el('h2', {}, info.title ?? 'API Reference'),
|
|
1676
|
-
el('div', {class:'meta'},
|
|
1677
|
-
info.version ? el('span', {class:'badge'}, 'v' + info.version) : null,
|
|
1678
|
-
el('span', {class:'badge'}, 'OpenAPI ' + (schema.openapi ?? '3.x')),
|
|
1679
|
-
),
|
|
1680
|
-
info.description ? el('p', {html: esc(info.description).replace(/\`([^\`]+)\`/g, '<code>$1</code>')}) : null,
|
|
1681
|
-
);
|
|
1682
|
-
|
|
1683
|
-
// ── endpoints ────────────────────────────────────────────────────
|
|
1684
|
-
const container = document.getElementById('endpoints');
|
|
1685
|
-
const nav = document.getElementById('sidebar');
|
|
1686
|
-
const methods = ['get','post','put','patch','delete'];
|
|
1687
|
-
|
|
1688
|
-
for (const [path, pathItem] of Object.entries(schema.paths ?? {})) {
|
|
1689
|
-
for (const method of methods) {
|
|
1690
|
-
const op = pathItem[method];
|
|
1691
|
-
if (!op) continue;
|
|
1692
|
-
|
|
1693
|
-
const id = method + '_' + path.replace(/[^a-z0-9]/gi, '_');
|
|
1694
|
-
|
|
1695
|
-
// nav link
|
|
1696
|
-
const navLink = el('a', {class:'nav-item', href:'#'+id});
|
|
1697
|
-
navLink.append(
|
|
1698
|
-
el('span', {class:'method method-'+method}, method),
|
|
1699
|
-
el('span', {}, path),
|
|
1700
|
-
);
|
|
1701
|
-
nav.append(navLink);
|
|
1702
|
-
|
|
1703
|
-
// card header
|
|
1704
|
-
const header = el('div', {class:'endpoint-header', id});
|
|
1705
|
-
header.append(
|
|
1706
|
-
el('span', {class:'method method-'+method}, method),
|
|
1707
|
-
el('span', {class:'endpoint-path'}, path),
|
|
1708
|
-
op.summary ? el('span', {class:'endpoint-summary'}, op.summary) : null,
|
|
1709
|
-
el('span', {class:'chevron'}, '▶'),
|
|
1710
|
-
);
|
|
1711
|
-
|
|
1712
|
-
// card body
|
|
1713
|
-
const body = el('div', {class:'endpoint-body'});
|
|
1714
|
-
|
|
1715
|
-
if (op.description) {
|
|
1716
|
-
body.append(el('p', {class:'endpoint-desc', html:
|
|
1717
|
-
esc(op.description).replace(/\`([^\`]+)\`/g,'<code>$1</code>')}));
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
// parameters
|
|
1721
|
-
const params = op.parameters ?? [];
|
|
1722
|
-
if (params.length) {
|
|
1723
|
-
body.append(el('div', {class:'section-label'}, 'Parameters'));
|
|
1724
|
-
const tbl = el('table', {});
|
|
1725
|
-
tbl.append(el('thead', {}, el('tr', {},
|
|
1726
|
-
el('th',{},'Name'), el('th',{},'In'), el('th',{},'Type'), el('th',{},'Description')
|
|
1727
|
-
)));
|
|
1728
|
-
const tbody = el('tbody', {});
|
|
1729
|
-
for (const p of params) {
|
|
1730
|
-
const nameCell = el('td', {class:'param-name'}, p.name);
|
|
1731
|
-
if (p.required) nameCell.append(el('span', {class:'required'}, '*'));
|
|
1732
|
-
tbody.append(el('tr', {},
|
|
1733
|
-
nameCell,
|
|
1734
|
-
el('td', {class:'param-in'}, p.in),
|
|
1735
|
-
el('td', {class:'param-type'}, schemaType(p.schema)),
|
|
1736
|
-
el('td', {class:'response-desc'}, p.description ?? ''),
|
|
1737
|
-
));
|
|
1738
|
-
}
|
|
1739
|
-
tbl.append(tbody);
|
|
1740
|
-
body.append(tbl);
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// request body
|
|
1744
|
-
if (op.requestBody) {
|
|
1745
|
-
body.append(el('div', {class:'section-label'}, 'Request Body'));
|
|
1746
|
-
const content = op.requestBody.content ?? {};
|
|
1747
|
-
for (const [ct, media] of Object.entries(content)) {
|
|
1748
|
-
body.append(el('div', {class:'param-in'}, ct));
|
|
1749
|
-
if (media.schema) {
|
|
1750
|
-
const resolved = media.schema.$ref ? resolveRef(media.schema.$ref) : media.schema;
|
|
1751
|
-
if (resolved?.properties) {
|
|
1752
|
-
const tbl = el('table', {});
|
|
1753
|
-
tbl.append(el('thead', {}, el('tr', {},
|
|
1754
|
-
el('th',{},'Field'), el('th',{},'Type'), el('th',{},'Description')
|
|
1755
|
-
)));
|
|
1756
|
-
const tbody = el('tbody', {});
|
|
1757
|
-
const required = new Set(resolved.required ?? []);
|
|
1758
|
-
for (const [name, prop] of Object.entries(resolved.properties)) {
|
|
1759
|
-
const nameCell = el('td', {class:'param-name'}, name);
|
|
1760
|
-
if (required.has(name)) nameCell.append(el('span', {class:'required'}, '*'));
|
|
1761
|
-
tbody.append(el('tr', {},
|
|
1762
|
-
nameCell,
|
|
1763
|
-
el('td', {class:'param-type'}, schemaType(prop)),
|
|
1764
|
-
el('td', {class:'response-desc'}, prop.description ?? ''),
|
|
1765
|
-
));
|
|
1766
|
-
}
|
|
1767
|
-
tbl.append(tbody);
|
|
1768
|
-
body.append(tbl);
|
|
1769
|
-
}
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
// responses
|
|
1775
|
-
if (op.responses) {
|
|
1776
|
-
body.append(el('div', {class:'section-label'}, 'Responses'));
|
|
1777
|
-
const wrap = el('div', {});
|
|
1778
|
-
for (const [code, resp] of Object.entries(op.responses)) {
|
|
1779
|
-
const resolved = resolveResponse(resp);
|
|
1780
|
-
const hasContent = resolved?.content && Object.keys(resolved.content).length > 0;
|
|
1781
|
-
|
|
1782
|
-
const group = el('div', {class:'response-group'});
|
|
1783
|
-
const respHeader = el('div', {class:'response-header'});
|
|
1784
|
-
respHeader.append(
|
|
1785
|
-
el('span', {class:'status-code ' + statusClass(code)}, code),
|
|
1786
|
-
el('span', {class:'response-desc'}, resolved?.description ?? ''),
|
|
1787
|
-
hasContent ? el('span', {class:'resp-chevron'}, '\u25B6') : null,
|
|
1788
|
-
);
|
|
1789
|
-
group.append(respHeader);
|
|
1790
|
-
|
|
1791
|
-
if (hasContent) {
|
|
1792
|
-
const detail = el('div', {class:'response-detail'});
|
|
1793
|
-
for (const [ct, media] of Object.entries(resolved.content)) {
|
|
1794
|
-
detail.append(el('div', {class:'response-content-type'}, ct));
|
|
1795
|
-
|
|
1796
|
-
// Render response schema fields
|
|
1797
|
-
if (media.schema) {
|
|
1798
|
-
const resolvedSchema = media.schema.$ref ? resolveRef(media.schema.$ref) : media.schema;
|
|
1799
|
-
if (resolvedSchema?.properties) {
|
|
1800
|
-
detail.append(el('div', {class:'section-label'}, 'Schema'));
|
|
1801
|
-
const tbl = el('table', {});
|
|
1802
|
-
tbl.append(el('thead', {}, el('tr', {},
|
|
1803
|
-
el('th',{},'Field'), el('th',{},'Type'), el('th',{},'Description')
|
|
1804
|
-
)));
|
|
1805
|
-
const tbody = el('tbody', {});
|
|
1806
|
-
const required = new Set(resolvedSchema.required ?? []);
|
|
1807
|
-
for (const [name, prop] of Object.entries(resolvedSchema.properties)) {
|
|
1808
|
-
const nameCell = el('td', {class:'param-name'}, name);
|
|
1809
|
-
if (required.has(name)) nameCell.append(el('span', {class:'required'}, '*'));
|
|
1810
|
-
tbody.append(el('tr', {},
|
|
1811
|
-
nameCell,
|
|
1812
|
-
el('td', {class:'param-type'}, schemaType(prop)),
|
|
1813
|
-
el('td', {class:'response-desc'}, prop.description ?? ''),
|
|
1814
|
-
));
|
|
1815
|
-
}
|
|
1816
|
-
tbl.append(tbody);
|
|
1817
|
-
detail.append(tbl);
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
// Render response example
|
|
1822
|
-
if (media.example !== undefined) {
|
|
1823
|
-
detail.append(el('div', {class:'section-label'}, 'Example'));
|
|
1824
|
-
detail.append(el('pre', {class:'example-block'}, JSON.stringify(media.example, null, 2)));
|
|
1825
|
-
}
|
|
1826
|
-
if (media.examples) {
|
|
1827
|
-
detail.append(el('div', {class:'section-label'}, 'Examples'));
|
|
1828
|
-
for (const [exName, exObj] of Object.entries(media.examples)) {
|
|
1829
|
-
const val = exObj?.value ?? exObj;
|
|
1830
|
-
detail.append(el('pre', {class:'example-block'}, '// ' + exName + '\\n' + JSON.stringify(val, null, 2)));
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
respHeader.addEventListener('click', () => {
|
|
1836
|
-
const open = respHeader.classList.toggle('open');
|
|
1837
|
-
detail.classList.toggle('open', open);
|
|
1838
|
-
});
|
|
1839
|
-
group.append(detail);
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
wrap.append(group);
|
|
1843
|
-
}
|
|
1844
|
-
body.append(wrap);
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
// x-codeSamples
|
|
1848
|
-
const samples = op['x-codeSamples'];
|
|
1849
|
-
if (samples && samples.length) {
|
|
1850
|
-
body.append(el('div', {class:'section-label'}, 'Example'));
|
|
1851
|
-
const samplesWrap = el('div', {class:'code-samples'});
|
|
1852
|
-
for (const sample of samples) {
|
|
1853
|
-
samplesWrap.append(
|
|
1854
|
-
el('div', {class:'code-sample-label'}, sample.label ?? sample.lang ?? 'Example'),
|
|
1855
|
-
el('pre', {}, sample.source),
|
|
1856
|
-
);
|
|
1857
|
-
}
|
|
1858
|
-
body.append(samplesWrap);
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
// toggle
|
|
1862
|
-
header.addEventListener('click', () => {
|
|
1863
|
-
const open = header.classList.toggle('open');
|
|
1864
|
-
body.classList.toggle('open', open);
|
|
1865
|
-
// sync nav highlight
|
|
1866
|
-
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
1867
|
-
if (open) navLink.classList.add('active');
|
|
1868
|
-
});
|
|
1869
|
-
|
|
1870
|
-
const card = el('div', {class:'endpoint'});
|
|
1871
|
-
card.append(header, body);
|
|
1872
|
-
container.append(card);
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
// highlight nav on scroll
|
|
1877
|
-
const observer = new IntersectionObserver(entries => {
|
|
1878
|
-
for (const entry of entries) {
|
|
1879
|
-
if (entry.isIntersecting) {
|
|
1880
|
-
const id = entry.target.id;
|
|
1881
|
-
document.querySelectorAll('.nav-item').forEach(n => {
|
|
1882
|
-
n.classList.toggle('active', n.getAttribute('href') === '#' + id);
|
|
1883
|
-
});
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
}, { threshold: 0.5 });
|
|
1887
|
-
document.querySelectorAll('.endpoint-header[id]').forEach(h => observer.observe(h));
|
|
1888
|
-
<\/script>
|
|
1889
|
-
</body>
|
|
1890
|
-
</html>`;
|
|
1891
|
-
}
|
|
1892
|
-
function esc(s) {
|
|
1893
|
-
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
//#endregion
|
|
1897
|
-
//#region src/bridge/routes/common.ts
|
|
1898
|
-
function getSandbox(ns, containerUUID) {
|
|
1899
|
-
return getSandbox$1(ns, containerUUID);
|
|
1900
|
-
}
|
|
1901
|
-
function getSandboxNs(env$1, sandboxBinding) {
|
|
1902
|
-
return env$1[sandboxBinding];
|
|
1903
|
-
}
|
|
1904
|
-
function getWarmPoolNs(env$1, warmPoolBinding) {
|
|
1905
|
-
return env$1[warmPoolBinding];
|
|
1906
|
-
}
|
|
1907
|
-
function getWarmPoolStub(env$1, warmPoolBinding) {
|
|
1908
|
-
const poolNs = getWarmPoolNs(env$1, warmPoolBinding);
|
|
1909
|
-
const poolId = poolNs.idFromName("global-pool");
|
|
1910
|
-
return poolNs.get(poolId);
|
|
1911
|
-
}
|
|
1912
|
-
function hasEndpoint(options) {
|
|
1913
|
-
return "endpoint" in options && typeof options.endpoint === "string";
|
|
1914
|
-
}
|
|
1915
|
-
function hasEndpointProperty(options) {
|
|
1916
|
-
return "endpoint" in options && options.endpoint !== void 0;
|
|
1917
|
-
}
|
|
1918
|
-
function hasCredentials(options) {
|
|
1919
|
-
return "credentials" in options && options.credentials !== void 0;
|
|
1920
|
-
}
|
|
1921
|
-
function isStringArray(value) {
|
|
1922
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1923
|
-
}
|
|
1924
|
-
function parseTunnelOptions(rawBody) {
|
|
1925
|
-
if (!rawBody.trim()) return void 0;
|
|
1926
|
-
let body;
|
|
1927
|
-
try {
|
|
1928
|
-
body = JSON.parse(rawBody);
|
|
1929
|
-
} catch {
|
|
1930
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
1931
|
-
}
|
|
1932
|
-
if (!body || typeof body !== "object" || Array.isArray(body) || body.name === void 0) return;
|
|
1933
|
-
if (typeof body.name !== "string") return errorJson("name must be a string when provided", "invalid_request", 400);
|
|
1934
|
-
try {
|
|
1935
|
-
validateTunnelName(body.name);
|
|
1936
|
-
} catch (err) {
|
|
1937
|
-
if (err instanceof SandboxSecurityError) return errorJson(err.message, "invalid_request", 400);
|
|
1938
|
-
throw err;
|
|
1939
|
-
}
|
|
1940
|
-
return { name: body.name };
|
|
1941
|
-
}
|
|
1942
|
-
function validateMountOptions(options, binding) {
|
|
1943
|
-
if ("endpoint" in options && !hasEndpoint(options)) return errorJson("options.endpoint must be a string when provided", "invalid_request", 400);
|
|
1944
|
-
if (binding !== void 0 && typeof binding !== "string") return errorJson("binding must be a string when provided", "invalid_request", 400);
|
|
1945
|
-
if (binding === "") return errorJson("binding must be a non-empty string when provided", "invalid_request", 400);
|
|
1946
|
-
if (binding !== void 0 && hasEndpointProperty(options)) return errorJson("Provide either binding or options.endpoint, not both", "invalid_request", 400);
|
|
1947
|
-
if (options.s3fsOptions !== void 0 && !isStringArray(options.s3fsOptions)) return errorJson("options.s3fsOptions must be an array of strings when provided", "invalid_request", 400);
|
|
1948
|
-
if (options.readOnly !== void 0 && typeof options.readOnly !== "boolean") return errorJson("options.readOnly must be a boolean when provided", "invalid_request", 400);
|
|
1949
|
-
if (options.prefix !== void 0 && typeof options.prefix !== "string") return errorJson("options.prefix must be a string when provided", "invalid_request", 400);
|
|
1950
|
-
if ("credentialProxy" in options && options.credentialProxy !== void 0 && typeof options.credentialProxy !== "boolean") return errorJson("options.credentialProxy must be a boolean when provided", "invalid_request", 400);
|
|
1951
|
-
if ("credentials" in options && options.credentials !== void 0 && (typeof options.credentials !== "object" || options.credentials === null || typeof options.credentials.accessKeyId !== "string" || typeof options.credentials.secretAccessKey !== "string")) return errorJson("options.credentials must include string accessKeyId and secretAccessKey", "invalid_request", 400);
|
|
1952
|
-
return null;
|
|
1953
|
-
}
|
|
1954
|
-
function resolveMountBucketName(body) {
|
|
1955
|
-
if (hasEndpoint(body.options)) {
|
|
1956
|
-
if (body.bucket && typeof body.bucket === "string") return body.bucket;
|
|
1957
|
-
return errorJson("bucket must be a non-empty string for remote mounts", "invalid_request", 400);
|
|
1958
|
-
}
|
|
1959
|
-
if (body.binding !== void 0) return body.binding;
|
|
1960
|
-
return errorJson("binding must be a non-empty string for R2 binding mounts", "invalid_request", 400);
|
|
1961
|
-
}
|
|
1962
|
-
function toSDKMountOptions(options) {
|
|
1963
|
-
if (hasEndpoint(options)) {
|
|
1964
|
-
const remoteOptions = { endpoint: options.endpoint };
|
|
1965
|
-
if (options.readOnly !== void 0) remoteOptions.readOnly = options.readOnly;
|
|
1966
|
-
if (options.prefix !== void 0) remoteOptions.prefix = options.prefix;
|
|
1967
|
-
if (hasCredentials(options)) remoteOptions.credentials = {
|
|
1968
|
-
accessKeyId: options.credentials.accessKeyId,
|
|
1969
|
-
secretAccessKey: options.credentials.secretAccessKey
|
|
1970
|
-
};
|
|
1971
|
-
if (options.s3fsOptions !== void 0) remoteOptions.s3fsOptions = options.s3fsOptions;
|
|
1972
|
-
if ("credentialProxy" in options && typeof options.credentialProxy === "boolean") remoteOptions.credentialProxy = options.credentialProxy;
|
|
1973
|
-
return remoteOptions;
|
|
1974
|
-
}
|
|
1975
|
-
const r2BindingOptions = {};
|
|
1976
|
-
if (options.readOnly !== void 0) r2BindingOptions.readOnly = options.readOnly;
|
|
1977
|
-
if (options.prefix !== void 0) r2BindingOptions.prefix = options.prefix;
|
|
1978
|
-
if (options.s3fsOptions !== void 0) r2BindingOptions.s3fsOptions = options.s3fsOptions;
|
|
1979
|
-
return r2BindingOptions;
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
//#endregion
|
|
1983
|
-
//#region src/bridge/routes/files.ts
|
|
1984
|
-
function registerFileRoutes(app, apiPrefix, sandboxBinding) {
|
|
1985
|
-
app.get(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
|
|
1986
|
-
const sandboxId = c.req.param("id");
|
|
1987
|
-
const fullPath = c.req.path;
|
|
1988
|
-
const marker = `${apiPrefix}/sandbox/${sandboxId}/file/`;
|
|
1989
|
-
const relativePath = fullPath.slice(marker.length);
|
|
1990
|
-
if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
|
|
1991
|
-
const resolvedPath = resolveWorkspacePath(`/${relativePath}`);
|
|
1992
|
-
if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
|
|
1993
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
1994
|
-
try {
|
|
1995
|
-
const stream = await sandbox.readFileStream(resolvedPath);
|
|
1996
|
-
return new Response(sseToByteStream(stream), {
|
|
1997
|
-
status: 200,
|
|
1998
|
-
headers: { "Content-Type": "application/octet-stream" }
|
|
1999
|
-
});
|
|
2000
|
-
} catch (err) {
|
|
2001
|
-
if (err.code === "FILE_NOT_FOUND") return errorJson(`File not found: ${resolvedPath}`, "workspace_read_not_found", 404);
|
|
2002
|
-
return errorJson(`read failed: ${err instanceof Error ? err.message : String(err)}`, "exec_transport_error", 502);
|
|
2003
|
-
}
|
|
2004
|
-
});
|
|
2005
|
-
app.put(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
|
|
2006
|
-
const sandboxId = c.req.param("id");
|
|
2007
|
-
const fullPath = c.req.path;
|
|
2008
|
-
const marker = `${apiPrefix}/sandbox/${sandboxId}/file/`;
|
|
2009
|
-
const relativePath = fullPath.slice(marker.length);
|
|
2010
|
-
if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
|
|
2011
|
-
const resolvedPath = resolveWorkspacePath(`/${relativePath}`);
|
|
2012
|
-
if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
|
|
2013
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2014
|
-
try {
|
|
2015
|
-
const buffer = await c.req.arrayBuffer();
|
|
2016
|
-
const MAX_WRITE_BYTES = 32 * 1024 * 1024;
|
|
2017
|
-
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);
|
|
2018
|
-
const bytes = new Uint8Array(buffer);
|
|
2019
|
-
let b64 = "";
|
|
2020
|
-
const CHUNK = 6144;
|
|
2021
|
-
for (let i = 0; i < bytes.length; i += CHUNK) b64 += btoa(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
|
|
2022
|
-
await sandbox.writeFile(resolvedPath, b64, { encoding: "base64" });
|
|
2023
|
-
return c.json({ ok: true });
|
|
2024
|
-
} catch (err) {
|
|
2025
|
-
return errorJson(`write failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
|
|
2026
|
-
}
|
|
2027
|
-
});
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
//#endregion
|
|
2031
|
-
//#region src/bridge/routes/lifecycle.ts
|
|
2032
|
-
function registerLifecycleRoutes(app, apiPrefix, healthPath, sandboxBinding, warmPoolBinding) {
|
|
2033
|
-
app.post(`${apiPrefix}/sandbox`, (c) => {
|
|
2034
|
-
const bytes = new Uint8Array(15);
|
|
2035
|
-
crypto.getRandomValues(bytes);
|
|
2036
|
-
return c.json({ id: base32Encode(bytes) });
|
|
2037
|
-
});
|
|
2038
|
-
app.post(`${apiPrefix}/sandbox/:id/persist`, async (c) => {
|
|
2039
|
-
const root = "/workspace";
|
|
2040
|
-
const excludesParam = c.req.query("excludes") ?? "";
|
|
2041
|
-
const excludes = excludesParam ? excludesParam.split(",").filter((s) => s.length > 0) : [];
|
|
2042
|
-
for (const ex of excludes) if (ex.includes("..")) return errorJson("exclude paths must not contain \"..\"", "invalid_request", 400);
|
|
2043
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2044
|
-
try {
|
|
2045
|
-
const archivePath = await sandbox.createWorkspaceArchive({
|
|
2046
|
-
root,
|
|
2047
|
-
excludes
|
|
2048
|
-
});
|
|
2049
|
-
let stream;
|
|
2050
|
-
try {
|
|
2051
|
-
stream = await sandbox.readFileStream(archivePath);
|
|
2052
|
-
} catch (error) {
|
|
2053
|
-
await sandbox.cleanupWorkspaceArchive(archivePath).catch(() => void 0);
|
|
2054
|
-
throw error;
|
|
2055
|
-
}
|
|
2056
|
-
const cleanupPath = archivePath;
|
|
2057
|
-
const responseStream = withStreamCleanup(sseToByteStream(stream), () => sandbox.cleanupWorkspaceArchive(cleanupPath));
|
|
2058
|
-
return new Response(responseStream, {
|
|
2059
|
-
status: 200,
|
|
2060
|
-
headers: { "Content-Type": "application/octet-stream" }
|
|
2061
|
-
});
|
|
2062
|
-
} catch (err) {
|
|
2063
|
-
return errorJson(`persist failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_read_error", 502);
|
|
2064
|
-
}
|
|
2065
|
-
});
|
|
2066
|
-
app.post(`${apiPrefix}/sandbox/:id/hydrate`, async (c) => {
|
|
2067
|
-
const root = "/workspace";
|
|
2068
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2069
|
-
let tarBytes;
|
|
2070
|
-
try {
|
|
2071
|
-
const buffer = await c.req.arrayBuffer();
|
|
2072
|
-
tarBytes = new Uint8Array(buffer);
|
|
2073
|
-
} catch {
|
|
2074
|
-
return errorJson("Could not read request body", "invalid_request", 400);
|
|
2075
|
-
}
|
|
2076
|
-
if (tarBytes.byteLength === 0) return errorJson("Empty tar payload", "invalid_request", 400);
|
|
2077
|
-
const MAX_HYDRATE_BYTES = 32 * 1024 * 1024;
|
|
2078
|
-
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);
|
|
2079
|
-
const tmpPath = `/tmp/sandbox-hydrate-${Date.now()}-${crypto.randomUUID()}.tar`;
|
|
2080
|
-
try {
|
|
2081
|
-
let b64 = "";
|
|
2082
|
-
const CHUNK = 6144;
|
|
2083
|
-
for (let i = 0; i < tarBytes.length; i += CHUNK) b64 += btoa(String.fromCharCode(...tarBytes.subarray(i, i + CHUNK)));
|
|
2084
|
-
await sandbox.writeFile(tmpPath, b64, { encoding: "base64" });
|
|
2085
|
-
await sandbox.extractWorkspaceArchive({
|
|
2086
|
-
root,
|
|
2087
|
-
archivePath: tmpPath
|
|
2088
|
-
});
|
|
2089
|
-
return c.json({ ok: true });
|
|
2090
|
-
} catch (err) {
|
|
2091
|
-
return errorJson(`hydrate failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
|
|
2092
|
-
} finally {
|
|
2093
|
-
await sandbox.cleanupWorkspaceArchive(tmpPath).catch(() => void 0);
|
|
2094
|
-
}
|
|
2095
|
-
});
|
|
2096
|
-
app.post(`${apiPrefix}/sandbox/:id/mount`, async (c) => {
|
|
2097
|
-
let body;
|
|
2098
|
-
try {
|
|
2099
|
-
body = await c.req.json();
|
|
2100
|
-
} catch {
|
|
2101
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2102
|
-
}
|
|
2103
|
-
if (body.bucket !== void 0 && (typeof body.bucket !== "string" || body.bucket === "")) return errorJson("bucket must be a non-empty string", "invalid_request", 400);
|
|
2104
|
-
if (!body.mountPath || typeof body.mountPath !== "string") return errorJson("mountPath must be a non-empty string", "invalid_request", 400);
|
|
2105
|
-
if (!body.mountPath.startsWith("/")) return errorJson("mountPath must be an absolute path (start with /)", "invalid_request", 400);
|
|
2106
|
-
if (!body.options || typeof body.options !== "object" || Array.isArray(body.options)) return errorJson("options must be an object", "invalid_request", 400);
|
|
2107
|
-
const optionsError = validateMountOptions(body.options, body.binding);
|
|
2108
|
-
if (optionsError) return optionsError;
|
|
2109
|
-
const bucketName = resolveMountBucketName(body);
|
|
2110
|
-
if (bucketName instanceof Response) return bucketName;
|
|
2111
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2112
|
-
const sdkOptions = toSDKMountOptions(body.options);
|
|
2113
|
-
try {
|
|
2114
|
-
await sandbox.mountBucket(bucketName, body.mountPath, sdkOptions);
|
|
2115
|
-
return c.json({ ok: true });
|
|
2116
|
-
} catch (err) {
|
|
2117
|
-
return errorJson(`mount failed: ${err instanceof Error ? err.message : String(err)}`, "mount_error", 502);
|
|
2118
|
-
}
|
|
2119
|
-
});
|
|
2120
|
-
app.post(`${apiPrefix}/sandbox/:id/unmount`, async (c) => {
|
|
2121
|
-
let body;
|
|
2122
|
-
try {
|
|
2123
|
-
body = await c.req.json();
|
|
2124
|
-
} catch {
|
|
2125
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2126
|
-
}
|
|
2127
|
-
if (!body.mountPath || typeof body.mountPath !== "string") return errorJson("mountPath must be a non-empty string", "invalid_request", 400);
|
|
2128
|
-
if (!body.mountPath.startsWith("/")) return errorJson("mountPath must be an absolute path (start with /)", "invalid_request", 400);
|
|
2129
|
-
const normalizedPath = new URL(body.mountPath, "file:///").pathname;
|
|
2130
|
-
if (normalizedPath === "/") return errorJson("mountPath must not resolve to / (filesystem root)", "invalid_request", 400);
|
|
2131
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2132
|
-
try {
|
|
2133
|
-
await sandbox.unmountBucket(normalizedPath);
|
|
2134
|
-
try {
|
|
2135
|
-
await sandbox.cleanupMountDirectory(normalizedPath);
|
|
2136
|
-
} catch {}
|
|
2137
|
-
return c.json({ ok: true });
|
|
2138
|
-
} catch (err) {
|
|
2139
|
-
return errorJson(`unmount failed: ${err instanceof Error ? err.message : String(err)}`, "unmount_error", 502);
|
|
2140
|
-
}
|
|
2141
|
-
});
|
|
2142
|
-
app.delete(`${apiPrefix}/sandbox/:id`, async (c) => {
|
|
2143
|
-
const containerUUID = c.get("containerUUID");
|
|
2144
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), containerUUID);
|
|
2145
|
-
try {
|
|
2146
|
-
await sandbox.destroy();
|
|
2147
|
-
} catch {}
|
|
2148
|
-
try {
|
|
2149
|
-
await getWarmPoolStub(c.env, warmPoolBinding).reportStopped(containerUUID);
|
|
2150
|
-
} catch {}
|
|
2151
|
-
return new Response(null, { status: 204 });
|
|
2152
|
-
});
|
|
2153
|
-
app.get(healthPath, (c) => {
|
|
2154
|
-
const errors = [];
|
|
2155
|
-
if (!c.env[sandboxBinding]) errors.push(`Missing required Durable Object binding "${sandboxBinding}". Ensure your wrangler.jsonc has a binding named "${sandboxBinding}".`);
|
|
2156
|
-
if (!c.env[warmPoolBinding]) errors.push(`Missing required Durable Object binding "${warmPoolBinding}". Ensure your wrangler.jsonc has a binding named "${warmPoolBinding}".`);
|
|
2157
|
-
if (errors.length > 0) return c.json({
|
|
2158
|
-
ok: false,
|
|
2159
|
-
errors
|
|
2160
|
-
}, 503);
|
|
2161
|
-
return c.json({ ok: true });
|
|
2162
|
-
});
|
|
2163
|
-
app.use(`${apiPrefix}/pool/*`, async (c, next) => {
|
|
2164
|
-
const token = c.env.SANDBOX_API_KEY;
|
|
2165
|
-
if (token) {
|
|
2166
|
-
const authHeader = c.req.header("Authorization") ?? "";
|
|
2167
|
-
if ((authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
2168
|
-
}
|
|
2169
|
-
return next();
|
|
2170
|
-
});
|
|
2171
|
-
app.get(`${apiPrefix}/pool/stats`, async (c) => {
|
|
2172
|
-
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2173
|
-
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2174
|
-
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2175
|
-
try {
|
|
2176
|
-
await poolStub.configure({
|
|
2177
|
-
warmTarget,
|
|
2178
|
-
refreshInterval
|
|
2179
|
-
});
|
|
2180
|
-
} catch {}
|
|
2181
|
-
const stats = await poolStub.getStats();
|
|
2182
|
-
return c.json(stats);
|
|
2183
|
-
});
|
|
2184
|
-
app.post(`${apiPrefix}/pool/shutdown-prewarmed`, async (c) => {
|
|
2185
|
-
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2186
|
-
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2187
|
-
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2188
|
-
try {
|
|
2189
|
-
await poolStub.configure({
|
|
2190
|
-
warmTarget,
|
|
2191
|
-
refreshInterval
|
|
2192
|
-
});
|
|
2193
|
-
} catch {}
|
|
2194
|
-
await poolStub.shutdownPrewarmed();
|
|
2195
|
-
return c.json({ ok: true });
|
|
2196
|
-
});
|
|
2197
|
-
app.post(`${apiPrefix}/pool/prime`, async (c) => {
|
|
2198
|
-
await primePool(c.env, warmPoolBinding);
|
|
2199
|
-
return c.json({ ok: true });
|
|
2200
|
-
});
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
//#endregion
|
|
2204
|
-
//#region src/bridge/routes/processes.ts
|
|
2205
|
-
function registerProcessRoutes(app, apiPrefix, sandboxBinding) {
|
|
2206
|
-
app.post(`${apiPrefix}/sandbox/:id/processes`, async (c) => {
|
|
2207
|
-
const body = await parseProcessRequest(c.req.raw);
|
|
2208
|
-
if (body instanceof Response) return body;
|
|
2209
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2210
|
-
const options = processOptions(body);
|
|
2211
|
-
if (options instanceof Response) return options;
|
|
2212
|
-
try {
|
|
2213
|
-
const process = await sandbox.exec(body.argv, options);
|
|
2214
|
-
return c.json(await process.status());
|
|
2215
|
-
} catch (err) {
|
|
2216
|
-
return errorJson(`process launch failed: ${err instanceof Error ? err.message : String(err)}`, "process_error", 502);
|
|
2217
|
-
}
|
|
2218
|
-
});
|
|
2219
|
-
app.get(`${apiPrefix}/sandbox/:id/processes`, async (c) => {
|
|
2220
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2221
|
-
return c.json(await sandbox.listProcesses());
|
|
2222
|
-
});
|
|
2223
|
-
app.get(`${apiPrefix}/sandbox/:id/processes/:processId`, async (c) => {
|
|
2224
|
-
const process = await getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID")).getProcess(c.req.param("processId"));
|
|
2225
|
-
if (!process) return errorJson("Process not found", "not_found", 404);
|
|
2226
|
-
return c.json(await process.status());
|
|
2227
|
-
});
|
|
2228
|
-
app.get(`${apiPrefix}/sandbox/:id/processes/:processId/logs`, async (c) => {
|
|
2229
|
-
const process = await getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID")).getProcess(c.req.param("processId"));
|
|
2230
|
-
if (!process) return errorJson("Process not found", "not_found", 404);
|
|
2231
|
-
const replay = parseBoolean(c.req.query("replay"));
|
|
2232
|
-
const follow = parseBoolean(c.req.query("follow"));
|
|
2233
|
-
const logs = await process.logs({
|
|
2234
|
-
since: c.req.query("since"),
|
|
2235
|
-
replay,
|
|
2236
|
-
follow
|
|
2237
|
-
});
|
|
2238
|
-
return new Response(processLogsToSSE(logs), { headers: {
|
|
2239
|
-
"Content-Type": "text/event-stream",
|
|
2240
|
-
"Cache-Control": "no-cache"
|
|
2241
|
-
} });
|
|
2242
|
-
});
|
|
2243
|
-
app.post(`${apiPrefix}/sandbox/:id/processes/:processId/kill`, async (c) => {
|
|
2244
|
-
const process = await getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID")).getProcess(c.req.param("processId"));
|
|
2245
|
-
if (!process) return errorJson("Process not found", "not_found", 404);
|
|
2246
|
-
const signal = await parseKillRequest(c.req.raw);
|
|
2247
|
-
if (signal instanceof Response) return signal;
|
|
2248
|
-
await process.kill(signal);
|
|
2249
|
-
return c.body(null, 204);
|
|
2250
|
-
});
|
|
2251
|
-
app.post(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
|
|
2252
|
-
const port = Number(c.req.param("port"));
|
|
2253
|
-
if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
|
|
2254
|
-
const options = parseTunnelOptions(await c.req.text());
|
|
2255
|
-
if (options instanceof Response) return options;
|
|
2256
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2257
|
-
try {
|
|
2258
|
-
const tunnel = await sandbox.tunnels.get(port, options);
|
|
2259
|
-
return c.json(tunnel);
|
|
2260
|
-
} catch (err) {
|
|
2261
|
-
return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
|
|
2262
|
-
}
|
|
2263
|
-
});
|
|
2264
|
-
app.delete(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
|
|
2265
|
-
const port = Number(c.req.param("port"));
|
|
2266
|
-
if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
|
|
2267
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2268
|
-
try {
|
|
2269
|
-
await sandbox.tunnels.destroy(port);
|
|
2270
|
-
return c.body(null, 204);
|
|
2271
|
-
} catch (err) {
|
|
2272
|
-
return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
|
|
2273
|
-
}
|
|
2274
|
-
});
|
|
2275
|
-
app.get(`${apiPrefix}/sandbox/:id/running`, async (c) => {
|
|
2276
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2277
|
-
try {
|
|
2278
|
-
const response = { running: await sandbox.isRuntimeActive() };
|
|
2279
|
-
return c.json(response);
|
|
2280
|
-
} catch {
|
|
2281
|
-
return c.json({ running: false });
|
|
2282
|
-
}
|
|
2283
|
-
});
|
|
2284
|
-
}
|
|
2285
|
-
async function parseProcessRequest(request) {
|
|
2286
|
-
let body;
|
|
2287
|
-
try {
|
|
2288
|
-
body = await request.json();
|
|
2289
|
-
} catch {
|
|
2290
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2291
|
-
}
|
|
2292
|
-
if (!Array.isArray(body.argv) || body.argv.length === 0) return errorJson("argv must be a non-empty array", "invalid_request", 400);
|
|
2293
|
-
if (body.argv.some((item) => typeof item !== "string")) return errorJson("argv items must be strings", "invalid_request", 400);
|
|
2294
|
-
if (body.argv[0].length === 0) return errorJson("argv executable must be non-empty", "invalid_request", 400);
|
|
2295
|
-
return body;
|
|
2296
|
-
}
|
|
2297
|
-
function processOptions(body) {
|
|
2298
|
-
const options = {};
|
|
2299
|
-
if (body.timeout !== void 0) {
|
|
2300
|
-
if (typeof body.timeout !== "number" || !Number.isFinite(body.timeout) || body.timeout <= 0) return errorJson("timeout must be a positive finite number", "invalid_request", 400);
|
|
2301
|
-
options.timeout = body.timeout;
|
|
2302
|
-
}
|
|
2303
|
-
if (typeof body.cwd === "string") {
|
|
2304
|
-
const cwd = resolveWorkspacePath(body.cwd);
|
|
2305
|
-
if (!cwd) return errorJson("cwd must resolve to a location within /workspace", "invalid_request", 403);
|
|
2306
|
-
options.cwd = cwd;
|
|
2307
|
-
}
|
|
2308
|
-
if ("env" in body && body.env !== void 0) {
|
|
2309
|
-
if (!isStringRecord(body.env)) return errorJson("env must be an object of strings", "invalid_request", 400);
|
|
2310
|
-
options.env = body.env;
|
|
2311
|
-
}
|
|
2312
|
-
return options;
|
|
2313
|
-
}
|
|
2314
|
-
async function parseKillRequest(request) {
|
|
2315
|
-
if (!request.headers.get("content-type")) return 15;
|
|
2316
|
-
let body;
|
|
2317
|
-
try {
|
|
2318
|
-
body = await request.json();
|
|
2319
|
-
} catch {
|
|
2320
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2321
|
-
}
|
|
2322
|
-
if (typeof body !== "object" || body === null || Array.isArray(body)) return errorJson("Request body must be an object", "invalid_request", 400);
|
|
2323
|
-
const signal = body.signal;
|
|
2324
|
-
if (signal === void 0) return 15;
|
|
2325
|
-
if (typeof signal !== "number" || !Number.isInteger(signal) || signal < 1 || signal > 64) return errorJson("signal must be an integer between 1 and 64", "invalid_request", 400);
|
|
2326
|
-
return signal;
|
|
2327
|
-
}
|
|
2328
|
-
function parseBoolean(value) {
|
|
2329
|
-
if (value === void 0) return void 0;
|
|
2330
|
-
return value === "true" || value === "1";
|
|
2331
|
-
}
|
|
2332
|
-
function isStringRecord(value) {
|
|
2333
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
2334
|
-
}
|
|
2335
|
-
function processLogsToSSE(logs) {
|
|
2336
|
-
const reader = logs.getReader();
|
|
2337
|
-
const encoder = new TextEncoder();
|
|
2338
|
-
let finished = false;
|
|
2339
|
-
async function finish(cancelSource) {
|
|
2340
|
-
if (finished) return;
|
|
2341
|
-
finished = true;
|
|
2342
|
-
if (cancelSource) await reader.cancel().catch(() => void 0);
|
|
2343
|
-
reader.releaseLock();
|
|
2344
|
-
}
|
|
2345
|
-
return new ReadableStream({
|
|
2346
|
-
async pull(controller) {
|
|
2347
|
-
try {
|
|
2348
|
-
const result = await reader.read();
|
|
2349
|
-
if (result.done) {
|
|
2350
|
-
await finish(false);
|
|
2351
|
-
controller.close();
|
|
2352
|
-
return;
|
|
2353
|
-
}
|
|
2354
|
-
const event = result.value;
|
|
2355
|
-
const data = event.type === "stdout" || event.type === "stderr" ? {
|
|
2356
|
-
type: event.type,
|
|
2357
|
-
cursor: event.cursor,
|
|
2358
|
-
timestamp: event.timestamp,
|
|
2359
|
-
data: bytesToBase64(event.data)
|
|
2360
|
-
} : event;
|
|
2361
|
-
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2362
|
-
} catch (error) {
|
|
2363
|
-
await finish(true);
|
|
2364
|
-
controller.error(error);
|
|
2365
|
-
}
|
|
2366
|
-
},
|
|
2367
|
-
async cancel() {
|
|
2368
|
-
await finish(true);
|
|
2369
|
-
}
|
|
2370
|
-
}, { highWaterMark: 0 });
|
|
2371
|
-
}
|
|
2372
|
-
function bytesToBase64(data) {
|
|
2373
|
-
let binary = "";
|
|
2374
|
-
for (const byte of data) binary += String.fromCharCode(byte);
|
|
2375
|
-
return btoa(binary);
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
|
-
//#endregion
|
|
2379
|
-
//#region src/bridge/routes/terminals.ts
|
|
2380
|
-
function parsePositiveInteger(value, name) {
|
|
2381
|
-
if (value === void 0) return void 0;
|
|
2382
|
-
const parsed = Number(value);
|
|
2383
|
-
if (!Number.isInteger(parsed) || parsed <= 0) return errorJson(`${name} must be a positive integer`, "invalid_request", 400);
|
|
2384
|
-
return parsed;
|
|
2385
|
-
}
|
|
2386
|
-
function isSandboxCommand(value) {
|
|
2387
|
-
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string");
|
|
2388
|
-
}
|
|
2389
|
-
function parseCreateTerminalBody(rawBody) {
|
|
2390
|
-
let body;
|
|
2391
|
-
try {
|
|
2392
|
-
body = JSON.parse(rawBody);
|
|
2393
|
-
} catch {
|
|
2394
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2395
|
-
}
|
|
2396
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return errorJson("Request body must be an object", "invalid_request", 400);
|
|
2397
|
-
const record = body;
|
|
2398
|
-
const argv = record.argv;
|
|
2399
|
-
if (!isSandboxCommand(argv)) return errorJson("argv must be a non-empty string array", "invalid_request", 400);
|
|
2400
|
-
const options = { command: argv };
|
|
2401
|
-
if (record.cwd !== void 0) {
|
|
2402
|
-
if (typeof record.cwd !== "string") return errorJson("cwd must be a string", "invalid_request", 400);
|
|
2403
|
-
options.cwd = record.cwd;
|
|
2404
|
-
}
|
|
2405
|
-
if (record.env !== void 0) {
|
|
2406
|
-
if (!record.env || typeof record.env !== "object" || Array.isArray(record.env)) return errorJson("env must be an object", "invalid_request", 400);
|
|
2407
|
-
const env$1 = {};
|
|
2408
|
-
for (const [key, value] of Object.entries(record.env)) {
|
|
2409
|
-
if (typeof value !== "string") return errorJson("env values must be strings", "invalid_request", 400);
|
|
2410
|
-
env$1[key] = value;
|
|
2411
|
-
}
|
|
2412
|
-
options.env = env$1;
|
|
2413
|
-
}
|
|
2414
|
-
if (record.cols !== void 0) {
|
|
2415
|
-
if (typeof record.cols !== "number" || !Number.isInteger(record.cols) || record.cols <= 0) return errorJson("cols must be a positive integer", "invalid_request", 400);
|
|
2416
|
-
options.cols = record.cols;
|
|
2417
|
-
}
|
|
2418
|
-
if (record.rows !== void 0) {
|
|
2419
|
-
if (typeof record.rows !== "number" || !Number.isInteger(record.rows) || record.rows <= 0) return errorJson("rows must be a positive integer", "invalid_request", 400);
|
|
2420
|
-
options.rows = record.rows;
|
|
2421
|
-
}
|
|
2422
|
-
return options;
|
|
2423
|
-
}
|
|
2424
|
-
function terminalError(error) {
|
|
2425
|
-
return errorJson(`terminal failed: ${error instanceof Error ? error.message : String(error)}`, "exec_transport_error", 502);
|
|
2426
|
-
}
|
|
2427
|
-
function registerTerminalRoutes(app, apiPrefix, sandboxBinding) {
|
|
2428
|
-
app.post(`${apiPrefix}/sandbox/:id/terminals`, async (c) => {
|
|
2429
|
-
const parsed = parseCreateTerminalBody(await c.req.text());
|
|
2430
|
-
if (parsed instanceof Response) return parsed;
|
|
2431
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2432
|
-
try {
|
|
2433
|
-
const terminal = await sandbox.createTerminal(parsed);
|
|
2434
|
-
return c.json(await terminal.getSnapshot());
|
|
2435
|
-
} catch (error) {
|
|
2436
|
-
return terminalError(error);
|
|
2437
|
-
}
|
|
2438
|
-
});
|
|
2439
|
-
app.get(`${apiPrefix}/sandbox/:id/terminals`, async (c) => {
|
|
2440
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2441
|
-
try {
|
|
2442
|
-
const terminals = await sandbox.listTerminals();
|
|
2443
|
-
return c.json(await Promise.all(terminals.map((terminal) => terminal.getSnapshot())));
|
|
2444
|
-
} catch (error) {
|
|
2445
|
-
return terminalError(error);
|
|
2446
|
-
}
|
|
2447
|
-
});
|
|
2448
|
-
app.get(`${apiPrefix}/sandbox/:id/terminals/:terminalId`, async (c) => {
|
|
2449
|
-
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2450
|
-
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2451
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2452
|
-
try {
|
|
2453
|
-
const terminal = await sandbox.getTerminal(terminalId);
|
|
2454
|
-
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2455
|
-
return c.json(await terminal.getSnapshot());
|
|
2456
|
-
} catch (error) {
|
|
2457
|
-
return terminalError(error);
|
|
2458
|
-
}
|
|
2459
|
-
});
|
|
2460
|
-
app.get(`${apiPrefix}/sandbox/:id/terminals/:terminalId/connect`, async (c) => {
|
|
2461
|
-
const upgrade = c.req.header("Upgrade");
|
|
2462
|
-
if (!upgrade || upgrade.toLowerCase() !== "websocket") return errorJson("WebSocket upgrade required", "invalid_request", 400);
|
|
2463
|
-
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2464
|
-
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2465
|
-
const cols = parsePositiveInteger(c.req.query("cols"), "cols");
|
|
2466
|
-
if (cols instanceof Response) return cols;
|
|
2467
|
-
const rows = parsePositiveInteger(c.req.query("rows"), "rows");
|
|
2468
|
-
if (rows instanceof Response) return rows;
|
|
2469
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2470
|
-
try {
|
|
2471
|
-
const terminal = await sandbox.getTerminal(terminalId);
|
|
2472
|
-
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2473
|
-
return await terminal.connect(c.req.raw, {
|
|
2474
|
-
cursor: c.req.query("cursor"),
|
|
2475
|
-
cols,
|
|
2476
|
-
rows
|
|
2477
|
-
});
|
|
2478
|
-
} catch (error) {
|
|
2479
|
-
return terminalError(error);
|
|
2480
|
-
}
|
|
2481
|
-
});
|
|
2482
|
-
app.post(`${apiPrefix}/sandbox/:id/terminals/:terminalId/interrupt`, async (c) => {
|
|
2483
|
-
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2484
|
-
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2485
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2486
|
-
try {
|
|
2487
|
-
const terminal = await sandbox.getTerminal(terminalId);
|
|
2488
|
-
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2489
|
-
await terminal.interrupt();
|
|
2490
|
-
return new Response(null, { status: 204 });
|
|
2491
|
-
} catch (error) {
|
|
2492
|
-
return terminalError(error);
|
|
2493
|
-
}
|
|
2494
|
-
});
|
|
2495
|
-
app.post(`${apiPrefix}/sandbox/:id/terminals/:terminalId/terminate`, async (c) => {
|
|
2496
|
-
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2497
|
-
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2498
|
-
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2499
|
-
try {
|
|
2500
|
-
const terminal = await sandbox.getTerminal(terminalId);
|
|
2501
|
-
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2502
|
-
await terminal.terminate();
|
|
2503
|
-
return new Response(null, { status: 204 });
|
|
2504
|
-
} catch (error) {
|
|
2505
|
-
return terminalError(error);
|
|
2506
|
-
}
|
|
2507
|
-
});
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
|
-
//#endregion
|
|
2511
|
-
//#region src/bridge/routes.ts
|
|
2512
|
-
function sandboxRoutePoolMode(method, routePath) {
|
|
2513
|
-
if (method === "GET" && routePath === "/running") return "lookup";
|
|
2514
|
-
if (method === "GET" && (routePath === "/processes" || /^\/processes\/[^/]+(?:\/logs)?$/.test(routePath))) return "lookup";
|
|
2515
|
-
if (method === "POST" && /^\/processes\/[^/]+\/kill$/.test(routePath)) return "lookup";
|
|
2516
|
-
if (method === "GET" && (routePath === "/terminals" || /^\/terminals\/[^/]+(?:\/connect)?$/.test(routePath))) return "lookup";
|
|
2517
|
-
if (method === "POST" && /^\/terminals\/[^/]+\/(interrupt|terminate)$/.test(routePath)) return "lookup";
|
|
2518
|
-
if (method === "POST" && [
|
|
2519
|
-
"/processes",
|
|
2520
|
-
"/terminals",
|
|
2521
|
-
"/persist",
|
|
2522
|
-
"/hydrate",
|
|
2523
|
-
"/mount",
|
|
2524
|
-
"/unmount"
|
|
2525
|
-
].includes(routePath)) return "allocate";
|
|
2526
|
-
if ((method === "GET" || method === "PUT") && routePath.startsWith("/file/")) return "allocate";
|
|
2527
|
-
if ((method === "POST" || method === "DELETE") && /^\/tunnel\/[^/]+$/.test(routePath)) return "allocate";
|
|
2528
|
-
return "unmatched";
|
|
2529
|
-
}
|
|
2530
|
-
function missingRuntimeResponse(routePath) {
|
|
2531
|
-
if (routePath === "/running") return Response.json({ running: false });
|
|
2532
|
-
if (routePath === "/processes" || routePath === "/terminals") return Response.json([]);
|
|
2533
|
-
if (routePath.startsWith("/terminals/")) return errorJson("Terminal not found", "not_found", 404);
|
|
2534
|
-
return errorJson("Process not found", "not_found", 404);
|
|
2535
|
-
}
|
|
2536
|
-
function createBridgeApp(config) {
|
|
2537
|
-
const app = new Hono();
|
|
2538
|
-
const { sandboxBinding, warmPoolBinding, apiPrefix } = config;
|
|
2539
|
-
app.use(`${apiPrefix}/sandbox/*`, async (c, next) => {
|
|
2540
|
-
const sandboxId = new URL(c.req.url).pathname.split("/")[apiPrefix.split("/").filter(Boolean).length + 2];
|
|
2541
|
-
if (sandboxId && !/^[a-z2-7]{1,128}$/.test(sandboxId)) return errorJson("Invalid sandbox ID format", "invalid_request", 400);
|
|
2542
|
-
const token = c.env.SANDBOX_API_KEY;
|
|
2543
|
-
if (token) {
|
|
2544
|
-
const authHeader = c.req.header("Authorization") ?? "";
|
|
2545
|
-
if ((authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
2546
|
-
}
|
|
2547
|
-
return next();
|
|
2548
|
-
});
|
|
2549
|
-
app.use(`${apiPrefix}/sandbox/:id/*`, async (c, next) => {
|
|
2550
|
-
const url = new URL(c.req.url);
|
|
2551
|
-
const sandboxId = c.req.param("id");
|
|
2552
|
-
const routePath = url.pathname.slice(`${apiPrefix}/sandbox/${sandboxId}`.length);
|
|
2553
|
-
const poolMode = sandboxRoutePoolMode(c.req.method, routePath);
|
|
2554
|
-
if (poolMode === "unmatched") return next();
|
|
2555
|
-
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2556
|
-
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2557
|
-
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2558
|
-
try {
|
|
2559
|
-
await poolStub.configure({
|
|
2560
|
-
warmTarget,
|
|
2561
|
-
refreshInterval
|
|
2562
|
-
});
|
|
2563
|
-
const containerUUID = poolMode === "lookup" ? await poolStub.lookupContainer(sandboxId) : await poolStub.getContainer(sandboxId);
|
|
2564
|
-
if (!containerUUID) return missingRuntimeResponse(routePath);
|
|
2565
|
-
c.set("containerUUID", containerUUID);
|
|
2566
|
-
} catch (err) {
|
|
2567
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2568
|
-
if (msg.includes("instance limit reached")) return errorJson(msg, "capacity_exceeded", 503);
|
|
2569
|
-
return errorJson(`pool error: ${msg}`, "pool_error", 502);
|
|
2570
|
-
}
|
|
2571
|
-
return next();
|
|
2572
|
-
});
|
|
2573
|
-
app.use(`${apiPrefix}/sandbox/:id`, async (c, next) => {
|
|
2574
|
-
if (c.req.method !== "DELETE") return next();
|
|
2575
|
-
const sandboxId = c.req.param("id");
|
|
2576
|
-
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2577
|
-
try {
|
|
2578
|
-
const containerUUID = await poolStub.lookupContainer(sandboxId);
|
|
2579
|
-
if (!containerUUID) return new Response(null, { status: 204 });
|
|
2580
|
-
c.set("containerUUID", containerUUID);
|
|
2581
|
-
} catch (err) {
|
|
2582
|
-
return errorJson(`pool error: ${err instanceof Error ? err.message : String(err)}`, "pool_error", 502);
|
|
2583
|
-
}
|
|
2584
|
-
return next();
|
|
2585
|
-
});
|
|
2586
|
-
registerLifecycleRoutes(app, apiPrefix, config.healthPath, sandboxBinding, warmPoolBinding);
|
|
2587
|
-
registerProcessRoutes(app, apiPrefix, sandboxBinding);
|
|
2588
|
-
registerFileRoutes(app, apiPrefix, sandboxBinding);
|
|
2589
|
-
registerTerminalRoutes(app, apiPrefix, sandboxBinding);
|
|
2590
|
-
registerOpenApiRoutes(app, apiPrefix);
|
|
2591
|
-
return app;
|
|
2592
|
-
}
|
|
2593
|
-
function registerOpenApiRoutes(app, apiPrefix) {
|
|
2594
|
-
const openapiAuth = async (c, next) => {
|
|
2595
|
-
const token = c.env.SANDBOX_API_KEY;
|
|
2596
|
-
if (token) {
|
|
2597
|
-
const authHeader = c.req.header("Authorization") ?? "";
|
|
2598
|
-
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
2599
|
-
const queryToken = c.req.query("token") ?? "";
|
|
2600
|
-
if (provided !== token && queryToken !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
2601
|
-
}
|
|
2602
|
-
return next();
|
|
2603
|
-
};
|
|
2604
|
-
const openapiHtmlHandler = () => new Response(renderOpenApiHtml(OPENAPI_SCHEMA), { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
2605
|
-
app.get(`${apiPrefix}/openapi.json`, openapiAuth, (c) => c.json(OPENAPI_SCHEMA));
|
|
2606
|
-
app.get(`${apiPrefix}/openapi.html`, openapiAuth, openapiHtmlHandler);
|
|
2607
|
-
app.get(`${apiPrefix}/openapi`, openapiAuth, openapiHtmlHandler);
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
//#endregion
|
|
2611
|
-
//#region src/bridge/warm-pool.ts
|
|
2612
|
-
/**
|
|
2613
|
-
* WarmPool — Durable Object that maintains a pool of pre-started sandbox containers.
|
|
2614
|
-
*
|
|
2615
|
-
* The pool keeps N idle containers standing by so new sandboxes boot instantly.
|
|
2616
|
-
* Once a container is assigned to a sandbox ID, it is consumed and never returned
|
|
2617
|
-
* to the pool.
|
|
2618
|
-
*
|
|
2619
|
-
* Configuration is pushed in via `configure()` on every request (idempotent)
|
|
2620
|
-
* so changes to wrangler vars take effect without manual intervention.
|
|
2621
|
-
*/
|
|
2622
|
-
const DEFAULT_CONFIG = {
|
|
2623
|
-
warmTarget: 0,
|
|
2624
|
-
refreshInterval: 1e4
|
|
2625
|
-
};
|
|
2626
|
-
var WarmPool = class extends DurableObject {
|
|
2627
|
-
config = { ...DEFAULT_CONFIG };
|
|
2628
|
-
/** Container UUIDs that are warm and available for assignment */
|
|
2629
|
-
warmContainers = /* @__PURE__ */ new Set();
|
|
2630
|
-
/** Maps caller-provided sandbox IDs to container UUIDs (1:1, no sharing) */
|
|
2631
|
-
assignments = /* @__PURE__ */ new Map();
|
|
2632
|
-
/** Containers currently starting — excluded from health checks */
|
|
2633
|
-
startingContainers = /* @__PURE__ */ new Set();
|
|
2634
|
-
/** Inferred max_instances limit learned from Cloudflare errors, or null */
|
|
2635
|
-
knownMaxInstances = null;
|
|
2636
|
-
capacityExhausted = false;
|
|
2637
|
-
initialized = false;
|
|
2638
|
-
/**
|
|
2639
|
-
* Get a container UUID for the given sandbox ID.
|
|
2640
|
-
* - If this ID already has an assigned container that's still running, return it.
|
|
2641
|
-
* - Otherwise assign a warm container (or start a new one).
|
|
2642
|
-
*/
|
|
2643
|
-
async getContainer(sandboxId) {
|
|
2644
|
-
await this.init();
|
|
2645
|
-
const existing = this.assignments.get(sandboxId);
|
|
2646
|
-
if (existing) {
|
|
2647
|
-
if (await this.isContainerRunning(existing)) return existing;
|
|
2648
|
-
this.assignments.delete(sandboxId);
|
|
2649
|
-
await this.persist();
|
|
2650
|
-
}
|
|
2651
|
-
if (this.warmContainers.size > 0) {
|
|
2652
|
-
const containerUUID$1 = this.warmContainers.values().next().value;
|
|
2653
|
-
this.warmContainers.delete(containerUUID$1);
|
|
2654
|
-
this.assignments.set(sandboxId, containerUUID$1);
|
|
2655
|
-
await this.persist();
|
|
2656
|
-
return containerUUID$1;
|
|
2657
|
-
}
|
|
2658
|
-
if (this.remainingCapacity() <= 0) this.throwCapacityError();
|
|
2659
|
-
const containerUUID = await this.startContainer();
|
|
2660
|
-
if (containerUUID) {
|
|
2661
|
-
this.assignments.set(sandboxId, containerUUID);
|
|
2662
|
-
await this.persist();
|
|
2663
|
-
return containerUUID;
|
|
2664
|
-
}
|
|
2665
|
-
if (this.capacityExhausted) this.throwCapacityError();
|
|
2666
|
-
throw new Error("Failed to start container");
|
|
2667
|
-
}
|
|
2668
|
-
/**
|
|
2669
|
-
* Look up an existing container assignment without allocating.
|
|
2670
|
-
* Returns the container UUID if the sandbox ID has an active assignment, null otherwise.
|
|
2671
|
-
* Used by DELETE to avoid starting a container just to destroy it.
|
|
2672
|
-
*/
|
|
2673
|
-
async lookupContainer(sandboxId) {
|
|
2674
|
-
await this.init();
|
|
2675
|
-
const existing = this.assignments.get(sandboxId);
|
|
2676
|
-
if (existing) return existing;
|
|
2677
|
-
return null;
|
|
2678
|
-
}
|
|
2679
|
-
/**
|
|
2680
|
-
* Report that a container has stopped — removes it from tracking.
|
|
2681
|
-
*/
|
|
2682
|
-
async reportStopped(containerUUID) {
|
|
2683
|
-
await this.init();
|
|
2684
|
-
this.removeContainer(containerUUID);
|
|
2685
|
-
await this.persist();
|
|
2686
|
-
}
|
|
2687
|
-
/**
|
|
2688
|
-
* Get current pool statistics.
|
|
2689
|
-
*/
|
|
2690
|
-
async getStats() {
|
|
2691
|
-
await this.init();
|
|
2692
|
-
return {
|
|
2693
|
-
warm: this.warmContainers.size,
|
|
2694
|
-
assigned: this.assignments.size,
|
|
2695
|
-
total: this.warmContainers.size + this.assignments.size,
|
|
2696
|
-
config: this.config,
|
|
2697
|
-
maxInstances: this.knownMaxInstances
|
|
2698
|
-
};
|
|
2699
|
-
}
|
|
2700
|
-
/**
|
|
2701
|
-
* Update pool configuration. Idempotent — called on every request to keep
|
|
2702
|
-
* config in sync with wrangler vars across deploys.
|
|
2703
|
-
*/
|
|
2704
|
-
async configure(config) {
|
|
2705
|
-
await this.init();
|
|
2706
|
-
this.config = {
|
|
2707
|
-
...DEFAULT_CONFIG,
|
|
2708
|
-
...config
|
|
2709
|
-
};
|
|
2710
|
-
await this.ctx.storage.put("config", this.config);
|
|
2711
|
-
}
|
|
2712
|
-
/**
|
|
2713
|
-
* Shutdown all pre-warmed (unassigned) containers.
|
|
2714
|
-
* Does not affect containers that are assigned to sandbox IDs.
|
|
2715
|
-
*/
|
|
2716
|
-
async shutdownPrewarmed() {
|
|
2717
|
-
await this.init();
|
|
2718
|
-
for (const containerUUID of [...this.warmContainers]) try {
|
|
2719
|
-
await this.getSandboxStub(containerUUID).destroy();
|
|
2720
|
-
this.warmContainers.delete(containerUUID);
|
|
2721
|
-
} catch (error) {
|
|
2722
|
-
console.error({
|
|
2723
|
-
message: "Failed to destroy container",
|
|
2724
|
-
component: "warm-pool",
|
|
2725
|
-
containerUUID,
|
|
2726
|
-
error
|
|
2727
|
-
});
|
|
2728
|
-
}
|
|
2729
|
-
await this.persist();
|
|
2730
|
-
}
|
|
2731
|
-
async alarm() {
|
|
2732
|
-
await this.init();
|
|
2733
|
-
this.capacityExhausted = false;
|
|
2734
|
-
try {
|
|
2735
|
-
await this.checkContainerHealth();
|
|
2736
|
-
await this.adjustPool();
|
|
2737
|
-
await this.keepWarmContainersAlive();
|
|
2738
|
-
} catch (error) {
|
|
2739
|
-
console.error({
|
|
2740
|
-
message: "Alarm handler error",
|
|
2741
|
-
component: "warm-pool",
|
|
2742
|
-
error
|
|
2743
|
-
});
|
|
2744
|
-
}
|
|
2745
|
-
await this.ctx.storage.setAlarm(Date.now() + this.config.refreshInterval);
|
|
2746
|
-
}
|
|
2747
|
-
async init() {
|
|
2748
|
-
if (this.initialized) return;
|
|
2749
|
-
const storedWarm = await this.ctx.storage.get("warmContainers");
|
|
2750
|
-
if (storedWarm) this.warmContainers = new Set(storedWarm);
|
|
2751
|
-
const storedAssignments = await this.ctx.storage.get("assignments");
|
|
2752
|
-
if (storedAssignments) this.assignments = new Map(storedAssignments);
|
|
2753
|
-
const storedConfig = await this.ctx.storage.get("config");
|
|
2754
|
-
if (storedConfig) this.config = {
|
|
2755
|
-
...DEFAULT_CONFIG,
|
|
2756
|
-
...storedConfig
|
|
2757
|
-
};
|
|
2758
|
-
const storedMax = await this.ctx.storage.get("knownMaxInstances");
|
|
2759
|
-
if (storedMax !== void 0) this.knownMaxInstances = storedMax;
|
|
2760
|
-
this.initialized = true;
|
|
2761
|
-
await this.scheduleRefresh();
|
|
2762
|
-
}
|
|
2763
|
-
async persist() {
|
|
2764
|
-
await this.ctx.storage.put("warmContainers", this.warmContainers);
|
|
2765
|
-
await this.ctx.storage.put("assignments", this.assignments);
|
|
2766
|
-
if (this.knownMaxInstances !== null) await this.ctx.storage.put("knownMaxInstances", this.knownMaxInstances);
|
|
2767
|
-
else await this.ctx.storage.delete("knownMaxInstances");
|
|
2768
|
-
}
|
|
2769
|
-
async scheduleRefresh() {
|
|
2770
|
-
if (!await this.ctx.storage.getAlarm()) await this.ctx.storage.setAlarm(Date.now() + this.config.refreshInterval);
|
|
2771
|
-
}
|
|
2772
|
-
async startContainer() {
|
|
2773
|
-
const containerUUID = crypto.randomUUID();
|
|
2774
|
-
this.startingContainers.add(containerUUID);
|
|
2775
|
-
try {
|
|
2776
|
-
await this.getSandboxStub(containerUUID).startAndWaitForPorts();
|
|
2777
|
-
console.info({
|
|
2778
|
-
message: "Container started",
|
|
2779
|
-
component: "warm-pool",
|
|
2780
|
-
containerUUID
|
|
2781
|
-
});
|
|
2782
|
-
return containerUUID;
|
|
2783
|
-
} catch (error) {
|
|
2784
|
-
if (this.isMaxInstancesError(error)) await this.recordCapacityLimit();
|
|
2785
|
-
else console.error({
|
|
2786
|
-
message: "Failed to start container",
|
|
2787
|
-
component: "warm-pool",
|
|
2788
|
-
containerUUID,
|
|
2789
|
-
error
|
|
2790
|
-
});
|
|
2791
|
-
return null;
|
|
2792
|
-
} finally {
|
|
2793
|
-
this.startingContainers.delete(containerUUID);
|
|
2794
|
-
}
|
|
2795
|
-
}
|
|
2796
|
-
async isContainerRunning(containerUUID) {
|
|
2797
|
-
if (this.startingContainers.has(containerUUID)) return true;
|
|
2798
|
-
try {
|
|
2799
|
-
const state = await this.getSandboxStub(containerUUID).getState();
|
|
2800
|
-
return state.status === "running" || state.status === "healthy";
|
|
2801
|
-
} catch (error) {
|
|
2802
|
-
console.warn({
|
|
2803
|
-
message: "Failed to check container status, assuming stopped",
|
|
2804
|
-
component: "warm-pool",
|
|
2805
|
-
containerUUID,
|
|
2806
|
-
error
|
|
2807
|
-
});
|
|
2808
|
-
return false;
|
|
2809
|
-
}
|
|
2810
|
-
}
|
|
2811
|
-
async checkContainerHealth() {
|
|
2812
|
-
const allUUIDs = [...this.warmContainers, ...this.assignments.values()];
|
|
2813
|
-
let anyRemoved = false;
|
|
2814
|
-
for (const uuid of allUUIDs) if (!await this.isContainerRunning(uuid)) {
|
|
2815
|
-
console.info({
|
|
2816
|
-
message: "Container not running, removing from pool",
|
|
2817
|
-
component: "warm-pool",
|
|
2818
|
-
containerUUID: uuid
|
|
2819
|
-
});
|
|
2820
|
-
if (this.removeContainer(uuid)) anyRemoved = true;
|
|
2821
|
-
}
|
|
2822
|
-
if (anyRemoved) await this.persist();
|
|
2823
|
-
}
|
|
2824
|
-
/**
|
|
2825
|
-
* Renew activity timeout on all warm containers to prevent them from sleeping.
|
|
2826
|
-
*/
|
|
2827
|
-
async keepWarmContainersAlive() {
|
|
2828
|
-
for (const containerUUID of this.warmContainers) try {
|
|
2829
|
-
this.getSandboxStub(containerUUID).renewActivityTimeout();
|
|
2830
|
-
} catch (error) {
|
|
2831
|
-
console.error({
|
|
2832
|
-
message: "Failed to renew activity timeout",
|
|
2833
|
-
component: "warm-pool",
|
|
2834
|
-
containerUUID,
|
|
2835
|
-
error
|
|
2836
|
-
});
|
|
2837
|
-
}
|
|
2838
|
-
}
|
|
2839
|
-
/**
|
|
2840
|
-
* Scale the warm pool towards warmTarget, respecting inferred max_instances.
|
|
2841
|
-
*/
|
|
2842
|
-
async adjustPool() {
|
|
2843
|
-
let diff = this.config.warmTarget - this.warmContainers.size;
|
|
2844
|
-
if (diff > 0) {
|
|
2845
|
-
if (this.remainingCapacity() === 0 && this.knownMaxInstances !== null) {
|
|
2846
|
-
console.info({
|
|
2847
|
-
message: "Pool at inferred limit, probing for capacity changes",
|
|
2848
|
-
component: "warm-pool",
|
|
2849
|
-
knownMaxInstances: this.knownMaxInstances
|
|
2850
|
-
});
|
|
2851
|
-
const probeUUID = await this.startContainer();
|
|
2852
|
-
if (probeUUID) {
|
|
2853
|
-
console.info({
|
|
2854
|
-
message: "Probe succeeded, clearing cached limit",
|
|
2855
|
-
component: "warm-pool"
|
|
2856
|
-
});
|
|
2857
|
-
this.knownMaxInstances = null;
|
|
2858
|
-
this.warmContainers.add(probeUUID);
|
|
2859
|
-
diff--;
|
|
2860
|
-
await this.persist();
|
|
2861
|
-
} else {
|
|
2862
|
-
await this.persist();
|
|
2863
|
-
return;
|
|
2864
|
-
}
|
|
2865
|
-
}
|
|
2866
|
-
const toStart = Math.min(diff, this.remainingCapacity());
|
|
2867
|
-
if (toStart <= 0) {
|
|
2868
|
-
console.log({
|
|
2869
|
-
message: "Cannot scale up pool",
|
|
2870
|
-
component: "warm-pool",
|
|
2871
|
-
needed: diff,
|
|
2872
|
-
available: this.remainingCapacity(),
|
|
2873
|
-
warm: this.warmContainers.size,
|
|
2874
|
-
assigned: this.assignments.size,
|
|
2875
|
-
knownMaxInstances: this.knownMaxInstances ?? "unknown"
|
|
2876
|
-
});
|
|
2877
|
-
return;
|
|
2878
|
-
}
|
|
2879
|
-
console.info({
|
|
2880
|
-
message: "Scaling up pool",
|
|
2881
|
-
component: "warm-pool",
|
|
2882
|
-
starting: toStart,
|
|
2883
|
-
needed: diff,
|
|
2884
|
-
capacity: this.remainingCapacity()
|
|
2885
|
-
});
|
|
2886
|
-
for (let i = 0; i < toStart; i++) {
|
|
2887
|
-
if (this.capacityExhausted) {
|
|
2888
|
-
console.log({
|
|
2889
|
-
message: "Capacity exhausted mid-loop, stopping further starts",
|
|
2890
|
-
component: "warm-pool"
|
|
2891
|
-
});
|
|
2892
|
-
break;
|
|
2893
|
-
}
|
|
2894
|
-
const uuid = await this.startContainer();
|
|
2895
|
-
if (uuid) this.warmContainers.add(uuid);
|
|
2896
|
-
}
|
|
2897
|
-
await this.persist();
|
|
2898
|
-
} else if (diff < 0) {
|
|
2899
|
-
const excess = -diff;
|
|
2900
|
-
console.info({
|
|
2901
|
-
message: "Scaling down pool",
|
|
2902
|
-
component: "warm-pool",
|
|
2903
|
-
destroying: excess
|
|
2904
|
-
});
|
|
2905
|
-
const toDestroy = [...this.warmContainers].slice(0, excess);
|
|
2906
|
-
const destroyed = [];
|
|
2907
|
-
for (const uuid of toDestroy) try {
|
|
2908
|
-
await this.getSandboxStub(uuid).destroy();
|
|
2909
|
-
destroyed.push(uuid);
|
|
2910
|
-
} catch (error) {
|
|
2911
|
-
console.error({
|
|
2912
|
-
message: "Failed to destroy container",
|
|
2913
|
-
component: "warm-pool",
|
|
2914
|
-
containerUUID: uuid,
|
|
2915
|
-
error
|
|
2916
|
-
});
|
|
2917
|
-
}
|
|
2918
|
-
for (const uuid of destroyed) this.warmContainers.delete(uuid);
|
|
2919
|
-
await this.persist();
|
|
2920
|
-
}
|
|
2921
|
-
}
|
|
2922
|
-
removeContainer(containerUUID) {
|
|
2923
|
-
let removed = false;
|
|
2924
|
-
if (this.warmContainers.delete(containerUUID)) removed = true;
|
|
2925
|
-
for (const [sandboxId, uuid] of this.assignments) if (uuid === containerUUID) {
|
|
2926
|
-
this.assignments.delete(sandboxId);
|
|
2927
|
-
removed = true;
|
|
2928
|
-
break;
|
|
2929
|
-
}
|
|
2930
|
-
return removed;
|
|
2931
|
-
}
|
|
2932
|
-
remainingCapacity() {
|
|
2933
|
-
if (this.knownMaxInstances === null) return Infinity;
|
|
2934
|
-
return Math.max(0, this.knownMaxInstances - (this.warmContainers.size + this.assignments.size));
|
|
2935
|
-
}
|
|
2936
|
-
isMaxInstancesError(error) {
|
|
2937
|
-
return (error instanceof Error ? error.message : String(error)).includes("Maximum number of running container instances exceeded");
|
|
2938
|
-
}
|
|
2939
|
-
async recordCapacityLimit() {
|
|
2940
|
-
const currentTotal = this.warmContainers.size + this.assignments.size;
|
|
2941
|
-
this.knownMaxInstances = currentTotal;
|
|
2942
|
-
this.capacityExhausted = true;
|
|
2943
|
-
console.warn({
|
|
2944
|
-
message: "Hit max_instances limit",
|
|
2945
|
-
component: "warm-pool",
|
|
2946
|
-
inferredCeiling: currentTotal,
|
|
2947
|
-
warm: this.warmContainers.size,
|
|
2948
|
-
assigned: this.assignments.size
|
|
2949
|
-
});
|
|
2950
|
-
await this.ctx.storage.put("knownMaxInstances", this.knownMaxInstances);
|
|
2951
|
-
}
|
|
2952
|
-
throwCapacityError() {
|
|
2953
|
-
const total = this.warmContainers.size + this.assignments.size;
|
|
2954
|
-
throw new Error(`Cannot start container: instance limit reached (${total}/${this.knownMaxInstances}). All container slots are in use. Wait for existing containers to stop.`);
|
|
2955
|
-
}
|
|
2956
|
-
getSandboxStub(containerUUID) {
|
|
2957
|
-
const id = this.env.Sandbox.idFromName(containerUUID);
|
|
2958
|
-
return this.env.Sandbox.get(id);
|
|
2959
|
-
}
|
|
2960
|
-
};
|
|
2961
|
-
|
|
2962
|
-
//#endregion
|
|
2963
|
-
//#region src/bridge/index.ts
|
|
2964
|
-
/**
|
|
2965
|
-
* @cloudflare/sandbox/bridge — Bridge factory for Cloudflare Sandbox Workers.
|
|
2966
|
-
*
|
|
2967
|
-
* Usage:
|
|
2968
|
-
* ```ts
|
|
2969
|
-
* import { bridge } from "@cloudflare/sandbox/bridge";
|
|
2970
|
-
* export { Sandbox } from "@cloudflare/sandbox";
|
|
2971
|
-
* export { WarmPool } from "@cloudflare/sandbox/bridge";
|
|
2972
|
-
*
|
|
2973
|
-
* export default bridge({
|
|
2974
|
-
* async fetch(request, env, ctx) {
|
|
2975
|
-
* return new Response("OK");
|
|
2976
|
-
* },
|
|
2977
|
-
* async scheduled(controller, env, ctx) {
|
|
2978
|
-
* // custom scheduled logic
|
|
2979
|
-
* }
|
|
2980
|
-
* });
|
|
2981
|
-
* ```
|
|
2982
|
-
*/
|
|
2983
|
-
/**
|
|
2984
|
-
* Log an error if the required Durable Object bindings are missing from the
|
|
2985
|
-
* module-level environment. The `cloudflare:workers` env exposes binding stubs
|
|
2986
|
-
* at module evaluation time, so a missing key means the wrangler.jsonc
|
|
2987
|
-
* configuration is wrong. The health endpoint performs the same validation at
|
|
2988
|
-
* request time and returns a 503.
|
|
2989
|
-
*/
|
|
2990
|
-
function checkBindings(env$1, options) {
|
|
2991
|
-
const { sandboxBinding, warmPoolBinding } = options;
|
|
2992
|
-
const missing = [];
|
|
2993
|
-
if (!env$1[sandboxBinding]) missing.push(sandboxBinding);
|
|
2994
|
-
if (!env$1[warmPoolBinding]) missing.push(warmPoolBinding);
|
|
2995
|
-
if (missing.length > 0) for (const binding of missing) console.error({
|
|
2996
|
-
message: `Missing required binding "${binding}"`,
|
|
2997
|
-
component: "bridge",
|
|
2998
|
-
binding,
|
|
2999
|
-
hint: `Ensure your wrangler.jsonc has a Durable Object binding named "${binding}".`
|
|
3000
|
-
});
|
|
3001
|
-
if (!env$1.SANDBOX_API_KEY) console.warn({
|
|
3002
|
-
message: "SANDBOX_API_KEY is not set — auth is disabled",
|
|
3003
|
-
component: "bridge",
|
|
3004
|
-
hint: "Set via `wrangler secret put SANDBOX_API_KEY`."
|
|
3005
|
-
});
|
|
3006
|
-
}
|
|
3007
|
-
/**
|
|
3008
|
-
* Create a Worker export that wraps user handlers with bridge functionality.
|
|
3009
|
-
*
|
|
3010
|
-
* The bridge:
|
|
3011
|
-
* 1. Checks that required Durable Object bindings exist (logs errors if missing).
|
|
3012
|
-
* 2. Wraps `fetch()` to serve bridge API routes first, then falls through to the user handler.
|
|
3013
|
-
* 3. Wraps `scheduled()` to prime the warm pool, then calls the user handler.
|
|
3014
|
-
* 4. Passes through all other properties unchanged.
|
|
3015
|
-
*
|
|
3016
|
-
* @param worker - The user's worker handlers (fetch, scheduled, and any others).
|
|
3017
|
-
* @param config - Optional configuration for binding names and route paths.
|
|
3018
|
-
*/
|
|
3019
|
-
function bridge(worker, config) {
|
|
3020
|
-
const sandboxBinding = config?.bindings?.sandbox ?? "Sandbox";
|
|
3021
|
-
const warmPoolBinding = config?.bindings?.warmPool ?? "WarmPool";
|
|
3022
|
-
const apiPrefix = config?.apiRoutePrefix ?? "/v1";
|
|
3023
|
-
const healthPath = config?.healthRoute ?? "/health";
|
|
3024
|
-
checkBindings(env, {
|
|
3025
|
-
sandboxBinding,
|
|
3026
|
-
warmPoolBinding
|
|
3027
|
-
});
|
|
3028
|
-
const app = createBridgeApp({
|
|
3029
|
-
sandboxBinding,
|
|
3030
|
-
warmPoolBinding,
|
|
3031
|
-
apiPrefix,
|
|
3032
|
-
healthPath
|
|
3033
|
-
});
|
|
3034
|
-
const passThrough = {};
|
|
3035
|
-
for (const key of Object.keys(worker)) if (key !== "fetch" && key !== "scheduled") passThrough[key] = worker[key];
|
|
3036
|
-
return {
|
|
3037
|
-
...passThrough,
|
|
3038
|
-
async fetch(request, env$1, ctx) {
|
|
3039
|
-
const url = new URL(request.url);
|
|
3040
|
-
if (url.pathname.startsWith(`${apiPrefix}/`) || url.pathname === apiPrefix || url.pathname === healthPath) return app.fetch(request, env$1, ctx);
|
|
3041
|
-
if (worker.fetch) return worker.fetch(request, env$1, ctx);
|
|
3042
|
-
return new Response("Not Found", { status: 404 });
|
|
3043
|
-
},
|
|
3044
|
-
async scheduled(controller, env$1, ctx) {
|
|
3045
|
-
await primePool(env$1, warmPoolBinding);
|
|
3046
|
-
if (worker.scheduled) await worker.scheduled(controller, env$1, ctx);
|
|
3047
|
-
}
|
|
3048
|
-
};
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
//#endregion
|
|
3052
|
-
export { WarmPool, bridge, resolveWorkspacePath, shellQuote };
|
|
3053
|
-
//# sourceMappingURL=index.js.map
|