@cloudflare/sandbox 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/index.js +187 -3
- package/dist/bridge/index.js.map +1 -1
- package/dist/{contexts-D_shbnJs.d.ts → contexts-XHAo64dB.d.ts} +1 -5
- package/dist/contexts-XHAo64dB.d.ts.map +1 -0
- package/dist/{errors-8Hvune8K.js → errors-COsTRno_.js} +3 -3
- package/dist/errors-COsTRno_.js.map +1 -0
- package/dist/index.d.ts +3 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -4
- package/dist/index.js.map +1 -0
- package/dist/openai/index.d.ts +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.js +1 -1
- package/dist/{sandbox-CwcSm_60.d.ts → sandbox-B9LOT0cg.d.ts} +138 -90
- package/dist/sandbox-B9LOT0cg.d.ts.map +1 -0
- package/dist/{sandbox-B-MUmsli.js → sandbox-DQxTkLyY.js} +1484 -323
- package/dist/sandbox-DQxTkLyY.js.map +1 -0
- package/package.json +2 -2
- package/dist/contexts-D_shbnJs.d.ts.map +0 -1
- package/dist/errors-8Hvune8K.js.map +0 -1
- package/dist/sandbox-B-MUmsli.js.map +0 -1
- package/dist/sandbox-CwcSm_60.d.ts.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { _ as GitLogger, b as getEnvString, c as parseSSEFrames, d as createNoOpLogger, f as TraceContext, g as DEFAULT_GIT_CLONE_TIMEOUT_MS, h as ResultImpl, i as isWSStreamChunk, l as shellEscape, m as Execution, n as isWSError, p as logCanonicalEvent, r as isWSResponse, t as generateRequestId, u as createLogger, v as extractRepoName, x as partitionEnvVars, y as filterEnvVars } from "./dist-B_eXrP83.js";
|
|
2
|
-
import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-
|
|
2
|
+
import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-COsTRno_.js";
|
|
3
3
|
import { Container, getContainer, switchPort } from "@cloudflare/containers";
|
|
4
4
|
import { AwsClient } from "aws4fetch";
|
|
5
5
|
import { RpcSession, RpcTarget } from "capnweb";
|
|
@@ -231,7 +231,7 @@ var SessionTerminatedError = class extends SandboxError {
|
|
|
231
231
|
}
|
|
232
232
|
};
|
|
233
233
|
/**
|
|
234
|
-
*
|
|
234
|
+
* Compatibility error for legacy port exposure registry responses.
|
|
235
235
|
*/
|
|
236
236
|
var PortAlreadyExposedError = class extends SandboxError {
|
|
237
237
|
constructor(errorResponse) {
|
|
@@ -246,7 +246,7 @@ var PortAlreadyExposedError = class extends SandboxError {
|
|
|
246
246
|
}
|
|
247
247
|
};
|
|
248
248
|
/**
|
|
249
|
-
*
|
|
249
|
+
* Compatibility error for legacy port exposure registry responses.
|
|
250
250
|
*/
|
|
251
251
|
var PortNotExposedError = class extends SandboxError {
|
|
252
252
|
constructor(errorResponse) {
|
|
@@ -2365,40 +2365,9 @@ var InterpreterClient = class extends BaseHttpClient {
|
|
|
2365
2365
|
//#endregion
|
|
2366
2366
|
//#region src/clients/port-client.ts
|
|
2367
2367
|
/**
|
|
2368
|
-
* Client for port
|
|
2368
|
+
* Client for port readiness operations.
|
|
2369
2369
|
*/
|
|
2370
2370
|
var PortClient = class extends BaseHttpClient {
|
|
2371
|
-
/**
|
|
2372
|
-
* Expose a port and get a preview URL
|
|
2373
|
-
* @param port - Port number to expose
|
|
2374
|
-
* @param sessionId - The session ID for this operation
|
|
2375
|
-
* @param name - Optional name for the port
|
|
2376
|
-
*/
|
|
2377
|
-
async exposePort(port, sessionId, name) {
|
|
2378
|
-
const data = {
|
|
2379
|
-
port,
|
|
2380
|
-
sessionId,
|
|
2381
|
-
name
|
|
2382
|
-
};
|
|
2383
|
-
return await this.post("/api/expose-port", data);
|
|
2384
|
-
}
|
|
2385
|
-
/**
|
|
2386
|
-
* Unexpose a port and remove its preview URL
|
|
2387
|
-
* @param port - Port number to unexpose
|
|
2388
|
-
* @param sessionId - The session ID for this operation
|
|
2389
|
-
*/
|
|
2390
|
-
async unexposePort(port, sessionId) {
|
|
2391
|
-
const url = `/api/exposed-ports/${port}?session=${encodeURIComponent(sessionId)}`;
|
|
2392
|
-
return await this.delete(url);
|
|
2393
|
-
}
|
|
2394
|
-
/**
|
|
2395
|
-
* Get all currently exposed ports
|
|
2396
|
-
* @param sessionId - The session ID for this operation
|
|
2397
|
-
*/
|
|
2398
|
-
async getExposedPorts(sessionId) {
|
|
2399
|
-
const url = `/api/exposed-ports?session=${encodeURIComponent(sessionId)}`;
|
|
2400
|
-
return await this.get(url);
|
|
2401
|
-
}
|
|
2402
2371
|
/**
|
|
2403
2372
|
* Watch a port for readiness via SSE stream
|
|
2404
2373
|
* @param request - Port watch configuration
|
|
@@ -3390,6 +3359,89 @@ var ContainerControlClient = class {
|
|
|
3390
3359
|
}
|
|
3391
3360
|
};
|
|
3392
3361
|
|
|
3362
|
+
//#endregion
|
|
3363
|
+
//#region src/current-runtime-identity.ts
|
|
3364
|
+
const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
|
|
3365
|
+
var RuntimeIdentityInactiveError = class extends Error {
|
|
3366
|
+
constructor() {
|
|
3367
|
+
super("Runtime identity is no longer active");
|
|
3368
|
+
this.name = "RuntimeIdentityInactiveError";
|
|
3369
|
+
}
|
|
3370
|
+
};
|
|
3371
|
+
var RuntimeIdentity = class {
|
|
3372
|
+
id;
|
|
3373
|
+
constructor(record) {
|
|
3374
|
+
this.id = record.id;
|
|
3375
|
+
}
|
|
3376
|
+
owns(record) {
|
|
3377
|
+
return record.runtimeIdentityID === this.id;
|
|
3378
|
+
}
|
|
3379
|
+
scope(value) {
|
|
3380
|
+
return {
|
|
3381
|
+
...value,
|
|
3382
|
+
runtimeIdentityID: this.id
|
|
3383
|
+
};
|
|
3384
|
+
}
|
|
3385
|
+
};
|
|
3386
|
+
var CurrentRuntimeIdentity = class {
|
|
3387
|
+
/**
|
|
3388
|
+
* Runtime identity is stored in Durable Object storage so a reconstructed DO
|
|
3389
|
+
* can still recognize the live container runtime it owns. In-memory state is
|
|
3390
|
+
* only a cache and cannot define runtime-scoped correctness.
|
|
3391
|
+
*/
|
|
3392
|
+
constructor(storage, getContainerState, isContainerRunning) {
|
|
3393
|
+
this.storage = storage;
|
|
3394
|
+
this.getContainerState = getContainerState;
|
|
3395
|
+
this.isContainerRunning = isContainerRunning;
|
|
3396
|
+
}
|
|
3397
|
+
async get() {
|
|
3398
|
+
const status = await this.getStatus();
|
|
3399
|
+
return status.status === "active" ? status.runtime : null;
|
|
3400
|
+
}
|
|
3401
|
+
async getStatus() {
|
|
3402
|
+
const state = await this.getContainerState();
|
|
3403
|
+
if (state.status !== "healthy") return {
|
|
3404
|
+
status: "inactive",
|
|
3405
|
+
reason: "runtime-not-healthy",
|
|
3406
|
+
containerStatus: state.status
|
|
3407
|
+
};
|
|
3408
|
+
if (!this.isContainerRunning()) return {
|
|
3409
|
+
status: "inactive",
|
|
3410
|
+
reason: "runtime-not-running",
|
|
3411
|
+
containerStatus: state.status
|
|
3412
|
+
};
|
|
3413
|
+
const runtime = await this.getStored();
|
|
3414
|
+
if (!runtime) return {
|
|
3415
|
+
status: "inactive",
|
|
3416
|
+
reason: "missing-runtime-id",
|
|
3417
|
+
containerStatus: state.status
|
|
3418
|
+
};
|
|
3419
|
+
return {
|
|
3420
|
+
status: "active",
|
|
3421
|
+
runtime,
|
|
3422
|
+
containerStatus: state.status
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
async getStored(storage = this.storage) {
|
|
3426
|
+
const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
|
|
3427
|
+
return record ? new RuntimeIdentity(record) : null;
|
|
3428
|
+
}
|
|
3429
|
+
async markStarted() {
|
|
3430
|
+
const record = { id: crypto.randomUUID() };
|
|
3431
|
+
await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
|
|
3432
|
+
return new RuntimeIdentity(record);
|
|
3433
|
+
}
|
|
3434
|
+
async clear() {
|
|
3435
|
+
await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
|
|
3436
|
+
}
|
|
3437
|
+
async isActive(runtime) {
|
|
3438
|
+
return (await this.get())?.id === runtime.id;
|
|
3439
|
+
}
|
|
3440
|
+
async assertActive(runtime) {
|
|
3441
|
+
if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
|
|
3393
3445
|
//#endregion
|
|
3394
3446
|
//#region src/file-stream.ts
|
|
3395
3447
|
/**
|
|
@@ -3582,6 +3634,32 @@ function validateLanguage(language) {
|
|
|
3582
3634
|
const normalized = language.toLowerCase();
|
|
3583
3635
|
if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
|
|
3584
3636
|
}
|
|
3637
|
+
/**
|
|
3638
|
+
* Validates a single DNS label for use as a Cloudflare Tunnel hostname.
|
|
3639
|
+
*
|
|
3640
|
+
* Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
|
|
3641
|
+
* input client-side before any network call. Whether the chosen label is
|
|
3642
|
+
* actually available under the configured zone is left to the Cloudflare
|
|
3643
|
+
* API (returned as a typed error).
|
|
3644
|
+
*
|
|
3645
|
+
* Rules:
|
|
3646
|
+
* - 1–63 characters
|
|
3647
|
+
* - Lowercase letters, digits, and internal hyphens only
|
|
3648
|
+
* - No leading or trailing hyphen
|
|
3649
|
+
* - No dots — multi-label hostnames need a delegated subdomain zone or
|
|
3650
|
+
* Advanced Certificate Manager, which are out of scope for this
|
|
3651
|
+
* feature. Universal SSL only covers `<label>.<zone>`.
|
|
3652
|
+
*
|
|
3653
|
+
* Throws `SandboxSecurityError` on any violation. Designed to be called
|
|
3654
|
+
* before any other tunnel work so callers see a fast, deterministic
|
|
3655
|
+
* failure.
|
|
3656
|
+
*/
|
|
3657
|
+
const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
3658
|
+
function validateTunnelName(name) {
|
|
3659
|
+
if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
|
|
3660
|
+
if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
|
|
3661
|
+
if (!TUNNEL_NAME_REGEX.test(name)) throw new SandboxSecurityError(`Tunnel name '${name}' is not a valid DNS label. Use lowercase letters, digits, and internal hyphens only (no dots, no leading/trailing hyphens).`, "INVALID_TUNNEL_NAME_FORMAT");
|
|
3662
|
+
}
|
|
3585
3663
|
|
|
3586
3664
|
//#endregion
|
|
3587
3665
|
//#region src/interpreter.ts
|
|
@@ -4237,118 +4315,138 @@ function base64ToUint8Array(base64) {
|
|
|
4237
4315
|
}
|
|
4238
4316
|
|
|
4239
4317
|
//#endregion
|
|
4240
|
-
//#region src/
|
|
4241
|
-
async function
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
const params = new URLSearchParams({ sessionId });
|
|
4245
|
-
if (options?.cols) params.set("cols", String(options.cols));
|
|
4246
|
-
if (options?.rows) params.set("rows", String(options.rows));
|
|
4247
|
-
if (options?.shell) params.set("shell", options.shell);
|
|
4248
|
-
const ptyUrl = `http://localhost/ws/pty?${params}`;
|
|
4249
|
-
const ptyRequest = new Request(ptyUrl, request);
|
|
4250
|
-
return stub.fetch(switchPort(ptyRequest, 3e3));
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
|
-
//#endregion
|
|
4254
|
-
//#region src/request-handler.ts
|
|
4255
|
-
async function proxyToSandbox(request, env$1) {
|
|
4256
|
-
const logger = createLogger({
|
|
4257
|
-
component: "sandbox-do",
|
|
4258
|
-
traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
|
|
4259
|
-
operation: "proxy"
|
|
4260
|
-
});
|
|
4318
|
+
//#region src/preview-forwarding.ts
|
|
4319
|
+
async function forwardPreviewRequest(tcpPort, request, lifecycle) {
|
|
4320
|
+
const containerURL = request.url.replace("https:", "http:");
|
|
4321
|
+
const settleForward = lifecycle.beginForward();
|
|
4261
4322
|
try {
|
|
4262
|
-
const
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
if (
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
url: request.url,
|
|
4275
|
-
method: request.method,
|
|
4276
|
-
userAgent: request.headers.get("User-Agent") || "unknown"
|
|
4277
|
-
});
|
|
4278
|
-
return new Response(JSON.stringify({
|
|
4279
|
-
error: `Access denied: Invalid token or port not exposed`,
|
|
4280
|
-
code: "INVALID_TOKEN"
|
|
4281
|
-
}), {
|
|
4282
|
-
status: 404,
|
|
4283
|
-
headers: { "Content-Type": "application/json" }
|
|
4284
|
-
});
|
|
4285
|
-
}
|
|
4323
|
+
const response = await tcpPort.fetch(containerURL, request);
|
|
4324
|
+
if (response.webSocket !== null) return {
|
|
4325
|
+
status: "response",
|
|
4326
|
+
response: bridgePreviewWebSocket(response, lifecycle, settleForward)
|
|
4327
|
+
};
|
|
4328
|
+
if (response.body !== null) {
|
|
4329
|
+
const { readable, writable } = new TransformStream();
|
|
4330
|
+
response.body.pipeTo(writable).finally(settleForward).catch(() => {});
|
|
4331
|
+
return {
|
|
4332
|
+
status: "response",
|
|
4333
|
+
response: new Response(readable, response)
|
|
4334
|
+
};
|
|
4286
4335
|
}
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
const headers = {
|
|
4292
|
-
"X-Original-URL": request.url,
|
|
4293
|
-
"X-Forwarded-Host": url.hostname,
|
|
4294
|
-
"X-Forwarded-Proto": url.protocol.replace(":", ""),
|
|
4295
|
-
"X-Sandbox-Name": sandboxId
|
|
4336
|
+
settleForward();
|
|
4337
|
+
return {
|
|
4338
|
+
status: "response",
|
|
4339
|
+
response
|
|
4296
4340
|
};
|
|
4297
|
-
request.headers.forEach((value, key) => {
|
|
4298
|
-
headers[key] = value;
|
|
4299
|
-
});
|
|
4300
|
-
const proxyRequest = new Request(proxyUrl, {
|
|
4301
|
-
method: request.method,
|
|
4302
|
-
headers,
|
|
4303
|
-
body: request.body,
|
|
4304
|
-
duplex: "half",
|
|
4305
|
-
redirect: "manual"
|
|
4306
|
-
});
|
|
4307
|
-
return await sandbox.containerFetch(proxyRequest, port);
|
|
4308
4341
|
} catch (error) {
|
|
4309
|
-
|
|
4310
|
-
|
|
4342
|
+
settleForward();
|
|
4343
|
+
if (error instanceof Error && error.message.includes("Network connection lost.")) return { status: "network-lost" };
|
|
4344
|
+
throw error;
|
|
4311
4345
|
}
|
|
4312
4346
|
}
|
|
4313
|
-
function
|
|
4314
|
-
const
|
|
4315
|
-
if (
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
const firstHyphen = subdomain.indexOf("-");
|
|
4319
|
-
if (firstHyphen === -1) return null;
|
|
4320
|
-
const portStr = subdomain.slice(0, firstHyphen);
|
|
4321
|
-
if (!/^\d{4,5}$/.test(portStr)) return null;
|
|
4322
|
-
const port = parseInt(portStr, 10);
|
|
4323
|
-
if (!validatePort(port)) return null;
|
|
4324
|
-
const rest = subdomain.slice(firstHyphen + 1);
|
|
4325
|
-
const lastHyphen = rest.lastIndexOf("-");
|
|
4326
|
-
if (lastHyphen === -1) return null;
|
|
4327
|
-
const sandboxId = rest.slice(0, lastHyphen);
|
|
4328
|
-
const token = rest.slice(lastHyphen + 1);
|
|
4329
|
-
if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) return null;
|
|
4330
|
-
if (sandboxId.length === 0 || sandboxId.length > 63) return null;
|
|
4331
|
-
let sanitizedSandboxId;
|
|
4332
|
-
try {
|
|
4333
|
-
sanitizedSandboxId = sanitizeSandboxId(sandboxId);
|
|
4334
|
-
} catch {
|
|
4335
|
-
return null;
|
|
4347
|
+
function bridgePreviewWebSocket(response, lifecycle, settleForward) {
|
|
4348
|
+
const containerWebSocket = response.webSocket;
|
|
4349
|
+
if (containerWebSocket === null) {
|
|
4350
|
+
settleForward();
|
|
4351
|
+
return response;
|
|
4336
4352
|
}
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4353
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
4354
|
+
let settled = false;
|
|
4355
|
+
const settle = () => {
|
|
4356
|
+
if (!settled) {
|
|
4357
|
+
settled = true;
|
|
4358
|
+
settleForward();
|
|
4359
|
+
}
|
|
4342
4360
|
};
|
|
4361
|
+
containerWebSocket.accept();
|
|
4362
|
+
server.accept();
|
|
4363
|
+
server.addEventListener("message", async (event) => {
|
|
4364
|
+
lifecycle.renewActivity();
|
|
4365
|
+
try {
|
|
4366
|
+
const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
|
|
4367
|
+
containerWebSocket.send(data);
|
|
4368
|
+
} catch {
|
|
4369
|
+
server.close(1011, "Failed to forward message to container");
|
|
4370
|
+
}
|
|
4371
|
+
});
|
|
4372
|
+
containerWebSocket.addEventListener("message", async (event) => {
|
|
4373
|
+
lifecycle.renewActivity();
|
|
4374
|
+
try {
|
|
4375
|
+
const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
|
|
4376
|
+
server.send(data);
|
|
4377
|
+
} catch {
|
|
4378
|
+
containerWebSocket.close(1011, "Failed to forward message to client");
|
|
4379
|
+
}
|
|
4380
|
+
});
|
|
4381
|
+
server.addEventListener("close", (event) => {
|
|
4382
|
+
settle();
|
|
4383
|
+
const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
|
|
4384
|
+
containerWebSocket.close(code, event.reason);
|
|
4385
|
+
});
|
|
4386
|
+
containerWebSocket.addEventListener("close", (event) => {
|
|
4387
|
+
settle();
|
|
4388
|
+
const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
|
|
4389
|
+
server.close(code, event.reason);
|
|
4390
|
+
});
|
|
4391
|
+
server.addEventListener("error", () => {
|
|
4392
|
+
settle();
|
|
4393
|
+
containerWebSocket.close(1011, "Client WebSocket error");
|
|
4394
|
+
});
|
|
4395
|
+
containerWebSocket.addEventListener("error", () => {
|
|
4396
|
+
settle();
|
|
4397
|
+
server.close(1011, "Container WebSocket error");
|
|
4398
|
+
});
|
|
4399
|
+
return new Response(null, {
|
|
4400
|
+
status: response.status,
|
|
4401
|
+
webSocket: client,
|
|
4402
|
+
headers: response.headers
|
|
4403
|
+
});
|
|
4343
4404
|
}
|
|
4405
|
+
|
|
4406
|
+
//#endregion
|
|
4407
|
+
//#region src/preview-proxy-protocol.ts
|
|
4408
|
+
/** @internal */
|
|
4409
|
+
const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
|
|
4410
|
+
/** @internal */
|
|
4411
|
+
const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
|
|
4412
|
+
/** @internal */
|
|
4413
|
+
const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
|
|
4414
|
+
/** @internal */
|
|
4415
|
+
const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
|
|
4416
|
+
/** @internal */
|
|
4417
|
+
const PREVIEW_PROXY_HEADERS = [
|
|
4418
|
+
PREVIEW_PROXY_HEADER,
|
|
4419
|
+
PREVIEW_PROXY_PORT_HEADER,
|
|
4420
|
+
PREVIEW_PROXY_TOKEN_HEADER,
|
|
4421
|
+
PREVIEW_PROXY_SANDBOX_ID_HEADER
|
|
4422
|
+
];
|
|
4423
|
+
|
|
4424
|
+
//#endregion
|
|
4425
|
+
//#region src/preview-url.ts
|
|
4344
4426
|
function isLocalhostPattern(hostname) {
|
|
4345
|
-
if (hostname.startsWith("["))
|
|
4346
|
-
|
|
4427
|
+
if (hostname.startsWith("[")) {
|
|
4428
|
+
if (hostname.includes("]:")) return hostname.substring(0, hostname.indexOf("]:") + 1) === "[::1]";
|
|
4429
|
+
return hostname === "[::1]";
|
|
4430
|
+
}
|
|
4347
4431
|
if (hostname === "::1") return true;
|
|
4348
4432
|
const hostPart = hostname.split(":")[0];
|
|
4349
4433
|
return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
|
|
4350
4434
|
}
|
|
4351
4435
|
|
|
4436
|
+
//#endregion
|
|
4437
|
+
//#region src/pty/proxy.ts
|
|
4438
|
+
async function proxyTerminal(stub, sessionId, request, options) {
|
|
4439
|
+
if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
|
|
4440
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
|
|
4441
|
+
const params = new URLSearchParams({ sessionId });
|
|
4442
|
+
if (options?.cols) params.set("cols", String(options.cols));
|
|
4443
|
+
if (options?.rows) params.set("rows", String(options.rows));
|
|
4444
|
+
if (options?.shell) params.set("shell", options.shell);
|
|
4445
|
+
const ptyUrl = `http://localhost/ws/pty?${params}`;
|
|
4446
|
+
const ptyRequest = new Request(ptyUrl, request);
|
|
4447
|
+
return stub.fetch(switchPort(ptyRequest, 3e3));
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4352
4450
|
//#endregion
|
|
4353
4451
|
//#region src/storage-mount/r2-egress-handler.ts
|
|
4354
4452
|
const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
|
|
@@ -4677,6 +4775,178 @@ const r2EgressHandler = async (request, env$1, ctx) => {
|
|
|
4677
4775
|
}
|
|
4678
4776
|
};
|
|
4679
4777
|
|
|
4778
|
+
//#endregion
|
|
4779
|
+
//#region src/tunnels/credentials.ts
|
|
4780
|
+
/**
|
|
4781
|
+
* Resolve a Cloudflare account id from environment with documented
|
|
4782
|
+
* precedence. Used by features that need to address a specific account
|
|
4783
|
+
* (Cloudflare Tunnel, R2 backup) to find their account id without
|
|
4784
|
+
* forcing every caller to set the same env var.
|
|
4785
|
+
*
|
|
4786
|
+
* Precedence (first non-empty wins):
|
|
4787
|
+
* 1. The feature-specific override env var (e.g. `CLOUDFLARE_TUNNEL_ACCOUNT_ID`).
|
|
4788
|
+
* 2. `CLOUDFLARE_ACCOUNT_ID`.
|
|
4789
|
+
* 3. The single account `CLOUDFLARE_API_TOKEN` is scoped to, via
|
|
4790
|
+
* `GET /user/tokens/verify`. Multi-account tokens are rejected.
|
|
4791
|
+
*
|
|
4792
|
+
* The resolver is feature-agnostic; only the `overrideKey` differs per
|
|
4793
|
+
* caller. Throws on any failure with a message that names the env vars
|
|
4794
|
+
* the caller can set to fix it.
|
|
4795
|
+
*/
|
|
4796
|
+
const TOKEN_VERIFY_URL = "https://api.cloudflare.com/client/v4/user/tokens/verify";
|
|
4797
|
+
const ACCOUNTS_LIST_URL = "https://api.cloudflare.com/client/v4/accounts";
|
|
4798
|
+
/**
|
|
4799
|
+
* Per-request timeout for the credential introspection calls below.
|
|
4800
|
+
* Without one a hung Cloudflare control-plane call wedges every
|
|
4801
|
+
* first-time named-tunnel `get()` on the DO (the resolver promises are
|
|
4802
|
+
* memoised on `Sandbox`, so the first caller's hang is everyone's hang).
|
|
4803
|
+
*/
|
|
4804
|
+
const CREDENTIALS_TIMEOUT_MS = 1e4;
|
|
4805
|
+
/**
|
|
4806
|
+
* Fetch wrapper that adds an `AbortSignal.timeout` and surfaces a
|
|
4807
|
+
* timeout as a labelled `Error` so the caller can blame the right URL.
|
|
4808
|
+
*/
|
|
4809
|
+
async function fetchWithTimeout(fetcher, url, init, timeoutMs = CREDENTIALS_TIMEOUT_MS) {
|
|
4810
|
+
try {
|
|
4811
|
+
return await fetcher(url, {
|
|
4812
|
+
...init,
|
|
4813
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
4814
|
+
});
|
|
4815
|
+
} catch (err) {
|
|
4816
|
+
if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`);
|
|
4817
|
+
throw err;
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
/**
|
|
4821
|
+
* Cloudflare error code returned by `GET /user/tokens/verify` when the
|
|
4822
|
+
* presented token is an account-owned (`cfat-`) token rather than a
|
|
4823
|
+
* user-owned one. Matches the heuristic wrangler uses in
|
|
4824
|
+
* `src/user/whoami.ts` (`getTokenType`).
|
|
4825
|
+
*/
|
|
4826
|
+
const ACCOUNT_OWNED_TOKEN_CODE = 1e3;
|
|
4827
|
+
async function resolveAccountId(env$1, options) {
|
|
4828
|
+
const override = getEnvString(env$1, options.overrideKey);
|
|
4829
|
+
if (override) return override;
|
|
4830
|
+
const generic = getEnvString(env$1, "CLOUDFLARE_ACCOUNT_ID");
|
|
4831
|
+
if (generic) return generic;
|
|
4832
|
+
const token = getEnvString(env$1, "CLOUDFLARE_API_TOKEN");
|
|
4833
|
+
if (!token) throw new Error(`Cloudflare account id could not be resolved. Set one of: ${options.overrideKey}, CLOUDFLARE_ACCOUNT_ID, or CLOUDFLARE_API_TOKEN (a token scoped to a single account).`);
|
|
4834
|
+
const fetcher = options.fetcher ?? fetch;
|
|
4835
|
+
const response = await fetchWithTimeout(fetcher, TOKEN_VERIFY_URL, {
|
|
4836
|
+
method: "GET",
|
|
4837
|
+
headers: {
|
|
4838
|
+
authorization: `Bearer ${token}`,
|
|
4839
|
+
"content-type": "application/json"
|
|
4840
|
+
}
|
|
4841
|
+
});
|
|
4842
|
+
let body;
|
|
4843
|
+
try {
|
|
4844
|
+
body = await response.json();
|
|
4845
|
+
} catch (err) {
|
|
4846
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4847
|
+
throw new Error(`Cloudflare token verification returned malformed JSON: ${message}`);
|
|
4848
|
+
}
|
|
4849
|
+
if (response.ok && body?.success) {
|
|
4850
|
+
const derived = body.result_info?.account?.id;
|
|
4851
|
+
if (!derived) throw new Error(`Cloudflare token is not scoped to a single account (ambiguous). Set ${options.overrideKey} or CLOUDFLARE_ACCOUNT_ID explicitly.`);
|
|
4852
|
+
return derived;
|
|
4853
|
+
}
|
|
4854
|
+
if (body?.errors?.some((e) => e.code === ACCOUNT_OWNED_TOKEN_CODE)) return await deriveAccountIdViaAccountToken(token, fetcher, options);
|
|
4855
|
+
throw new Error(`Cloudflare token verification failed with status ${response.status}. Check that CLOUDFLARE_API_TOKEN is valid or set ${options.overrideKey} / CLOUDFLARE_ACCOUNT_ID explicitly.`);
|
|
4856
|
+
}
|
|
4857
|
+
/**
|
|
4858
|
+
* Account-owned token (cfat-) fallback: list the accounts the token can
|
|
4859
|
+
* see, and — if there's exactly one — confirm with the account-scoped
|
|
4860
|
+
* verify endpoint before returning the id.
|
|
4861
|
+
*
|
|
4862
|
+
* Common failure modes get specific, actionable error messages:
|
|
4863
|
+
* - `/accounts` 403 (token lacks `account:read`): tell the caller to
|
|
4864
|
+
* set `CLOUDFLARE_ACCOUNT_ID` explicitly.
|
|
4865
|
+
* - multiple accounts: same.
|
|
4866
|
+
* - zero accounts: same.
|
|
4867
|
+
* - confirm step fails: surface the API error code verbatim.
|
|
4868
|
+
*/
|
|
4869
|
+
async function deriveAccountIdViaAccountToken(token, fetcher, options) {
|
|
4870
|
+
const listResponse = await fetchWithTimeout(fetcher, `${ACCOUNTS_LIST_URL}?per_page=2`, {
|
|
4871
|
+
method: "GET",
|
|
4872
|
+
headers: {
|
|
4873
|
+
authorization: `Bearer ${token}`,
|
|
4874
|
+
"content-type": "application/json"
|
|
4875
|
+
}
|
|
4876
|
+
});
|
|
4877
|
+
let listBody;
|
|
4878
|
+
try {
|
|
4879
|
+
listBody = await listResponse.json();
|
|
4880
|
+
} catch (err) {
|
|
4881
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4882
|
+
throw new Error(`Cloudflare account-owned token: /accounts returned malformed JSON: ${message}`);
|
|
4883
|
+
}
|
|
4884
|
+
if (!listResponse.ok || !listBody?.success) throw new Error(`Cloudflare account-owned token (cfat-...) detected, but /accounts returned status ${listResponse.status}. The token may lack account:read scope. Set CLOUDFLARE_ACCOUNT_ID explicitly to skip introspection.`);
|
|
4885
|
+
const accounts = listBody.result ?? [];
|
|
4886
|
+
if (accounts.length === 0) throw new Error("Cloudflare account-owned token has access to no accounts. Set CLOUDFLARE_ACCOUNT_ID explicitly.");
|
|
4887
|
+
if (accounts.length > 1) throw new Error("Cloudflare account-owned token has access to multiple accounts (ambiguous). Set CLOUDFLARE_ACCOUNT_ID explicitly to disambiguate.");
|
|
4888
|
+
const accountId = accounts[0]?.id;
|
|
4889
|
+
if (!accountId) throw new Error("Cloudflare /accounts returned a result without an id field.");
|
|
4890
|
+
const verifyResponse = await fetchWithTimeout(fetcher, `${ACCOUNTS_LIST_URL}/${encodeURIComponent(accountId)}/tokens/verify`, {
|
|
4891
|
+
method: "GET",
|
|
4892
|
+
headers: {
|
|
4893
|
+
authorization: `Bearer ${token}`,
|
|
4894
|
+
"content-type": "application/json"
|
|
4895
|
+
}
|
|
4896
|
+
});
|
|
4897
|
+
let verifyBody;
|
|
4898
|
+
try {
|
|
4899
|
+
verifyBody = await verifyResponse.json();
|
|
4900
|
+
} catch (err) {
|
|
4901
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4902
|
+
throw new Error(`Cloudflare account token verify returned malformed JSON: ${message}`);
|
|
4903
|
+
}
|
|
4904
|
+
if (!verifyResponse.ok || !verifyBody?.success) {
|
|
4905
|
+
const detail = verifyBody?.errors?.map((e) => `${e.code}: ${e.message}`).join("; ") ?? `HTTP ${verifyResponse.status}`;
|
|
4906
|
+
throw new Error(`Cloudflare account token verify failed for account ${accountId}: ${detail}`);
|
|
4907
|
+
}
|
|
4908
|
+
return accountId;
|
|
4909
|
+
}
|
|
4910
|
+
const ZONES_LIST_URL = "https://api.cloudflare.com/client/v4/zones";
|
|
4911
|
+
/**
|
|
4912
|
+
* Resolve a Cloudflare zone id.
|
|
4913
|
+
*
|
|
4914
|
+
* Precedence:
|
|
4915
|
+
* 1. `CLOUDFLARE_ZONE_ID` env var.
|
|
4916
|
+
* 2. The single zone the token can see under `accountId`, via
|
|
4917
|
+
* `GET /zones?account.id=<accountId>&per_page=2`.
|
|
4918
|
+
*
|
|
4919
|
+
* Step 2 deliberately fetches at most two results: one is the happy path,
|
|
4920
|
+
* two (or more) means the token is ambiguous and we refuse to guess.
|
|
4921
|
+
* Multi-zone tokens must set `CLOUDFLARE_ZONE_ID` explicitly so the
|
|
4922
|
+
* caller's intent is unambiguous.
|
|
4923
|
+
*/
|
|
4924
|
+
async function resolveZoneId(env$1, options) {
|
|
4925
|
+
const envZone = getEnvString(env$1, "CLOUDFLARE_ZONE_ID");
|
|
4926
|
+
if (envZone) return envZone;
|
|
4927
|
+
const response = await fetchWithTimeout(options.fetcher ?? fetch, `${ZONES_LIST_URL}?account.id=${encodeURIComponent(options.accountId)}&per_page=2`, {
|
|
4928
|
+
method: "GET",
|
|
4929
|
+
headers: {
|
|
4930
|
+
authorization: `Bearer ${options.token}`,
|
|
4931
|
+
"content-type": "application/json"
|
|
4932
|
+
}
|
|
4933
|
+
});
|
|
4934
|
+
if (!response.ok) throw new Error(`Cloudflare zones lookup failed with status ${response.status}. Set CLOUDFLARE_ZONE_ID explicitly or grant the API token Zone:Read.`);
|
|
4935
|
+
let body;
|
|
4936
|
+
try {
|
|
4937
|
+
body = await response.json();
|
|
4938
|
+
} catch (err) {
|
|
4939
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4940
|
+
throw new Error(`Cloudflare zones lookup returned malformed JSON: ${message}`);
|
|
4941
|
+
}
|
|
4942
|
+
const zones = body.result ?? [];
|
|
4943
|
+
if (zones.length === 0) throw new Error(`Cloudflare API token has access to no zones in account ${options.accountId}. Set CLOUDFLARE_ZONE_ID explicitly or grant the token Zone:Read on the intended zone.`);
|
|
4944
|
+
if (zones.length > 1) throw new Error(`Cloudflare API token has access to multiple zones in account ${options.accountId} (ambiguous). Set CLOUDFLARE_ZONE_ID explicitly to disambiguate.`);
|
|
4945
|
+
const zoneId = zones[0]?.id;
|
|
4946
|
+
if (!zoneId) throw new Error("Cloudflare zones lookup returned a result without an id field.");
|
|
4947
|
+
return zoneId;
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4680
4950
|
//#endregion
|
|
4681
4951
|
//#region src/tunnels/sandbox-control-callback.ts
|
|
4682
4952
|
var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
@@ -4699,6 +4969,229 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
|
4699
4969
|
}
|
|
4700
4970
|
};
|
|
4701
4971
|
|
|
4972
|
+
//#endregion
|
|
4973
|
+
//#region src/tunnels/cloudflare-api.ts
|
|
4974
|
+
/**
|
|
4975
|
+
* Cloudflare API client for named-tunnel orchestration.
|
|
4976
|
+
*
|
|
4977
|
+
* Design notes:
|
|
4978
|
+
*
|
|
4979
|
+
* - The Cloudflare API envelope is `{ success, result, errors }`. We
|
|
4980
|
+
* unwrap `result` on success and surface a thrown `Error` with the
|
|
4981
|
+
* API error code/message on failure. Transport-level errors
|
|
4982
|
+
* propagate unchanged.
|
|
4983
|
+
* - Delete endpoints are idempotent from the caller's perspective:
|
|
4984
|
+
* a 404 (already gone) resolves successfully so destroy() can run
|
|
4985
|
+
* without special-casing.
|
|
4986
|
+
* - `upsertCNAME` is the most subtle wrapper: it lists existing
|
|
4987
|
+
* records, reuses a matching one, and refuses to mutate a record
|
|
4988
|
+
* whose content differs from what we want. This is the fence that
|
|
4989
|
+
* stops two sandboxes from racing on the same hostname.
|
|
4990
|
+
*/
|
|
4991
|
+
const API_BASE = "https://api.cloudflare.com/client/v4";
|
|
4992
|
+
/**
|
|
4993
|
+
* Default request timeout. Cloudflare API P99 latency is well under
|
|
4994
|
+
* this; values much smaller risk false positives on cold control-plane
|
|
4995
|
+
* paths (e.g. first `cfd_tunnel` POST in a new account).
|
|
4996
|
+
*/
|
|
4997
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
4998
|
+
/**
|
|
4999
|
+
* Internal request helper. Centralises auth header, JSON encoding,
|
|
5000
|
+
* timeout enforcement, and envelope unwrapping so each wrapper above
|
|
5001
|
+
* stays declarative.
|
|
5002
|
+
*/
|
|
5003
|
+
async function cfRequest(url, token, fetcher, options = {}) {
|
|
5004
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
5005
|
+
const init = {
|
|
5006
|
+
method: options.method ?? "GET",
|
|
5007
|
+
headers: {
|
|
5008
|
+
authorization: `Bearer ${token}`,
|
|
5009
|
+
"content-type": "application/json"
|
|
5010
|
+
},
|
|
5011
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
5012
|
+
};
|
|
5013
|
+
if (options.body !== void 0) init.body = JSON.stringify(options.body);
|
|
5014
|
+
let response;
|
|
5015
|
+
try {
|
|
5016
|
+
response = await fetcher(url, init);
|
|
5017
|
+
} catch (err) {
|
|
5018
|
+
if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`);
|
|
5019
|
+
throw err;
|
|
5020
|
+
}
|
|
5021
|
+
if (options.acceptStatuses?.includes(response.status)) return;
|
|
5022
|
+
let envelope;
|
|
5023
|
+
try {
|
|
5024
|
+
envelope = await response.json();
|
|
5025
|
+
} catch (err) {
|
|
5026
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5027
|
+
throw new Error(`Cloudflare API returned non-JSON response (status ${response.status}): ${message}`);
|
|
5028
|
+
}
|
|
5029
|
+
if (!response.ok || envelope.success === false) {
|
|
5030
|
+
const errs = envelope.errors ?? [];
|
|
5031
|
+
const summary = errs.length ? errs.map((e) => `${e.code ?? "???"}: ${e.message ?? "unknown"}`).join(", ") : `HTTP ${response.status}`;
|
|
5032
|
+
throw new Error(`Cloudflare API error: ${summary}`);
|
|
5033
|
+
}
|
|
5034
|
+
return envelope.result;
|
|
5035
|
+
}
|
|
5036
|
+
/**
|
|
5037
|
+
* Heuristic for the "tags are an Enterprise-only feature" error class.
|
|
5038
|
+
* Empirically grounded against a non-Enterprise account:
|
|
5039
|
+
*
|
|
5040
|
+
* - DNS create with `tags: [...]` on a non-Enterprise zone rejects with
|
|
5041
|
+
* Cloudflare error code 9300 and the message "DNS record has N tags,
|
|
5042
|
+
* exceeding the quota of 0.". The error string `cfRequest` constructs
|
|
5043
|
+
* embeds both the code and the message, so we match on either signal.
|
|
5044
|
+
* - Tunnel create with `tags: [...]` silently succeeds and drops the
|
|
5045
|
+
* field on the floor (no error to retry on). The fallback wrapper
|
|
5046
|
+
* therefore costs nothing on tunnel writes.
|
|
5047
|
+
*
|
|
5048
|
+
* Generic "requires Enterprise" phrasing is also matched as a forward-
|
|
5049
|
+
* compatibility hedge in case Cloudflare changes the response shape on
|
|
5050
|
+
* future endpoints.
|
|
5051
|
+
*/
|
|
5052
|
+
function isEnterpriseOnlyTagError(error) {
|
|
5053
|
+
if (!(error instanceof Error)) return false;
|
|
5054
|
+
const msg = error.message.toLowerCase();
|
|
5055
|
+
if (msg.includes("9300") && msg.includes("tag")) return true;
|
|
5056
|
+
if (!msg.includes("tag")) return false;
|
|
5057
|
+
return msg.includes("quota") || msg.includes("enterprise") || msg.includes("not allowed") || msg.includes("not entitled") || msg.includes("not available") || msg.includes("not supported");
|
|
5058
|
+
}
|
|
5059
|
+
/**
|
|
5060
|
+
* Build the `tags` field attached to created Cloudflare resources. The
|
|
5061
|
+
* tag is `sandboxId:<id>`, the same key used in DNS comments / tunnel
|
|
5062
|
+
* metadata; together they let an operator find every resource a given
|
|
5063
|
+
* sandbox owns from the Cloudflare dashboard.
|
|
5064
|
+
*
|
|
5065
|
+
* Tags are an Enterprise-only feature. The wrapper `createWithTagFallback`
|
|
5066
|
+
* automatically retries the request without tags on the documented
|
|
5067
|
+
* "requires Enterprise" error so non-enterprise accounts succeed without
|
|
5068
|
+
* any configuration.
|
|
5069
|
+
*/
|
|
5070
|
+
function buildSandboxTags(sandboxId) {
|
|
5071
|
+
if (!sandboxId) return void 0;
|
|
5072
|
+
return [`sandboxId:${sandboxId}`];
|
|
5073
|
+
}
|
|
5074
|
+
/**
|
|
5075
|
+
* Wrap a tagged-create request with an automatic tag-strip retry. The
|
|
5076
|
+
* callback receives `tags`: pass it through to the request body as-is on
|
|
5077
|
+
* the first call (`undefined` on the retry). The retry only fires for
|
|
5078
|
+
* the Enterprise-only tag error class; any other failure surfaces
|
|
5079
|
+
* verbatim.
|
|
5080
|
+
*/
|
|
5081
|
+
async function createWithTagFallback(sandboxId, send) {
|
|
5082
|
+
const tags = buildSandboxTags(sandboxId);
|
|
5083
|
+
if (!tags) return send(void 0);
|
|
5084
|
+
try {
|
|
5085
|
+
return await send(tags);
|
|
5086
|
+
} catch (err) {
|
|
5087
|
+
if (!isEnterpriseOnlyTagError(err)) throw err;
|
|
5088
|
+
return send(void 0);
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
async function createTunnel(args) {
|
|
5092
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5093
|
+
const result = await createWithTagFallback(args.metadata.sandboxId, (tags) => cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel`, args.token, fetcher, {
|
|
5094
|
+
method: "POST",
|
|
5095
|
+
body: {
|
|
5096
|
+
name: args.tunnelName,
|
|
5097
|
+
config_src: "cloudflare",
|
|
5098
|
+
metadata: args.metadata,
|
|
5099
|
+
...tags ? { tags } : {}
|
|
5100
|
+
}
|
|
5101
|
+
}));
|
|
5102
|
+
if (!result) throw new Error("Cloudflare tunnel create returned no result body");
|
|
5103
|
+
return {
|
|
5104
|
+
id: result.id,
|
|
5105
|
+
token: result.token
|
|
5106
|
+
};
|
|
5107
|
+
}
|
|
5108
|
+
/**
|
|
5109
|
+
* Look up an existing tunnel by exact name match. Filters out tunnels
|
|
5110
|
+
* marked `deleted_at != null` defensively in case the API ignores the
|
|
5111
|
+
* `is_deleted=false` query parameter.
|
|
5112
|
+
*
|
|
5113
|
+
* When `expectedSandboxId` is provided, also verify that the tunnel's
|
|
5114
|
+
* `metadata.sandboxId` tag matches — this is the authoritative "this
|
|
5115
|
+
* resource was created by this sandbox" check, and the tag is set by
|
|
5116
|
+
* `createTunnel`. Mismatches are treated as "not found" so the caller
|
|
5117
|
+
* falls through to creating a fresh tunnel.
|
|
5118
|
+
*/
|
|
5119
|
+
async function findTunnelByName(args) {
|
|
5120
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5121
|
+
const result = await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel?name=${encodeURIComponent(args.tunnelName)}&is_deleted=false`, args.token, fetcher);
|
|
5122
|
+
if (!result) return null;
|
|
5123
|
+
const live = result.find((t) => !t.deleted_at);
|
|
5124
|
+
if (!live) return null;
|
|
5125
|
+
if (args.expectedSandboxId !== void 0) {
|
|
5126
|
+
if (live.metadata?.sandboxId !== args.expectedSandboxId) return null;
|
|
5127
|
+
}
|
|
5128
|
+
return {
|
|
5129
|
+
id: live.id,
|
|
5130
|
+
name: live.name
|
|
5131
|
+
};
|
|
5132
|
+
}
|
|
5133
|
+
async function deleteTunnel(args) {
|
|
5134
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5135
|
+
await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel/${encodeURIComponent(args.tunnelId)}`, args.token, fetcher, {
|
|
5136
|
+
method: "DELETE",
|
|
5137
|
+
acceptStatuses: [404]
|
|
5138
|
+
});
|
|
5139
|
+
}
|
|
5140
|
+
/**
|
|
5141
|
+
* Fetch the opaque `--token` for an existing tunnel. Used on the retry
|
|
5142
|
+
* path: when `findTunnelByName` discovers a tunnel left behind from a
|
|
5143
|
+
* previous failed attempt, we need its token to run `cloudflared` again.
|
|
5144
|
+
*
|
|
5145
|
+
* The Cloudflare API returns the token as a bare quoted string in the
|
|
5146
|
+
* `result` envelope (e.g. `"<base64-token>"`).
|
|
5147
|
+
*/
|
|
5148
|
+
async function getTunnelToken(args) {
|
|
5149
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5150
|
+
const result = await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel/${encodeURIComponent(args.tunnelId)}/token`, args.token, fetcher);
|
|
5151
|
+
if (typeof result !== "string" || result.length === 0) throw new Error(`Cloudflare did not return a token for tunnel ${args.tunnelId}`);
|
|
5152
|
+
return result;
|
|
5153
|
+
}
|
|
5154
|
+
async function getZoneName(args) {
|
|
5155
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5156
|
+
const result = await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}`, args.token, fetcher);
|
|
5157
|
+
if (!result?.name) throw new Error(`Cloudflare zone ${args.zoneId} did not return a name`);
|
|
5158
|
+
return result.name;
|
|
5159
|
+
}
|
|
5160
|
+
async function upsertCNAME(args) {
|
|
5161
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5162
|
+
const existing = (await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records?type=CNAME&name=${encodeURIComponent(args.hostname)}`, args.token, fetcher) ?? []).find((r) => r.type === "CNAME" && r.name === args.hostname);
|
|
5163
|
+
if (existing) {
|
|
5164
|
+
if (existing.content === args.cnameTarget) return {
|
|
5165
|
+
recordId: existing.id,
|
|
5166
|
+
reused: true
|
|
5167
|
+
};
|
|
5168
|
+
throw new Error(`DNS record for ${args.hostname} already exists with different content (owned by you, not us): existing content="${existing.content}", existing comment="${existing.comment ?? ""}". Delete the record manually to allow the sandbox to manage it.`);
|
|
5169
|
+
}
|
|
5170
|
+
const createResult = await createWithTagFallback(args.sandboxId, (tags) => cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records`, args.token, fetcher, {
|
|
5171
|
+
method: "POST",
|
|
5172
|
+
body: {
|
|
5173
|
+
type: "CNAME",
|
|
5174
|
+
name: args.hostname,
|
|
5175
|
+
content: args.cnameTarget,
|
|
5176
|
+
proxied: true,
|
|
5177
|
+
comment: args.comment,
|
|
5178
|
+
...tags ? { tags } : {}
|
|
5179
|
+
}
|
|
5180
|
+
}));
|
|
5181
|
+
if (!createResult) throw new Error("Cloudflare DNS create returned no result body");
|
|
5182
|
+
return {
|
|
5183
|
+
recordId: createResult.id,
|
|
5184
|
+
reused: false
|
|
5185
|
+
};
|
|
5186
|
+
}
|
|
5187
|
+
async function deleteDNSRecord(args) {
|
|
5188
|
+
const fetcher = args.fetcher ?? fetch;
|
|
5189
|
+
await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records/${encodeURIComponent(args.recordId)}`, args.token, fetcher, {
|
|
5190
|
+
method: "DELETE",
|
|
5191
|
+
acceptStatuses: [404]
|
|
5192
|
+
});
|
|
5193
|
+
}
|
|
5194
|
+
|
|
4702
5195
|
//#endregion
|
|
4703
5196
|
//#region src/tunnels/tunnels-handler.ts
|
|
4704
5197
|
/**
|
|
@@ -4713,6 +5206,14 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
|
4713
5206
|
*/
|
|
4714
5207
|
/** DO storage key for the `port → TunnelInfo` map. */
|
|
4715
5208
|
const STORAGE_KEY = "tunnels";
|
|
5209
|
+
/**
|
|
5210
|
+
* Sidecar storage key for per-port metadata the handler needs but the
|
|
5211
|
+
* public `TunnelInfo` shape does not carry: the options hash used to
|
|
5212
|
+
* detect divergent retries, and (for named tunnels) the DNS record id
|
|
5213
|
+
* needed for cleanup. Kept under a separate key so the existing
|
|
5214
|
+
* `tunnels` shape remains a clean `Record<port, TunnelInfo>`.
|
|
5215
|
+
*/
|
|
5216
|
+
const META_STORAGE_KEY = "tunnels:meta";
|
|
4716
5217
|
function validateTunnelPort(port) {
|
|
4717
5218
|
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
|
|
4718
5219
|
}
|
|
@@ -4722,47 +5223,142 @@ function shortId() {
|
|
|
4722
5223
|
crypto.getRandomValues(buf);
|
|
4723
5224
|
return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
4724
5225
|
}
|
|
5226
|
+
/**
|
|
5227
|
+
* Match a structured SandboxError code anywhere on the error — translated
|
|
5228
|
+
* SandboxErrors expose the code both as a top-level `code` field and on
|
|
5229
|
+
* the nested `errorResponse.code`. Used for the few error codes the SDK
|
|
5230
|
+
* recognises and recovers from (TUNNEL_NOT_FOUND, TUNNEL_ALREADY_RUNNING).
|
|
5231
|
+
*
|
|
5232
|
+
* Previous versions matched by substring on `error.message`, which
|
|
5233
|
+
* false-positived on any error whose message merely quoted the literal
|
|
5234
|
+
* code token.
|
|
5235
|
+
*/
|
|
5236
|
+
function hasErrorCode(error, code) {
|
|
5237
|
+
if (!error || typeof error !== "object") return false;
|
|
5238
|
+
const e = error;
|
|
5239
|
+
if (e.code === code) return true;
|
|
5240
|
+
if (e.errorResponse?.code === code) return true;
|
|
5241
|
+
return false;
|
|
5242
|
+
}
|
|
4725
5243
|
function isTunnelNotFoundError(error) {
|
|
4726
|
-
return (error
|
|
5244
|
+
return hasErrorCode(error, "TUNNEL_NOT_FOUND");
|
|
5245
|
+
}
|
|
5246
|
+
function isTunnelAlreadyRunningError(error) {
|
|
5247
|
+
return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
|
|
4727
5248
|
}
|
|
4728
5249
|
async function readMap(storage) {
|
|
4729
5250
|
return await storage.get(STORAGE_KEY) ?? {};
|
|
4730
5251
|
}
|
|
5252
|
+
async function readMetaMap(storage) {
|
|
5253
|
+
return await storage.get(META_STORAGE_KEY) ?? {};
|
|
5254
|
+
}
|
|
5255
|
+
/**
|
|
5256
|
+
* Stable hash of `options`. Empty/undefined options collapse to the same
|
|
5257
|
+
* hash so `get(port)`, `get(port, {})`, and `get(port, { name: undefined })`
|
|
5258
|
+
* all hit the same cache entry. Named tunnels hash on `name` alone (the
|
|
5259
|
+
* only option today).
|
|
5260
|
+
*
|
|
5261
|
+
* The `v1:` prefix exists so a future addition of a second option (e.g.
|
|
5262
|
+
* `subdomain`) can change the canonical form without colliding with an
|
|
5263
|
+
* older record's hash. Comparison goes through `optionsHashesEqual`, which
|
|
5264
|
+
* normalises legacy unversioned hashes (`quick`, `named:foo`) to their v1
|
|
5265
|
+
* form before equality, so upgrading does not invalidate cached records.
|
|
5266
|
+
*/
|
|
5267
|
+
function computeOptionsHash(options) {
|
|
5268
|
+
if (!options || !options.name) return "v1:quick";
|
|
5269
|
+
return `v1:named:${options.name}`;
|
|
5270
|
+
}
|
|
5271
|
+
/** Strip the optional `v1:` prefix so legacy hashes compare equal. */
|
|
5272
|
+
function normaliseHash(hash) {
|
|
5273
|
+
return hash.startsWith("v1:") ? hash.slice(3) : hash;
|
|
5274
|
+
}
|
|
5275
|
+
function optionsHashesEqual(a, b) {
|
|
5276
|
+
return normaliseHash(a) === normaliseHash(b);
|
|
5277
|
+
}
|
|
4731
5278
|
/**
|
|
4732
|
-
* Concrete `TunnelsHandler` implementation.
|
|
4733
|
-
*
|
|
4734
|
-
*
|
|
4735
|
-
* `RpcTarget` instances
|
|
5279
|
+
* Concrete `TunnelsHandler` implementation.
|
|
5280
|
+
*
|
|
5281
|
+
* Extends `RpcTarget` for forward compatibility with direct Workers RPC
|
|
5282
|
+
* pipelining (`stub.tunnels.get(port)`): only `RpcTarget` instances may
|
|
5283
|
+
* be passed by reference across the Workers RPC boundary. Today the
|
|
5284
|
+
* public `sandbox.tunnels` proxy in `getSandbox()` dispatches through
|
|
5285
|
+
* `stub.callTunnels(method, args)` instead — pipelining through
|
|
5286
|
+
* property getters is broken under the vite-plugin runtime — so the
|
|
5287
|
+
* `RpcTarget` base is not on the hot call path. It is retained so the
|
|
5288
|
+
* pipelining shape works once that constraint lifts.
|
|
4736
5289
|
*/
|
|
4737
5290
|
var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
4738
5291
|
#host;
|
|
4739
5292
|
#withPortLock;
|
|
5293
|
+
/**
|
|
5294
|
+
* Memoised zone name (e.g. `'example.com'`) for the configured
|
|
5295
|
+
* `CLOUDFLARE_ZONE_ID`. Filled in lazily on the first named-tunnel
|
|
5296
|
+
* `get()` so quick-tunnel callers never hit the zone-lookup endpoint.
|
|
5297
|
+
*
|
|
5298
|
+
* Only successful resolutions are cached: a rejected lookup clears
|
|
5299
|
+
* the slot so the next caller retries, instead of permanently
|
|
5300
|
+
* poisoning every subsequent named-tunnel `get()` on the DO with the
|
|
5301
|
+
* same transient error.
|
|
5302
|
+
*/
|
|
5303
|
+
#zoneNamePromise = null;
|
|
4740
5304
|
constructor(host, withPortLock) {
|
|
4741
5305
|
super();
|
|
4742
5306
|
this.#host = host;
|
|
4743
5307
|
this.#withPortLock = withPortLock;
|
|
4744
5308
|
}
|
|
4745
|
-
|
|
5309
|
+
/**
|
|
5310
|
+
* Resolve the zone name for the configured zone id. Memoised for the
|
|
5311
|
+
* lifetime of this handler; the zone name doesn't change while a DO
|
|
5312
|
+
* is alive, and one extra GET on first use is cheaper than threading
|
|
5313
|
+
* the value through the host.
|
|
5314
|
+
*
|
|
5315
|
+
* On failure the cached promise is cleared so the next caller retries.
|
|
5316
|
+
* Without that, a transient 5xx on the first call would permanently
|
|
5317
|
+
* poison every subsequent named-tunnel `get()` until the DO restarts.
|
|
5318
|
+
*/
|
|
5319
|
+
async #getZoneName(config) {
|
|
5320
|
+
if (!this.#zoneNamePromise) {
|
|
5321
|
+
const pending = getZoneName({
|
|
5322
|
+
token: config.token,
|
|
5323
|
+
zoneId: config.zoneId,
|
|
5324
|
+
fetcher: this.#host.fetcher
|
|
5325
|
+
});
|
|
5326
|
+
this.#zoneNamePromise = pending;
|
|
5327
|
+
pending.catch(() => {
|
|
5328
|
+
if (this.#zoneNamePromise === pending) this.#zoneNamePromise = null;
|
|
5329
|
+
});
|
|
5330
|
+
}
|
|
5331
|
+
return this.#zoneNamePromise;
|
|
5332
|
+
}
|
|
5333
|
+
async get(port, options) {
|
|
4746
5334
|
const startTime = Date.now();
|
|
4747
5335
|
let outcome = "error";
|
|
4748
5336
|
let cacheState = "miss";
|
|
4749
5337
|
let caughtError;
|
|
4750
5338
|
try {
|
|
4751
5339
|
validateTunnelPort(port);
|
|
5340
|
+
if (options?.name !== void 0) validateTunnelName(options.name);
|
|
5341
|
+
const requestedHash = computeOptionsHash(options);
|
|
4752
5342
|
const info = await this.#withPortLock(port, async () => {
|
|
4753
5343
|
const existing = (await readMap(this.#host.storage))[port.toString()];
|
|
4754
5344
|
if (existing) {
|
|
5345
|
+
const metaEntry = (await readMetaMap(this.#host.storage))[port.toString()];
|
|
5346
|
+
if (!optionsHashesEqual(metaEntry?.optionsHash ?? (existing.name ? `v1:named:${existing.name}` : "v1:quick"), requestedHash)) throw new Error(`Tunnel on port ${port} was created with different options. Call destroy(${port}) before changing tunnel options.`);
|
|
5347
|
+
if (metaEntry?.needsRespawn && existing.name) return await this.#provisionNamedTunnel(port, existing.name);
|
|
5348
|
+
if (existing.name && this.#host.getNamedTunnelConfig) {
|
|
5349
|
+
const currentConfig = await this.#host.getNamedTunnelConfig();
|
|
5350
|
+
const storedAccountId = metaEntry?.accountId;
|
|
5351
|
+
const storedZoneId = metaEntry?.zoneId;
|
|
5352
|
+
if (storedAccountId !== void 0 && storedAccountId !== currentConfig.accountId || storedZoneId !== void 0 && storedZoneId !== currentConfig.zoneId) {
|
|
5353
|
+
this.#zoneNamePromise = null;
|
|
5354
|
+
return await this.#provisionNamedTunnel(port, existing.name);
|
|
5355
|
+
}
|
|
5356
|
+
}
|
|
4755
5357
|
cacheState = "hit";
|
|
4756
5358
|
return existing;
|
|
4757
5359
|
}
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
await this.#host.storage.transaction(async (txn) => {
|
|
4761
|
-
const nextMap = await readMap(txn);
|
|
4762
|
-
nextMap[port.toString()] = spawned;
|
|
4763
|
-
await txn.put(STORAGE_KEY, nextMap);
|
|
4764
|
-
});
|
|
4765
|
-
return spawned;
|
|
5360
|
+
if (options?.name) return await this.#provisionNamedTunnel(port, options.name);
|
|
5361
|
+
return await this.#provisionQuickTunnel(port);
|
|
4766
5362
|
});
|
|
4767
5363
|
outcome = "success";
|
|
4768
5364
|
return info;
|
|
@@ -4780,6 +5376,129 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
|
4780
5376
|
});
|
|
4781
5377
|
}
|
|
4782
5378
|
}
|
|
5379
|
+
/**
|
|
5380
|
+
* Provision a fresh quick tunnel and persist it. Caller holds the
|
|
5381
|
+
* per-port lock.
|
|
5382
|
+
*
|
|
5383
|
+
* Quick-tunnel ids are minted from a 32-bit random source. Collisions
|
|
5384
|
+
* are astronomically unlikely, but if the container happens to already
|
|
5385
|
+
* have one running under the freshly-minted id it rejects with
|
|
5386
|
+
* TUNNEL_ALREADY_RUNNING. Mint a fresh id and try again rather than
|
|
5387
|
+
* surfacing the confusing error — the retry budget caps the loop so a
|
|
5388
|
+
* persistent failure still surfaces.
|
|
5389
|
+
*/
|
|
5390
|
+
async #provisionQuickTunnel(port) {
|
|
5391
|
+
const MAX_ID_RETRIES = 3;
|
|
5392
|
+
let lastError;
|
|
5393
|
+
for (let attempt = 0; attempt < MAX_ID_RETRIES; attempt += 1) {
|
|
5394
|
+
const id = `quick-${shortId()}`;
|
|
5395
|
+
try {
|
|
5396
|
+
const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
|
|
5397
|
+
await this.#host.storage.transaction(async (txn) => {
|
|
5398
|
+
const nextMap = await readMap(txn);
|
|
5399
|
+
nextMap[port.toString()] = spawned;
|
|
5400
|
+
await txn.put(STORAGE_KEY, nextMap);
|
|
5401
|
+
const nextMeta = await readMetaMap(txn);
|
|
5402
|
+
nextMeta[port.toString()] = { optionsHash: "v1:quick" };
|
|
5403
|
+
await txn.put(META_STORAGE_KEY, nextMeta);
|
|
5404
|
+
});
|
|
5405
|
+
return spawned;
|
|
5406
|
+
} catch (err) {
|
|
5407
|
+
if (!isTunnelAlreadyRunningError(err)) throw err;
|
|
5408
|
+
lastError = err;
|
|
5409
|
+
}
|
|
5410
|
+
}
|
|
5411
|
+
throw lastError ?? /* @__PURE__ */ new Error("Failed to mint a unique quick-tunnel id");
|
|
5412
|
+
}
|
|
5413
|
+
/**
|
|
5414
|
+
* Provision a named tunnel end-to-end:
|
|
5415
|
+
* 1. resolve credentials + zone name
|
|
5416
|
+
* 2. reuse or create the Cloudflare tunnel resource
|
|
5417
|
+
* 3. upsert the proxied CNAME (or reuse a matching one)
|
|
5418
|
+
* 4. spawn cloudflared inside the container
|
|
5419
|
+
* 5. persist the record + meta
|
|
5420
|
+
*
|
|
5421
|
+
* Failure between (2) and (5) intentionally leaves the Cloudflare-side
|
|
5422
|
+
* resources in place so a retry can re-discover them via
|
|
5423
|
+
* `findTunnelByName` and the DNS reuse path. See
|
|
5424
|
+
* `.plans/09-named-tunnel-api.md § Retry-friendly failure model`.
|
|
5425
|
+
*/
|
|
5426
|
+
async #provisionNamedTunnel(port, name) {
|
|
5427
|
+
if (!this.#host.sandboxId) throw new Error("Named tunnels require host.sandboxId on the tunnels handler.");
|
|
5428
|
+
if (!this.#host.getNamedTunnelConfig) throw new Error("Named tunnels require host.getNamedTunnelConfig on the tunnels handler.");
|
|
5429
|
+
const config = await this.#host.getNamedTunnelConfig();
|
|
5430
|
+
const hostname = `${name}.${await this.#getZoneName({
|
|
5431
|
+
token: config.token,
|
|
5432
|
+
zoneId: config.zoneId
|
|
5433
|
+
})}`;
|
|
5434
|
+
const sandboxId = this.#host.sandboxId;
|
|
5435
|
+
const tunnelName = `sandbox-${sandboxId}-${name}`;
|
|
5436
|
+
let tunnelId;
|
|
5437
|
+
let tunnelToken;
|
|
5438
|
+
const existingTunnel = await findTunnelByName({
|
|
5439
|
+
token: config.token,
|
|
5440
|
+
accountId: config.accountId,
|
|
5441
|
+
tunnelName,
|
|
5442
|
+
expectedSandboxId: sandboxId,
|
|
5443
|
+
fetcher: this.#host.fetcher
|
|
5444
|
+
});
|
|
5445
|
+
if (existingTunnel) {
|
|
5446
|
+
tunnelId = existingTunnel.id;
|
|
5447
|
+
tunnelToken = await getTunnelToken({
|
|
5448
|
+
token: config.token,
|
|
5449
|
+
accountId: config.accountId,
|
|
5450
|
+
tunnelId,
|
|
5451
|
+
fetcher: this.#host.fetcher
|
|
5452
|
+
});
|
|
5453
|
+
} else {
|
|
5454
|
+
const created = await createTunnel({
|
|
5455
|
+
token: config.token,
|
|
5456
|
+
accountId: config.accountId,
|
|
5457
|
+
tunnelName,
|
|
5458
|
+
metadata: {
|
|
5459
|
+
sandboxId,
|
|
5460
|
+
createdBy: "sandbox-sdk",
|
|
5461
|
+
name,
|
|
5462
|
+
port
|
|
5463
|
+
},
|
|
5464
|
+
fetcher: this.#host.fetcher
|
|
5465
|
+
});
|
|
5466
|
+
tunnelId = created.id;
|
|
5467
|
+
tunnelToken = created.token;
|
|
5468
|
+
}
|
|
5469
|
+
const dnsResult = await upsertCNAME({
|
|
5470
|
+
token: config.token,
|
|
5471
|
+
zoneId: config.zoneId,
|
|
5472
|
+
hostname,
|
|
5473
|
+
cnameTarget: `${tunnelId}.cfargotunnel.com`,
|
|
5474
|
+
comment: `sandbox-${sandboxId}`,
|
|
5475
|
+
sandboxId,
|
|
5476
|
+
fetcher: this.#host.fetcher
|
|
5477
|
+
});
|
|
5478
|
+
await this.#host.client.tunnels.runNamedTunnel(tunnelId, tunnelToken, port);
|
|
5479
|
+
const info = {
|
|
5480
|
+
id: tunnelId,
|
|
5481
|
+
port,
|
|
5482
|
+
name,
|
|
5483
|
+
hostname,
|
|
5484
|
+
url: `https://${hostname}`,
|
|
5485
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5486
|
+
};
|
|
5487
|
+
await this.#host.storage.transaction(async (txn) => {
|
|
5488
|
+
const nextMap = await readMap(txn);
|
|
5489
|
+
nextMap[port.toString()] = info;
|
|
5490
|
+
await txn.put(STORAGE_KEY, nextMap);
|
|
5491
|
+
const nextMeta = await readMetaMap(txn);
|
|
5492
|
+
nextMeta[port.toString()] = {
|
|
5493
|
+
optionsHash: computeOptionsHash({ name }),
|
|
5494
|
+
dnsRecordId: dnsResult.recordId,
|
|
5495
|
+
accountId: config.accountId,
|
|
5496
|
+
zoneId: config.zoneId
|
|
5497
|
+
};
|
|
5498
|
+
await txn.put(META_STORAGE_KEY, nextMeta);
|
|
5499
|
+
});
|
|
5500
|
+
return info;
|
|
5501
|
+
}
|
|
4783
5502
|
async destroy(portOrInfo) {
|
|
4784
5503
|
const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
|
|
4785
5504
|
const startTime = Date.now();
|
|
@@ -4791,16 +5510,68 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
|
4791
5510
|
const existing = (await readMap(this.#host.storage))[port.toString()];
|
|
4792
5511
|
if (!existing) return;
|
|
4793
5512
|
tunnelId = existing.id;
|
|
5513
|
+
const metaBefore = (await readMetaMap(this.#host.storage))[port.toString()];
|
|
4794
5514
|
await this.#host.storage.transaction(async (txn) => {
|
|
4795
5515
|
const current = await readMap(txn);
|
|
4796
5516
|
delete current[port.toString()];
|
|
4797
5517
|
await txn.put(STORAGE_KEY, current);
|
|
5518
|
+
const currentMeta = await readMetaMap(txn);
|
|
5519
|
+
delete currentMeta[port.toString()];
|
|
5520
|
+
await txn.put(META_STORAGE_KEY, currentMeta);
|
|
4798
5521
|
});
|
|
4799
5522
|
try {
|
|
4800
5523
|
await this.#host.client.tunnels.destroyTunnel(existing.id);
|
|
4801
5524
|
} catch (error) {
|
|
4802
|
-
if (
|
|
5525
|
+
if (isTunnelNotFoundError(error)) {} else if (metaBefore?.dnsRecordId) this.#host.logger.warn("tunnel.destroy: container tunnel cleanup failed", {
|
|
5526
|
+
port,
|
|
5527
|
+
tunnelId,
|
|
5528
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5529
|
+
});
|
|
5530
|
+
else throw error;
|
|
5531
|
+
}
|
|
5532
|
+
if (!metaBefore?.dnsRecordId) return;
|
|
5533
|
+
if (!this.#host.getNamedTunnelConfig) return;
|
|
5534
|
+
let config;
|
|
5535
|
+
try {
|
|
5536
|
+
config = await this.#host.getNamedTunnelConfig();
|
|
5537
|
+
} catch (err) {
|
|
5538
|
+
this.#host.logger.warn("tunnel.destroy: skipping CF cleanup, credentials unavailable", {
|
|
5539
|
+
port,
|
|
5540
|
+
tunnelId,
|
|
5541
|
+
dnsRecordId: metaBefore.dnsRecordId,
|
|
5542
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5543
|
+
});
|
|
5544
|
+
return;
|
|
4803
5545
|
}
|
|
5546
|
+
const fetcher = this.#host.fetcher;
|
|
5547
|
+
const accountId = metaBefore.accountId ?? config.accountId;
|
|
5548
|
+
const zoneId = metaBefore.zoneId ?? config.zoneId;
|
|
5549
|
+
await Promise.allSettled([metaBefore.dnsRecordId ? deleteDNSRecord({
|
|
5550
|
+
token: config.token,
|
|
5551
|
+
zoneId,
|
|
5552
|
+
recordId: metaBefore.dnsRecordId,
|
|
5553
|
+
fetcher
|
|
5554
|
+
}).catch((err) => {
|
|
5555
|
+
this.#host.logger.warn("tunnel.destroy: dns delete failed", {
|
|
5556
|
+
port,
|
|
5557
|
+
tunnelId,
|
|
5558
|
+
recordId: metaBefore.dnsRecordId,
|
|
5559
|
+
zoneId,
|
|
5560
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5561
|
+
});
|
|
5562
|
+
}) : Promise.resolve(), deleteTunnel({
|
|
5563
|
+
token: config.token,
|
|
5564
|
+
accountId,
|
|
5565
|
+
tunnelId: existing.id,
|
|
5566
|
+
fetcher
|
|
5567
|
+
}).catch((err) => {
|
|
5568
|
+
this.#host.logger.warn("tunnel.destroy: tunnel delete failed", {
|
|
5569
|
+
port,
|
|
5570
|
+
tunnelId,
|
|
5571
|
+
accountId,
|
|
5572
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5573
|
+
});
|
|
5574
|
+
})]);
|
|
4804
5575
|
});
|
|
4805
5576
|
outcome = "success";
|
|
4806
5577
|
} catch (error) {
|
|
@@ -4832,29 +5603,126 @@ function createTunnelsHandler(host) {
|
|
|
4832
5603
|
const tunnels = new TunnelsRpcTarget(host, withPortLock);
|
|
4833
5604
|
const handleTunnelExit = async (id, port, exitCode) => {
|
|
4834
5605
|
const startTime = Date.now();
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
5606
|
+
let outcome = "error";
|
|
5607
|
+
let caughtError;
|
|
5608
|
+
try {
|
|
5609
|
+
await withPortLock(port, async () => {
|
|
5610
|
+
await host.storage.transaction(async (txn) => {
|
|
5611
|
+
const map = await readMap(txn);
|
|
5612
|
+
const existing = map[port.toString()];
|
|
5613
|
+
if (existing?.id !== id) return;
|
|
5614
|
+
if (existing.name) {
|
|
5615
|
+
const meta$1 = await readMetaMap(txn);
|
|
5616
|
+
meta$1[port.toString()] = {
|
|
5617
|
+
...meta$1[port.toString()],
|
|
5618
|
+
optionsHash: meta$1[port.toString()]?.optionsHash ?? `v1:named:${existing.name}`,
|
|
5619
|
+
needsRespawn: true
|
|
5620
|
+
};
|
|
5621
|
+
await txn.put(META_STORAGE_KEY, meta$1);
|
|
5622
|
+
return;
|
|
5623
|
+
}
|
|
4839
5624
|
delete map[port.toString()];
|
|
4840
5625
|
await txn.put(STORAGE_KEY, map);
|
|
4841
|
-
|
|
5626
|
+
const meta = await readMetaMap(txn);
|
|
5627
|
+
delete meta[port.toString()];
|
|
5628
|
+
await txn.put(META_STORAGE_KEY, meta);
|
|
5629
|
+
});
|
|
4842
5630
|
});
|
|
5631
|
+
outcome = "success";
|
|
5632
|
+
} catch (error) {
|
|
5633
|
+
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
5634
|
+
throw error;
|
|
5635
|
+
} finally {
|
|
4843
5636
|
logCanonicalEvent(host.logger, {
|
|
4844
5637
|
event: "tunnel.exit",
|
|
4845
|
-
outcome
|
|
5638
|
+
outcome,
|
|
4846
5639
|
port,
|
|
4847
5640
|
tunnelId: id,
|
|
4848
5641
|
exitCode: exitCode ?? void 0,
|
|
4849
|
-
durationMs: Date.now() - startTime
|
|
5642
|
+
durationMs: Date.now() - startTime,
|
|
5643
|
+
error: caughtError
|
|
4850
5644
|
});
|
|
4851
|
-
}
|
|
5645
|
+
}
|
|
5646
|
+
};
|
|
5647
|
+
/**
|
|
5648
|
+
* Iterate every stored tunnel and call `tunnels.destroy(port)` on it,
|
|
5649
|
+
* sequentially. Each `destroy()` already swallows container-side
|
|
5650
|
+
* TUNNEL_NOT_FOUND and best-effort-logs Cloudflare-side failures; we
|
|
5651
|
+
* wrap the call in catch-and-log here too so a transport-level error
|
|
5652
|
+
* on one port can't poison the rest of the teardown.
|
|
5653
|
+
*
|
|
5654
|
+
* Each port is processed sequentially: this caps the *number of
|
|
5655
|
+
* concurrent ports* in flight at one. Note that an individual
|
|
5656
|
+
* destroy() still fans the DNS-delete and tunnel-delete out via
|
|
5657
|
+
* `Promise.allSettled` internally — so "sequential" here means
|
|
5658
|
+
* "one port at a time", not "one Cloudflare API call at a time".
|
|
5659
|
+
* The handful of ports we expect in the common case makes the
|
|
5660
|
+
* trade-off cheap.
|
|
5661
|
+
*/
|
|
5662
|
+
const destroyAll = async () => {
|
|
5663
|
+
const map = await readMap(host.storage);
|
|
5664
|
+
const ports = Object.keys(map).map((p) => Number(p));
|
|
5665
|
+
for (const port of ports) try {
|
|
5666
|
+
await tunnels.destroy(port);
|
|
5667
|
+
} catch (err) {
|
|
5668
|
+
host.logger.warn("tunnels.destroyAll: destroy(port) failed", {
|
|
5669
|
+
port,
|
|
5670
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5671
|
+
});
|
|
5672
|
+
}
|
|
4852
5673
|
};
|
|
4853
5674
|
return {
|
|
4854
5675
|
tunnels,
|
|
4855
|
-
handleTunnelExit
|
|
5676
|
+
handleTunnelExit,
|
|
5677
|
+
destroyAll
|
|
4856
5678
|
};
|
|
4857
5679
|
}
|
|
5680
|
+
/**
|
|
5681
|
+
* Reconcile storage with a fresh container.
|
|
5682
|
+
*
|
|
5683
|
+
* Called from `Sandbox.onStart()` after every container restart. The
|
|
5684
|
+
* `cloudflared` processes the container was running all died with it, so
|
|
5685
|
+
* any stored record is *not* currently backed by a running tunnel.
|
|
5686
|
+
*
|
|
5687
|
+
* Two tunnel flavours, two recovery stories:
|
|
5688
|
+
*
|
|
5689
|
+
* - Quick tunnels: the `*.trycloudflare.com` URL is bound to the dead
|
|
5690
|
+
* `cloudflared` process. Nothing on Cloudflare's side outlives the
|
|
5691
|
+
* container, and the URL is unrecoverable. Drop the record from both
|
|
5692
|
+
* maps so the next `get(port)` takes the miss branch and mints a new
|
|
5693
|
+
* URL.
|
|
5694
|
+
* - Named tunnels: the Cloudflare-side tunnel + DNS record survive.
|
|
5695
|
+
* The hostname is stable, the DNS still resolves to
|
|
5696
|
+
* `<tunnelId>.cfargotunnel.com`, and the next caller can reuse both
|
|
5697
|
+
* by walking the same `findTunnelByName` / `upsertCNAME` path the
|
|
5698
|
+
* SDK uses for retries. Keep the record in storage and mark the
|
|
5699
|
+
* meta entry `needsRespawn: true`; the next `get(port, { name })`
|
|
5700
|
+
* cache hit falls through to `#provisionNamedTunnel` to respawn
|
|
5701
|
+
* `cloudflared`.
|
|
5702
|
+
*
|
|
5703
|
+
* Crucially, named-tunnel metadata (including `dnsRecordId`) is
|
|
5704
|
+
* preserved so `destroy(port)` and `sandbox.destroy()` can still clean
|
|
5705
|
+
* up the Cloudflare-side resources after a restart. Wiping meta
|
|
5706
|
+
* unconditionally — the previous behaviour — silently leaked the tunnel
|
|
5707
|
+
* and DNS record on every restart.
|
|
5708
|
+
*/
|
|
5709
|
+
async function pruneTunnelsForRestart(storage) {
|
|
5710
|
+
await storage.transaction(async (txn) => {
|
|
5711
|
+
const map = await readMap(txn);
|
|
5712
|
+
const meta = await readMetaMap(txn);
|
|
5713
|
+
const nextMap = {};
|
|
5714
|
+
const nextMeta = {};
|
|
5715
|
+
for (const [portKey, info] of Object.entries(map)) if (info.name) {
|
|
5716
|
+
nextMap[portKey] = info;
|
|
5717
|
+
nextMeta[portKey] = {
|
|
5718
|
+
...meta[portKey] ?? { optionsHash: `v1:named:${info.name}` },
|
|
5719
|
+
needsRespawn: true
|
|
5720
|
+
};
|
|
5721
|
+
}
|
|
5722
|
+
await txn.put(STORAGE_KEY, nextMap);
|
|
5723
|
+
await txn.put(META_STORAGE_KEY, nextMeta);
|
|
5724
|
+
});
|
|
5725
|
+
}
|
|
4858
5726
|
|
|
4859
5727
|
//#endregion
|
|
4860
5728
|
//#region src/version.ts
|
|
@@ -4863,10 +5731,12 @@ function createTunnelsHandler(host) {
|
|
|
4863
5731
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
4864
5732
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
4865
5733
|
*/
|
|
4866
|
-
const SDK_VERSION = "0.
|
|
5734
|
+
const SDK_VERSION = "0.11.0";
|
|
4867
5735
|
|
|
4868
5736
|
//#endregion
|
|
4869
5737
|
//#region src/sandbox.ts
|
|
5738
|
+
const PORT_TOKENS_STORAGE_KEY = "portTokens";
|
|
5739
|
+
const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
|
|
4870
5740
|
const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
|
|
4871
5741
|
var R2EgressProxyTarget = class extends Container {};
|
|
4872
5742
|
Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
|
|
@@ -5106,6 +5976,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5106
5976
|
sandboxName = null;
|
|
5107
5977
|
tunnelsHandler = null;
|
|
5108
5978
|
tunnelExitHandler = null;
|
|
5979
|
+
destroyAllTunnels = null;
|
|
5109
5980
|
controlCallback;
|
|
5110
5981
|
normalizeId = false;
|
|
5111
5982
|
defaultSession = null;
|
|
@@ -5115,6 +5986,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5115
5986
|
logger;
|
|
5116
5987
|
keepAliveEnabled = false;
|
|
5117
5988
|
activeMounts = /* @__PURE__ */ new Map();
|
|
5989
|
+
currentRuntime;
|
|
5118
5990
|
transport = "http";
|
|
5119
5991
|
/**
|
|
5120
5992
|
* True once transport has been written to storage at least once (either
|
|
@@ -5140,8 +6012,23 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5140
6012
|
r2SecretAccessKey = null;
|
|
5141
6013
|
r2AccountId = null;
|
|
5142
6014
|
backupBucketName = null;
|
|
6015
|
+
backupBucketEndpoint = null;
|
|
5143
6016
|
r2Client = null;
|
|
5144
6017
|
/**
|
|
6018
|
+
* Lazily-resolved Cloudflare account id for named-tunnel provisioning.
|
|
6019
|
+
* Resolved on first access via `tunnels/credentials.ts` and cached for
|
|
6020
|
+
* the lifetime of this DO instance. See the credentials helper for
|
|
6021
|
+
* the precedence chain.
|
|
6022
|
+
*/
|
|
6023
|
+
tunnelAccountIdPromise = null;
|
|
6024
|
+
/**
|
|
6025
|
+
* Lazily-resolved Cloudflare zone id for named-tunnel provisioning.
|
|
6026
|
+
* Falls back to the single zone the token can see under the resolved
|
|
6027
|
+
* account id when `CLOUDFLARE_ZONE_ID` is not set. Cached for the
|
|
6028
|
+
* lifetime of this DO instance.
|
|
6029
|
+
*/
|
|
6030
|
+
tunnelZoneIdPromise = null;
|
|
6031
|
+
/**
|
|
5145
6032
|
* Default container startup timeouts (conservative for production)
|
|
5146
6033
|
* Based on Cloudflare docs: "Containers take several minutes to provision"
|
|
5147
6034
|
*/
|
|
@@ -5300,16 +6187,64 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5300
6187
|
component: "sandbox-do",
|
|
5301
6188
|
sandboxId: this.ctx.id.toString()
|
|
5302
6189
|
});
|
|
6190
|
+
this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
|
|
5303
6191
|
const transportEnv = envObj?.SANDBOX_TRANSPORT;
|
|
5304
6192
|
if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
|
|
5305
6193
|
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
|
|
5306
6194
|
this.logger.info(`Using ${this.transport} transport`);
|
|
5307
6195
|
const backupBucket = envObj?.BACKUP_BUCKET;
|
|
5308
6196
|
if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
|
|
5309
|
-
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
6197
|
+
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_R2_ACCOUNT_ID") ?? getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
5310
6198
|
this.r2AccessKeyId = getEnvString(envObj, "R2_ACCESS_KEY_ID") ?? null;
|
|
5311
6199
|
this.r2SecretAccessKey = getEnvString(envObj, "R2_SECRET_ACCESS_KEY") ?? null;
|
|
5312
6200
|
this.backupBucketName = getEnvString(envObj, "BACKUP_BUCKET_NAME") ?? null;
|
|
6201
|
+
const rawEndpoint = getEnvString(envObj, "BACKUP_BUCKET_ENDPOINT") ?? null;
|
|
6202
|
+
if (rawEndpoint !== null) {
|
|
6203
|
+
let parsed;
|
|
6204
|
+
try {
|
|
6205
|
+
parsed = new URL(rawEndpoint);
|
|
6206
|
+
} catch {
|
|
6207
|
+
const msg = `BACKUP_BUCKET_ENDPOINT is not a valid URL: "${rawEndpoint}". Expected format: https://<account_id>.eu.r2.cloudflarestorage.com`;
|
|
6208
|
+
throw new InvalidBackupConfigError({
|
|
6209
|
+
message: msg,
|
|
6210
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6211
|
+
httpStatus: 400,
|
|
6212
|
+
context: { reason: msg },
|
|
6213
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6216
|
+
if (parsed.protocol !== "https:") {
|
|
6217
|
+
const msg = `BACKUP_BUCKET_ENDPOINT must use https://, got "${parsed.protocol.slice(0, -1)}://"`;
|
|
6218
|
+
throw new InvalidBackupConfigError({
|
|
6219
|
+
message: msg,
|
|
6220
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6221
|
+
httpStatus: 400,
|
|
6222
|
+
context: { reason: msg },
|
|
6223
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6224
|
+
});
|
|
6225
|
+
}
|
|
6226
|
+
if (parsed.pathname !== "/") {
|
|
6227
|
+
const msg = `BACKUP_BUCKET_ENDPOINT must not include a path (got "${parsed.pathname}"). Provide only the origin, e.g. https://<account_id>.eu.r2.cloudflarestorage.com`;
|
|
6228
|
+
throw new InvalidBackupConfigError({
|
|
6229
|
+
message: msg,
|
|
6230
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6231
|
+
httpStatus: 400,
|
|
6232
|
+
context: { reason: msg },
|
|
6233
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6234
|
+
});
|
|
6235
|
+
}
|
|
6236
|
+
if (parsed.search !== "" || parsed.hash !== "") {
|
|
6237
|
+
const msg = "BACKUP_BUCKET_ENDPOINT must not include query parameters or fragments. Provide only the origin, e.g. https://<account_id>.eu.r2.cloudflarestorage.com";
|
|
6238
|
+
throw new InvalidBackupConfigError({
|
|
6239
|
+
message: msg,
|
|
6240
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6241
|
+
httpStatus: 400,
|
|
6242
|
+
context: { reason: msg },
|
|
6243
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6244
|
+
});
|
|
6245
|
+
}
|
|
6246
|
+
this.backupBucketEndpoint = parsed.origin;
|
|
6247
|
+
} else this.backupBucketEndpoint = null;
|
|
5313
6248
|
if (this.r2AccessKeyId && this.r2SecretAccessKey) this.r2Client = new AwsClient({
|
|
5314
6249
|
accessKeyId: this.r2AccessKeyId,
|
|
5315
6250
|
secretAccessKey: this.r2SecretAccessKey
|
|
@@ -5344,6 +6279,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5344
6279
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
5345
6280
|
this.tunnelsHandler = null;
|
|
5346
6281
|
this.tunnelExitHandler = null;
|
|
6282
|
+
this.destroyAllTunnels = null;
|
|
5347
6283
|
previousClient.disconnect();
|
|
5348
6284
|
}
|
|
5349
6285
|
if (storedTransport) this.hasStoredTransport = true;
|
|
@@ -5425,6 +6361,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5425
6361
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
5426
6362
|
this.tunnelsHandler = null;
|
|
5427
6363
|
this.tunnelExitHandler = null;
|
|
6364
|
+
this.destroyAllTunnels = null;
|
|
5428
6365
|
previousClient.disconnect();
|
|
5429
6366
|
this.renewActivityTimeout();
|
|
5430
6367
|
this.logger.debug("Transport updated", { transport });
|
|
@@ -5896,6 +6833,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5896
6833
|
let outcome = "error";
|
|
5897
6834
|
let caughtError;
|
|
5898
6835
|
try {
|
|
6836
|
+
await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY);
|
|
6837
|
+
await this.clearActivePreviewPorts();
|
|
6838
|
+
await this.currentRuntime.clear();
|
|
5899
6839
|
if (this.ctx.container?.running) try {
|
|
5900
6840
|
await this.client.desktop.stop();
|
|
5901
6841
|
} catch {}
|
|
@@ -5920,8 +6860,14 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5920
6860
|
await this.deletePasswordFile(mountInfo.passwordFilePath);
|
|
5921
6861
|
}
|
|
5922
6862
|
}
|
|
5923
|
-
|
|
6863
|
+
try {
|
|
6864
|
+
this.ensureTunnelsBuilt();
|
|
6865
|
+
await this.destroyAllTunnels?.();
|
|
6866
|
+
} catch (error) {
|
|
6867
|
+
this.logger.warn("Failed to tear down tunnels during destroy()", { error: error instanceof Error ? error.message : String(error) });
|
|
6868
|
+
}
|
|
5924
6869
|
await this.ctx.storage.delete("tunnels");
|
|
6870
|
+
await this.ctx.storage.delete("tunnels:meta");
|
|
5925
6871
|
this.client.disconnect();
|
|
5926
6872
|
outcome = "success";
|
|
5927
6873
|
await super.destroy();
|
|
@@ -5941,74 +6887,20 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5941
6887
|
}
|
|
5942
6888
|
async onStart() {
|
|
5943
6889
|
this.logger.debug("Sandbox started");
|
|
6890
|
+
await this.currentRuntime.markStarted();
|
|
5944
6891
|
this.checkVersionCompatibility().catch((error) => {
|
|
5945
6892
|
this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
|
|
5946
6893
|
});
|
|
5947
6894
|
try {
|
|
5948
|
-
await this.
|
|
6895
|
+
await pruneTunnelsForRestart(this.ctx.storage);
|
|
5949
6896
|
} catch (error) {
|
|
5950
|
-
this.logger.error("Failed to
|
|
5951
|
-
}
|
|
5952
|
-
try {
|
|
5953
|
-
await this.ctx.storage.delete("tunnels");
|
|
5954
|
-
} catch (error) {
|
|
5955
|
-
this.logger.error("Failed to clear tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
|
|
6897
|
+
this.logger.error("Failed to reconcile tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
|
|
5956
6898
|
}
|
|
5957
6899
|
}
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
* The DO storage holds the source of truth for which ports should be
|
|
5963
|
-
* exposed, which tokens authorize them, and the friendly name (if any)
|
|
5964
|
-
* that the caller set when first exposing the port. If a port is already
|
|
5965
|
-
* exposed on the container this is a no-op for that port. Individual port
|
|
5966
|
-
* failures are logged but do not abort the overall restore — a transient
|
|
5967
|
-
* failure for one port must not prevent the others from being restored.
|
|
5968
|
-
*/
|
|
5969
|
-
async restoreExposedPorts() {
|
|
5970
|
-
const savedTokens = await this.readPortTokens();
|
|
5971
|
-
const portEntries = Object.entries(savedTokens);
|
|
5972
|
-
if (portEntries.length === 0) return;
|
|
5973
|
-
const startTime = Date.now();
|
|
5974
|
-
let restored = 0;
|
|
5975
|
-
let skipped = 0;
|
|
5976
|
-
let failed = 0;
|
|
5977
|
-
const exposedSet = await this.client.ports.getExposedPorts(DISABLE_SESSION_TOKEN).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
|
|
5978
|
-
this.logger.warn("Failed to fetch exposed ports for restore; assuming none exposed", { error: error instanceof Error ? error.message : String(error) });
|
|
5979
|
-
return /* @__PURE__ */ new Set();
|
|
5980
|
-
});
|
|
5981
|
-
for (const [portStr, entry] of portEntries) {
|
|
5982
|
-
const port = Number.parseInt(portStr, 10);
|
|
5983
|
-
if (!Number.isFinite(port) || !validatePort(port)) {
|
|
5984
|
-
this.logger.warn("Skipping restore of invalid port in storage", { port: portStr });
|
|
5985
|
-
failed++;
|
|
5986
|
-
continue;
|
|
5987
|
-
}
|
|
5988
|
-
if (exposedSet.has(port)) {
|
|
5989
|
-
skipped++;
|
|
5990
|
-
continue;
|
|
5991
|
-
}
|
|
5992
|
-
try {
|
|
5993
|
-
await this.client.ports.exposePort(port, DISABLE_SESSION_TOKEN, entry.name);
|
|
5994
|
-
restored++;
|
|
5995
|
-
} catch (error) {
|
|
5996
|
-
failed++;
|
|
5997
|
-
this.logger.warn("Failed to re-expose port on container restart", {
|
|
5998
|
-
port,
|
|
5999
|
-
error: error instanceof Error ? error.message : String(error)
|
|
6000
|
-
});
|
|
6001
|
-
}
|
|
6002
|
-
}
|
|
6003
|
-
logCanonicalEvent(this.logger, {
|
|
6004
|
-
event: "port.restore",
|
|
6005
|
-
outcome: failed === 0 ? "success" : "error",
|
|
6006
|
-
durationMs: Date.now() - startTime,
|
|
6007
|
-
restored,
|
|
6008
|
-
skipped,
|
|
6009
|
-
failed,
|
|
6010
|
-
total: portEntries.length
|
|
6011
|
-
});
|
|
6900
|
+
async stop(signal) {
|
|
6901
|
+
await this.currentRuntime.clear();
|
|
6902
|
+
await this.clearActivePreviewPorts();
|
|
6903
|
+
await super.stop(signal);
|
|
6012
6904
|
}
|
|
6013
6905
|
/**
|
|
6014
6906
|
* Read the `portTokens` map from DO storage, normalizing the legacy
|
|
@@ -6016,12 +6908,32 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6016
6908
|
* ({ token, name? }). The legacy format predates port-name persistence and
|
|
6017
6909
|
* can appear on any DO whose storage was written before that change.
|
|
6018
6910
|
*/
|
|
6019
|
-
async readPortTokens() {
|
|
6020
|
-
const raw = await
|
|
6911
|
+
async readPortTokens(storage = this.ctx.storage) {
|
|
6912
|
+
const raw = await storage.get(PORT_TOKENS_STORAGE_KEY) ?? {};
|
|
6021
6913
|
const normalized = {};
|
|
6022
6914
|
for (const [port, value] of Object.entries(raw)) normalized[port] = typeof value === "string" ? { token: value } : value;
|
|
6023
6915
|
return normalized;
|
|
6024
6916
|
}
|
|
6917
|
+
async readActivePreviewPorts(storage = this.ctx.storage) {
|
|
6918
|
+
return await storage.get(ACTIVE_PREVIEW_PORTS_STORAGE_KEY) ?? {};
|
|
6919
|
+
}
|
|
6920
|
+
async writeActivePreviewPorts(activations, storage = this.ctx.storage) {
|
|
6921
|
+
if (Object.keys(activations).length === 0) {
|
|
6922
|
+
await storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
|
|
6923
|
+
return;
|
|
6924
|
+
}
|
|
6925
|
+
await storage.put(ACTIVE_PREVIEW_PORTS_STORAGE_KEY, activations);
|
|
6926
|
+
}
|
|
6927
|
+
async readPreviewState(storage = this.ctx.storage) {
|
|
6928
|
+
const [tokens, activations] = await Promise.all([this.readPortTokens(storage), this.readActivePreviewPorts(storage)]);
|
|
6929
|
+
return {
|
|
6930
|
+
tokens,
|
|
6931
|
+
activations
|
|
6932
|
+
};
|
|
6933
|
+
}
|
|
6934
|
+
async clearActivePreviewPorts() {
|
|
6935
|
+
await this.ctx.storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
|
|
6936
|
+
}
|
|
6025
6937
|
/**
|
|
6026
6938
|
* Check if the container version matches the SDK version
|
|
6027
6939
|
* Logs a warning if there's a mismatch
|
|
@@ -6054,6 +6966,13 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6054
6966
|
this.containerGeneration++;
|
|
6055
6967
|
this.defaultSession = null;
|
|
6056
6968
|
this.defaultSessionInit = null;
|
|
6969
|
+
await this.currentRuntime.clear();
|
|
6970
|
+
await this.clearActivePreviewPorts();
|
|
6971
|
+
try {
|
|
6972
|
+
await pruneTunnelsForRestart(this.ctx.storage);
|
|
6973
|
+
} catch (error) {
|
|
6974
|
+
this.logger.error("Failed to reconcile tunnel storage after container stop", error instanceof Error ? error : new Error(String(error)));
|
|
6975
|
+
}
|
|
6057
6976
|
this.client.disconnect();
|
|
6058
6977
|
let hadR2EgressMount = false;
|
|
6059
6978
|
for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
|
|
@@ -6275,6 +7194,99 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6275
7194
|
await super.onActivityExpired();
|
|
6276
7195
|
}
|
|
6277
7196
|
}
|
|
7197
|
+
isPreviewProxyRequest(request) {
|
|
7198
|
+
return request.headers.get(PREVIEW_PROXY_HEADER) === "1";
|
|
7199
|
+
}
|
|
7200
|
+
invalidPreviewTokenResponse() {
|
|
7201
|
+
return new Response(JSON.stringify({
|
|
7202
|
+
error: "Access denied: Invalid token or port not exposed",
|
|
7203
|
+
code: "INVALID_TOKEN"
|
|
7204
|
+
}), {
|
|
7205
|
+
status: 404,
|
|
7206
|
+
headers: { "Content-Type": "application/json" }
|
|
7207
|
+
});
|
|
7208
|
+
}
|
|
7209
|
+
stalePreviewURLResponse() {
|
|
7210
|
+
return new Response(JSON.stringify({
|
|
7211
|
+
error: "Preview URL is stale because the sandbox runtime is not active",
|
|
7212
|
+
code: "STALE_PREVIEW_URL"
|
|
7213
|
+
}), {
|
|
7214
|
+
status: 410,
|
|
7215
|
+
headers: { "Content-Type": "application/json" }
|
|
7216
|
+
});
|
|
7217
|
+
}
|
|
7218
|
+
getPreviewForwardingContainer() {
|
|
7219
|
+
return this.ctx.container;
|
|
7220
|
+
}
|
|
7221
|
+
beginPreviewForward() {
|
|
7222
|
+
const lifecycle = this;
|
|
7223
|
+
lifecycle.inflightRequests = (lifecycle.inflightRequests ?? 0) + 1;
|
|
7224
|
+
this.renewActivityTimeout();
|
|
7225
|
+
let settled = false;
|
|
7226
|
+
return () => {
|
|
7227
|
+
if (settled) return;
|
|
7228
|
+
settled = true;
|
|
7229
|
+
lifecycle.inflightRequests = Math.max(0, (lifecycle.inflightRequests ?? 0) - 1);
|
|
7230
|
+
if (lifecycle.inflightRequests === 0) this.renewActivityTimeout();
|
|
7231
|
+
};
|
|
7232
|
+
}
|
|
7233
|
+
async fetchPreviewIfRunning(request, port, runtime) {
|
|
7234
|
+
const container = this.getPreviewForwardingContainer();
|
|
7235
|
+
const state = await this.getState();
|
|
7236
|
+
if (!container?.running || state.status !== "healthy") return this.stalePreviewURLResponse();
|
|
7237
|
+
if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
|
|
7238
|
+
const result = await forwardPreviewRequest(container.getTcpPort(port), request, {
|
|
7239
|
+
beginForward: () => this.beginPreviewForward(),
|
|
7240
|
+
renewActivity: () => this.renewActivityTimeout()
|
|
7241
|
+
});
|
|
7242
|
+
if (result.status === "network-lost") {
|
|
7243
|
+
if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
|
|
7244
|
+
return new Response("Container suddenly disconnected, try again", { status: 500 });
|
|
7245
|
+
}
|
|
7246
|
+
return result.response;
|
|
7247
|
+
}
|
|
7248
|
+
buildPreviewProxyRequest(request, port, sandboxId) {
|
|
7249
|
+
const url = new URL(request.url);
|
|
7250
|
+
const proxyUrl = `http://localhost:${port}${url.pathname}${url.search}`;
|
|
7251
|
+
const headers = new Headers(request.headers);
|
|
7252
|
+
for (const header of PREVIEW_PROXY_HEADERS) headers.delete(header);
|
|
7253
|
+
headers.set("X-Original-URL", request.url);
|
|
7254
|
+
headers.set("X-Forwarded-Host", url.hostname);
|
|
7255
|
+
headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
|
|
7256
|
+
headers.set("X-Sandbox-Name", this.sandboxName ?? sandboxId);
|
|
7257
|
+
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return new Request(request, {
|
|
7258
|
+
headers,
|
|
7259
|
+
redirect: "manual"
|
|
7260
|
+
});
|
|
7261
|
+
return new Request(proxyUrl, {
|
|
7262
|
+
method: request.method,
|
|
7263
|
+
headers,
|
|
7264
|
+
body: request.body,
|
|
7265
|
+
duplex: "half",
|
|
7266
|
+
redirect: "manual"
|
|
7267
|
+
});
|
|
7268
|
+
}
|
|
7269
|
+
async proxyPreviewRequest(request) {
|
|
7270
|
+
const portValue = request.headers.get(PREVIEW_PROXY_PORT_HEADER);
|
|
7271
|
+
const token = request.headers.get(PREVIEW_PROXY_TOKEN_HEADER);
|
|
7272
|
+
const sandboxId = request.headers.get(PREVIEW_PROXY_SANDBOX_ID_HEADER);
|
|
7273
|
+
const port = portValue === null ? NaN : Number.parseInt(portValue, 10);
|
|
7274
|
+
if (!Number.isFinite(port) || !validatePort(port) || !token || !sandboxId) return this.invalidPreviewTokenResponse();
|
|
7275
|
+
const proxyRequest = this.buildPreviewProxyRequest(request, port, sandboxId);
|
|
7276
|
+
const validation = await this.validatePreviewURLForRuntime(port, token);
|
|
7277
|
+
if (validation.status === "invalid") return this.invalidPreviewTokenResponse();
|
|
7278
|
+
if (validation.status === "stale") {
|
|
7279
|
+
this.logger.warn("Stale preview URL blocked", {
|
|
7280
|
+
port,
|
|
7281
|
+
sandboxId,
|
|
7282
|
+
containerStatus: validation.containerStatus,
|
|
7283
|
+
reason: validation.reason,
|
|
7284
|
+
method: request.method
|
|
7285
|
+
});
|
|
7286
|
+
return this.stalePreviewURLResponse();
|
|
7287
|
+
}
|
|
7288
|
+
return await this.fetchPreviewIfRunning(proxyRequest, port, validation.runtime);
|
|
7289
|
+
}
|
|
6278
7290
|
async fetch(request) {
|
|
6279
7291
|
const traceId = TraceContext.fromHeaders(request.headers) || TraceContext.generate();
|
|
6280
7292
|
const requestLogger = this.logger.child({
|
|
@@ -6282,6 +7294,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6282
7294
|
operation: "fetch"
|
|
6283
7295
|
});
|
|
6284
7296
|
const url = new URL(request.url);
|
|
7297
|
+
if (this.isPreviewProxyRequest(request)) return await this.proxyPreviewRequest(request);
|
|
6285
7298
|
if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
|
|
6286
7299
|
const name = request.headers.get("X-Sandbox-Name");
|
|
6287
7300
|
this.sandboxName = name;
|
|
@@ -7015,17 +8028,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7015
8028
|
*/
|
|
7016
8029
|
async getDesktopStreamUrl(hostname, options) {
|
|
7017
8030
|
if ((await this.client.desktop.status()).status === "inactive") throw new Error("Desktop is not running. Call sandbox.desktop.start() first.");
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
token: options?.token
|
|
7023
|
-
})).url;
|
|
7024
|
-
} catch {
|
|
7025
|
-
const existingEntry = (await this.readPortTokens())["6080"];
|
|
7026
|
-
if (existingEntry && this.sandboxName) url = this.constructPreviewUrl(6080, this.sandboxName, hostname, existingEntry.token);
|
|
7027
|
-
else throw new Error("Failed to get desktop stream URL: port 6080 could not be exposed and no existing token found.");
|
|
7028
|
-
}
|
|
8031
|
+
const url = (await this.exposePort(6080, {
|
|
8032
|
+
hostname,
|
|
8033
|
+
token: options?.token
|
|
8034
|
+
})).url;
|
|
7029
8035
|
try {
|
|
7030
8036
|
await this.waitForPort({
|
|
7031
8037
|
portToCheck: 6080,
|
|
@@ -7084,11 +8090,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7084
8090
|
/**
|
|
7085
8091
|
* Expose a port and get a preview URL for accessing services running in the sandbox
|
|
7086
8092
|
*
|
|
7087
|
-
* Preview
|
|
7088
|
-
*
|
|
7089
|
-
*
|
|
7090
|
-
*
|
|
7091
|
-
* `destroy()`.
|
|
8093
|
+
* Preview URL authorization survives transient container restarts, but
|
|
8094
|
+
* forwarding is active only for the runtime where `exposePort()` was last
|
|
8095
|
+
* called. Call `exposePort()` again after a restart to reactivate an
|
|
8096
|
+
* existing URL for the current runtime.
|
|
7092
8097
|
*
|
|
7093
8098
|
* @param port - Port number to expose (1024-65535)
|
|
7094
8099
|
* @param options - Configuration options
|
|
@@ -7125,27 +8130,33 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7125
8130
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
7126
8131
|
});
|
|
7127
8132
|
if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
const
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
8133
|
+
if (options.token !== void 0) this.validateCustomToken(options.token);
|
|
8134
|
+
await this.ensureDefaultSession();
|
|
8135
|
+
let runtime = await this.currentRuntime.get();
|
|
8136
|
+
runtime = runtime ?? await this.currentRuntime.markStarted();
|
|
8137
|
+
await this.currentRuntime.assertActive(runtime);
|
|
8138
|
+
const token = await this.ctx.storage.transaction(async (txn) => {
|
|
8139
|
+
const tokens = await this.readPortTokens(txn);
|
|
8140
|
+
const existingEntry = tokens[port.toString()];
|
|
8141
|
+
const nextToken = options.token ?? existingEntry?.token ?? this.generatePortToken();
|
|
8142
|
+
const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === nextToken && p !== port.toString());
|
|
8143
|
+
if (existingPort) throw new SandboxSecurityError(`Token '${nextToken}' is already in use by port ${existingPort[0]}. Please use a different token.`);
|
|
8144
|
+
const activations = await this.readActivePreviewPorts(txn);
|
|
8145
|
+
tokens[port.toString()] = {
|
|
8146
|
+
token: nextToken,
|
|
8147
|
+
name: options.name
|
|
8148
|
+
};
|
|
8149
|
+
activations[port.toString()] = runtime.scope({ token: nextToken });
|
|
8150
|
+
await Promise.all([txn.put(PORT_TOKENS_STORAGE_KEY, tokens), this.writeActivePreviewPorts(activations, txn)]);
|
|
8151
|
+
return nextToken;
|
|
8152
|
+
});
|
|
8153
|
+
await this.currentRuntime.assertActive(runtime);
|
|
7143
8154
|
const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname, token);
|
|
7144
8155
|
outcome = "success";
|
|
7145
8156
|
return {
|
|
7146
8157
|
url,
|
|
7147
8158
|
port,
|
|
7148
|
-
name: options
|
|
8159
|
+
name: options.name
|
|
7149
8160
|
};
|
|
7150
8161
|
} catch (error) {
|
|
7151
8162
|
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -7156,29 +8167,37 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7156
8167
|
outcome,
|
|
7157
8168
|
port,
|
|
7158
8169
|
durationMs: Date.now() - exposeStartTime,
|
|
7159
|
-
name: options
|
|
8170
|
+
name: options.name,
|
|
7160
8171
|
hostname: options.hostname,
|
|
7161
8172
|
error: caughtError
|
|
7162
8173
|
});
|
|
7163
8174
|
}
|
|
7164
8175
|
}
|
|
8176
|
+
/**
|
|
8177
|
+
* Revoke preview URL authorization and current-runtime activation for a port.
|
|
8178
|
+
*
|
|
8179
|
+
* Revocation is idempotent: calling this for a port with no preview state is
|
|
8180
|
+
* still successful. The operation clears Durable Object-owned preview state
|
|
8181
|
+
* only and does not contact, probe, wake, or clean up the container runtime.
|
|
8182
|
+
*/
|
|
7165
8183
|
async unexposePort(port) {
|
|
7166
8184
|
const unexposeStartTime = Date.now();
|
|
7167
8185
|
let outcome = "error";
|
|
7168
8186
|
let caughtError;
|
|
7169
8187
|
try {
|
|
7170
8188
|
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
8189
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
8190
|
+
const tokens = await this.readPortTokens(txn);
|
|
8191
|
+
if (tokens[port.toString()]) {
|
|
8192
|
+
delete tokens[port.toString()];
|
|
8193
|
+
await txn.put(PORT_TOKENS_STORAGE_KEY, tokens);
|
|
8194
|
+
}
|
|
8195
|
+
const activations = await this.readActivePreviewPorts(txn);
|
|
8196
|
+
if (activations[port.toString()]) {
|
|
8197
|
+
delete activations[port.toString()];
|
|
8198
|
+
await this.writeActivePreviewPorts(activations, txn);
|
|
8199
|
+
}
|
|
8200
|
+
});
|
|
7182
8201
|
outcome = "success";
|
|
7183
8202
|
} catch (error) {
|
|
7184
8203
|
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -7193,23 +8212,17 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7193
8212
|
});
|
|
7194
8213
|
}
|
|
7195
8214
|
}
|
|
8215
|
+
/**
|
|
8216
|
+
* Returns preview URLs that are currently forwardable in the active runtime.
|
|
8217
|
+
* Durable authorization without current-runtime activation is omitted.
|
|
8218
|
+
*/
|
|
7196
8219
|
async getExposedPorts(hostname) {
|
|
7197
|
-
const sessionId = this.serializeExecutionContext(await this.resolveExecution());
|
|
7198
|
-
const response = await this.client.ports.getExposedPorts(sessionId);
|
|
7199
8220
|
if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
return [];
|
|
7206
|
-
}
|
|
7207
|
-
return [{
|
|
7208
|
-
url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, entry.token),
|
|
7209
|
-
port: port.port,
|
|
7210
|
-
status: port.status
|
|
7211
|
-
}];
|
|
7212
|
-
});
|
|
8221
|
+
return (await this.getCurrentPreviewPorts()).map(({ port, entry }) => ({
|
|
8222
|
+
url: this.constructPreviewUrl(port, this.sandboxName, hostname, entry.token),
|
|
8223
|
+
port,
|
|
8224
|
+
status: "active"
|
|
8225
|
+
}));
|
|
7213
8226
|
}
|
|
7214
8227
|
/**
|
|
7215
8228
|
* Namespaced tunnel API. Quick tunnels are zero-config preview URLs
|
|
@@ -7245,26 +8258,172 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7245
8258
|
const built = createTunnelsHandler({
|
|
7246
8259
|
client: this.client,
|
|
7247
8260
|
storage: this.ctx.storage,
|
|
7248
|
-
logger: this.logger
|
|
8261
|
+
logger: this.logger,
|
|
8262
|
+
sandboxId: this.ctx.id.toString(),
|
|
8263
|
+
getNamedTunnelConfig: async () => {
|
|
8264
|
+
const envObj = this.env;
|
|
8265
|
+
const token = getEnvString(envObj, "CLOUDFLARE_API_TOKEN");
|
|
8266
|
+
if (!token) throw new Error("Named tunnels require CLOUDFLARE_API_TOKEN. Set it as a secret in your wrangler.jsonc.");
|
|
8267
|
+
const accountId = await this.getTunnelAccountId();
|
|
8268
|
+
return {
|
|
8269
|
+
token,
|
|
8270
|
+
accountId,
|
|
8271
|
+
zoneId: await this.getTunnelZoneId(token, accountId)
|
|
8272
|
+
};
|
|
8273
|
+
}
|
|
7249
8274
|
});
|
|
7250
8275
|
this.tunnelsHandler = built.tunnels;
|
|
7251
8276
|
this.tunnelExitHandler = built.handleTunnelExit;
|
|
8277
|
+
this.destroyAllTunnels = built.destroyAll;
|
|
7252
8278
|
}
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
8279
|
+
/**
|
|
8280
|
+
* Resolve the Cloudflare account id used for named-tunnel provisioning.
|
|
8281
|
+
*
|
|
8282
|
+
* Memoised for the lifetime of this DO instance. The first call may hit
|
|
8283
|
+
* `GET /user/tokens/verify` to derive the account id from the configured
|
|
8284
|
+
* `CLOUDFLARE_API_TOKEN`; subsequent calls return the cached promise.
|
|
8285
|
+
*
|
|
8286
|
+
* Only successful resolutions are cached: a rejected lookup clears the
|
|
8287
|
+
* slot so the next caller retries. Otherwise a transient failure on
|
|
8288
|
+
* first use would permanently poison every later named-tunnel `get()`
|
|
8289
|
+
* on this DO instance.
|
|
8290
|
+
*/
|
|
8291
|
+
getTunnelAccountId() {
|
|
8292
|
+
if (!this.tunnelAccountIdPromise) {
|
|
8293
|
+
const pending = resolveAccountId(this.env, { overrideKey: "CLOUDFLARE_TUNNEL_ACCOUNT_ID" });
|
|
8294
|
+
this.tunnelAccountIdPromise = pending;
|
|
8295
|
+
pending.catch(() => {
|
|
8296
|
+
if (this.tunnelAccountIdPromise === pending) this.tunnelAccountIdPromise = null;
|
|
8297
|
+
});
|
|
8298
|
+
}
|
|
8299
|
+
return this.tunnelAccountIdPromise;
|
|
8300
|
+
}
|
|
8301
|
+
/**
|
|
8302
|
+
* Resolve the Cloudflare zone id used for named-tunnel provisioning.
|
|
8303
|
+
*
|
|
8304
|
+
* Memoised for the lifetime of this DO instance. Falls back to the
|
|
8305
|
+
* single zone the token can see under `accountId` via `GET /zones`
|
|
8306
|
+
* when `CLOUDFLARE_ZONE_ID` is not set. Failed lookups clear the cache
|
|
8307
|
+
* so the next caller retries — see `getTunnelAccountId` for the
|
|
8308
|
+
* rationale.
|
|
8309
|
+
*/
|
|
8310
|
+
getTunnelZoneId(token, accountId) {
|
|
8311
|
+
if (!this.tunnelZoneIdPromise) {
|
|
8312
|
+
const pending = resolveZoneId(this.env, {
|
|
8313
|
+
token,
|
|
8314
|
+
accountId
|
|
8315
|
+
});
|
|
8316
|
+
this.tunnelZoneIdPromise = pending;
|
|
8317
|
+
pending.catch(() => {
|
|
8318
|
+
if (this.tunnelZoneIdPromise === pending) this.tunnelZoneIdPromise = null;
|
|
8319
|
+
});
|
|
7260
8320
|
}
|
|
8321
|
+
return this.tunnelZoneIdPromise;
|
|
8322
|
+
}
|
|
8323
|
+
/**
|
|
8324
|
+
* Returns whether a port is currently preview-forwardable.
|
|
8325
|
+
* This checks Durable Object-owned auth and runtime activation without
|
|
8326
|
+
* contacting or waking the container.
|
|
8327
|
+
*/
|
|
8328
|
+
async isPortExposed(port) {
|
|
8329
|
+
if (!validatePort(port)) return false;
|
|
8330
|
+
return (await this.getCurrentPreviewPorts()).some((activePort) => activePort.port === port);
|
|
7261
8331
|
}
|
|
8332
|
+
/**
|
|
8333
|
+
* Checks durable preview URL authorization for a port/token pair.
|
|
8334
|
+
*
|
|
8335
|
+
* This does not check whether the port is activated for the current runtime
|
|
8336
|
+
* and is not sufficient to decide whether preview traffic may forward.
|
|
8337
|
+
*/
|
|
7262
8338
|
async validatePortToken(port, token) {
|
|
7263
8339
|
const entry = (await this.readPortTokens())[port.toString()];
|
|
7264
8340
|
if (!entry) return false;
|
|
8341
|
+
return this.previewTokensMatch(entry.token, token);
|
|
8342
|
+
}
|
|
8343
|
+
async validatePreviewURLForRuntime(port, token) {
|
|
8344
|
+
const containerState = await this.getState();
|
|
8345
|
+
const containerRunning = this.ctx.container?.running === true;
|
|
8346
|
+
const { tokens, activations, runtime } = await this.ctx.storage.transaction(async (txn) => {
|
|
8347
|
+
const [previewState, runtime$1] = await Promise.all([this.readPreviewState(txn), this.currentRuntime.getStored(txn)]);
|
|
8348
|
+
return {
|
|
8349
|
+
...previewState,
|
|
8350
|
+
runtime: runtime$1
|
|
8351
|
+
};
|
|
8352
|
+
});
|
|
8353
|
+
const entry = tokens[port.toString()];
|
|
8354
|
+
if (!entry) return { status: "invalid" };
|
|
8355
|
+
if (!this.previewTokensMatch(entry.token, token)) return { status: "invalid" };
|
|
8356
|
+
if (containerState.status !== "healthy") return {
|
|
8357
|
+
status: "stale",
|
|
8358
|
+
reason: "runtime-not-healthy",
|
|
8359
|
+
containerStatus: containerState.status
|
|
8360
|
+
};
|
|
8361
|
+
if (!containerRunning) return {
|
|
8362
|
+
status: "stale",
|
|
8363
|
+
reason: "runtime-not-running",
|
|
8364
|
+
containerStatus: containerState.status
|
|
8365
|
+
};
|
|
8366
|
+
if (!runtime) return {
|
|
8367
|
+
status: "stale",
|
|
8368
|
+
reason: "missing-runtime-id",
|
|
8369
|
+
containerStatus: containerState.status
|
|
8370
|
+
};
|
|
8371
|
+
const activation = activations[port.toString()];
|
|
8372
|
+
if (!activation) return {
|
|
8373
|
+
status: "stale",
|
|
8374
|
+
reason: "missing-activation",
|
|
8375
|
+
containerStatus: containerState.status
|
|
8376
|
+
};
|
|
8377
|
+
if (!runtime.owns(activation)) return {
|
|
8378
|
+
status: "stale",
|
|
8379
|
+
reason: "runtime-mismatch",
|
|
8380
|
+
containerStatus: containerState.status
|
|
8381
|
+
};
|
|
8382
|
+
if (!this.previewTokensMatch(activation.token, token)) {
|
|
8383
|
+
this.logger.warn("Preview URL activation token mismatch", {
|
|
8384
|
+
port,
|
|
8385
|
+
runtimeIdentityID: runtime.id
|
|
8386
|
+
});
|
|
8387
|
+
return {
|
|
8388
|
+
status: "stale",
|
|
8389
|
+
reason: "token-mismatch",
|
|
8390
|
+
containerStatus: containerState.status
|
|
8391
|
+
};
|
|
8392
|
+
}
|
|
8393
|
+
return {
|
|
8394
|
+
status: "active",
|
|
8395
|
+
runtime
|
|
8396
|
+
};
|
|
8397
|
+
}
|
|
8398
|
+
async getCurrentPreviewPorts() {
|
|
8399
|
+
const containerState = await this.getState();
|
|
8400
|
+
const containerRunning = this.ctx.container?.running === true;
|
|
8401
|
+
const { tokens, activations, runtime } = await this.ctx.storage.transaction(async (txn) => {
|
|
8402
|
+
const [previewState, runtime$1] = await Promise.all([this.readPreviewState(txn), this.currentRuntime.getStored(txn)]);
|
|
8403
|
+
return {
|
|
8404
|
+
...previewState,
|
|
8405
|
+
runtime: runtime$1
|
|
8406
|
+
};
|
|
8407
|
+
});
|
|
8408
|
+
if (containerState.status !== "healthy" || !containerRunning || !runtime) return [];
|
|
8409
|
+
const activePorts = [];
|
|
8410
|
+
for (const [portKey, activation] of Object.entries(activations)) {
|
|
8411
|
+
const port = Number.parseInt(portKey, 10);
|
|
8412
|
+
const entry = tokens[portKey];
|
|
8413
|
+
if (!entry || !Number.isInteger(port) || !validatePort(port)) continue;
|
|
8414
|
+
if (!runtime.owns(activation)) continue;
|
|
8415
|
+
if (!this.previewTokensMatch(entry.token, activation.token)) continue;
|
|
8416
|
+
activePorts.push({
|
|
8417
|
+
port,
|
|
8418
|
+
entry
|
|
8419
|
+
});
|
|
8420
|
+
}
|
|
8421
|
+
return activePorts.sort((a, b) => a.port - b.port);
|
|
8422
|
+
}
|
|
8423
|
+
previewTokensMatch(expected, actual) {
|
|
7265
8424
|
const encoder = new TextEncoder();
|
|
7266
|
-
const a = encoder.encode(
|
|
7267
|
-
const b = encoder.encode(
|
|
8425
|
+
const a = encoder.encode(expected);
|
|
8426
|
+
const b = encoder.encode(actual);
|
|
7268
8427
|
try {
|
|
7269
8428
|
return crypto.subtle.timingSafeEqual(a, b);
|
|
7270
8429
|
} catch {
|
|
@@ -7596,10 +8755,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7596
8755
|
* Returns validated presigned URL configuration or throws if not configured.
|
|
7597
8756
|
* All credential fields plus the R2 binding are required for backup to work.
|
|
7598
8757
|
*/
|
|
7599
|
-
|
|
8758
|
+
requirePresignedURLSupport() {
|
|
7600
8759
|
if (!this.r2Client || !this.r2AccountId || !this.backupBucketName) {
|
|
7601
8760
|
const missing = [];
|
|
7602
|
-
if (!this.r2AccountId) missing.push("CLOUDFLARE_ACCOUNT_ID");
|
|
8761
|
+
if (!this.r2AccountId) missing.push("CLOUDFLARE_R2_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID");
|
|
7603
8762
|
if (!this.r2AccessKeyId) missing.push("R2_ACCESS_KEY_ID");
|
|
7604
8763
|
if (!this.r2SecretAccessKey) missing.push("R2_SECRET_ACCESS_KEY");
|
|
7605
8764
|
if (!this.backupBucketName) missing.push("BACKUP_BUCKET_NAME");
|
|
@@ -7617,15 +8776,21 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7617
8776
|
bucketName: this.backupBucketName
|
|
7618
8777
|
};
|
|
7619
8778
|
}
|
|
8779
|
+
getBackupBucketEndpoint(accountId) {
|
|
8780
|
+
return this.backupBucketEndpoint ?? `https://${accountId}.r2.cloudflarestorage.com`;
|
|
8781
|
+
}
|
|
8782
|
+
getBackupObjectURL(accountId, bucketName, r2Key) {
|
|
8783
|
+
const encodedBucket = encodeURIComponent(bucketName);
|
|
8784
|
+
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
8785
|
+
return new URL(`${this.getBackupBucketEndpoint(accountId)}/${encodedBucket}/${encodedKey}`);
|
|
8786
|
+
}
|
|
7620
8787
|
/**
|
|
7621
8788
|
* Generate a presigned GET URL for downloading an object from R2.
|
|
7622
8789
|
* The container can curl this URL directly without credentials.
|
|
7623
8790
|
*/
|
|
7624
|
-
async
|
|
7625
|
-
const { client, accountId, bucketName } = this.
|
|
7626
|
-
const
|
|
7627
|
-
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
7628
|
-
const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
|
|
8791
|
+
async generatePresignedGetURL(r2Key) {
|
|
8792
|
+
const { client, accountId, bucketName } = this.requirePresignedURLSupport();
|
|
8793
|
+
const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
|
|
7629
8794
|
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
7630
8795
|
return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
|
|
7631
8796
|
}
|
|
@@ -7633,11 +8798,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7633
8798
|
* Generate a presigned PUT URL for uploading an object to R2.
|
|
7634
8799
|
* The container can curl PUT to this URL directly without credentials.
|
|
7635
8800
|
*/
|
|
7636
|
-
async
|
|
7637
|
-
const { client, accountId, bucketName } = this.
|
|
7638
|
-
const
|
|
7639
|
-
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
7640
|
-
const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
|
|
8801
|
+
async generatePresignedPutURL(r2Key) {
|
|
8802
|
+
const { client, accountId, bucketName } = this.requirePresignedURLSupport();
|
|
8803
|
+
const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
|
|
7641
8804
|
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
7642
8805
|
return (await client.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } })).url;
|
|
7643
8806
|
}
|
|
@@ -7647,7 +8810,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7647
8810
|
* ~24 MB/s throughput vs ~0.6 MB/s for base64 readFile.
|
|
7648
8811
|
*/
|
|
7649
8812
|
async uploadBackupPresigned(archivePath, r2Key, archiveSize, backupId, dir, backupSession) {
|
|
7650
|
-
const
|
|
8813
|
+
const presignedURL = await this.generatePresignedPutURL(r2Key);
|
|
7651
8814
|
const curlCmd = [
|
|
7652
8815
|
"curl -sSf",
|
|
7653
8816
|
"-X PUT",
|
|
@@ -7657,7 +8820,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7657
8820
|
"--retry 2",
|
|
7658
8821
|
"--retry-max-time 60",
|
|
7659
8822
|
`-T ${shellEscape(archivePath)}`,
|
|
7660
|
-
shellEscape(
|
|
8823
|
+
shellEscape(presignedURL)
|
|
7661
8824
|
].join(" ");
|
|
7662
8825
|
const result = await this.execWithSession(curlCmd, backupSession, {
|
|
7663
8826
|
timeout: 181e4,
|
|
@@ -7691,11 +8854,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7691
8854
|
/**
|
|
7692
8855
|
* Generate a presigned PUT URL for a single part in a multipart upload.
|
|
7693
8856
|
*/
|
|
7694
|
-
async
|
|
7695
|
-
const { client, accountId, bucketName } = this.
|
|
7696
|
-
const
|
|
7697
|
-
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
7698
|
-
const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
|
|
8857
|
+
async generatePresignedPartURL(r2Key, uploadId, partNumber) {
|
|
8858
|
+
const { client, accountId, bucketName } = this.requirePresignedURLSupport();
|
|
8859
|
+
const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
|
|
7699
8860
|
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
7700
8861
|
url.searchParams.set("partNumber", String(partNumber));
|
|
7701
8862
|
url.searchParams.set("uploadId", uploadId);
|
|
@@ -7710,9 +8871,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7710
8871
|
const targetParts = calculatePartCount(sizeBytes, BACKUP_MULTIPART_TARGET_PARTS, BACKUP_MULTIPART_MAX_PARTS);
|
|
7711
8872
|
const numParts = Math.min(targetParts, Math.floor(sizeBytes / BACKUP_MULTIPART_MIN_PART_SIZE));
|
|
7712
8873
|
if (numParts <= 1) return this.uploadBackupPresigned(archivePath, r2Key, sizeBytes, backupId, dir, backupSession);
|
|
7713
|
-
const { client, accountId, bucketName } = this.
|
|
7714
|
-
const
|
|
7715
|
-
const createResp = await client.fetch(`${
|
|
8874
|
+
const { client, accountId, bucketName } = this.requirePresignedURLSupport();
|
|
8875
|
+
const objectURL = this.getBackupObjectURL(accountId, bucketName, r2Key).toString();
|
|
8876
|
+
const createResp = await client.fetch(`${objectURL}?uploads`, { method: "POST" });
|
|
7716
8877
|
if (!createResp.ok) throw new BackupCreateError({
|
|
7717
8878
|
message: `Failed to initiate multipart upload: HTTP ${createResp.status}`,
|
|
7718
8879
|
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
@@ -7735,7 +8896,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7735
8896
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
7736
8897
|
});
|
|
7737
8898
|
const abortMultipart = async () => {
|
|
7738
|
-
await client.fetch(`${
|
|
8899
|
+
await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
|
|
7739
8900
|
};
|
|
7740
8901
|
try {
|
|
7741
8902
|
const partSize = Math.ceil(sizeBytes / numParts);
|
|
@@ -7746,7 +8907,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7746
8907
|
size: i === numParts - 1 ? sizeBytes - i * partSize : partSize
|
|
7747
8908
|
})).map(async (part) => ({
|
|
7748
8909
|
...part,
|
|
7749
|
-
url: await this.
|
|
8910
|
+
url: await this.generatePresignedPartURL(r2Key, uploadId, part.partNumber)
|
|
7750
8911
|
})));
|
|
7751
8912
|
let uploadResult;
|
|
7752
8913
|
try {
|
|
@@ -7777,7 +8938,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7777
8938
|
...uploadResult.parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`),
|
|
7778
8939
|
"</CompleteMultipartUpload>"
|
|
7779
8940
|
].join("");
|
|
7780
|
-
const completeResp = await client.fetch(`${
|
|
8941
|
+
const completeResp = await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, {
|
|
7781
8942
|
method: "POST",
|
|
7782
8943
|
headers: { "Content-Type": "application/xml" },
|
|
7783
8944
|
body: completeXml
|
|
@@ -7819,7 +8980,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7819
8980
|
* with dd using byte offsets, then atomically moved to the final path.
|
|
7820
8981
|
*/
|
|
7821
8982
|
async downloadBackupParallel(archivePath, r2Key, expectedSize, backupId, dir, backupSession) {
|
|
7822
|
-
const
|
|
8983
|
+
const presignedURL = await this.generatePresignedGetURL(r2Key);
|
|
7823
8984
|
await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
|
|
7824
8985
|
const tmpPath = `${archivePath}.tmp`;
|
|
7825
8986
|
if (expectedSize < BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE) {
|
|
@@ -7830,7 +8991,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7830
8991
|
"--retry 2",
|
|
7831
8992
|
"--retry-max-time 60",
|
|
7832
8993
|
`-o ${shellEscape(tmpPath)}`,
|
|
7833
|
-
shellEscape(
|
|
8994
|
+
shellEscape(presignedURL)
|
|
7834
8995
|
].join(" ");
|
|
7835
8996
|
const result = await this.execWithSession(curlCmd, backupSession, {
|
|
7836
8997
|
timeout: 181e4,
|
|
@@ -7863,7 +9024,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7863
9024
|
"--connect-timeout 10",
|
|
7864
9025
|
"--max-time 1800",
|
|
7865
9026
|
`-H ${shellEscape(`Range: bytes=${range}`)}`,
|
|
7866
|
-
shellEscape(
|
|
9027
|
+
shellEscape(presignedURL),
|
|
7867
9028
|
"|",
|
|
7868
9029
|
"dd",
|
|
7869
9030
|
`of=${shellEscape(tmpPath)}`,
|
|
@@ -7969,7 +9130,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7969
9130
|
}
|
|
7970
9131
|
async doCreateBackup(options) {
|
|
7971
9132
|
const bucket = this.requireBackupBucket();
|
|
7972
|
-
this.
|
|
9133
|
+
this.requirePresignedURLSupport();
|
|
7973
9134
|
const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [], compression, multipart = true } = options;
|
|
7974
9135
|
const backupStartTime = Date.now();
|
|
7975
9136
|
let backupId;
|
|
@@ -8269,7 +9430,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8269
9430
|
async doRestoreBackup(backup) {
|
|
8270
9431
|
const restoreStartTime = Date.now();
|
|
8271
9432
|
const bucket = this.requireBackupBucket();
|
|
8272
|
-
this.
|
|
9433
|
+
this.requirePresignedURLSupport();
|
|
8273
9434
|
const { id, dir } = backup;
|
|
8274
9435
|
let outcome = "error";
|
|
8275
9436
|
let caughtError;
|
|
@@ -8539,5 +9700,5 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8539
9700
|
};
|
|
8540
9701
|
|
|
8541
9702
|
//#endregion
|
|
8542
|
-
export {
|
|
8543
|
-
//# sourceMappingURL=sandbox-
|
|
9703
|
+
export { DesktopClient as A, DesktopProcessCrashedError as B, streamFile as C, PortClient as D, ProcessClient as E, BackupNotFoundError as F, ProcessReadyTimeoutError as G, DesktopUnavailableError as H, BackupRestoreError as I, RPCTransportError as K, DesktopInvalidCoordinatesError as L, BackupClient as M, BackupCreateError as N, GitClient as O, BackupExpiredError as P, DesktopInvalidOptionsError as R, collectFile as S, UtilityClient as T, InvalidBackupConfigError as U, DesktopStartFailedError as V, ProcessExitedBeforeReadyError as W, CodeInterpreter as _, PREVIEW_PROXY_HEADERS as a, validatePort as b, PREVIEW_PROXY_TOKEN_HEADER as c, InvalidMountConfigError as d, MissingCredentialsError as f, responseToAsyncIterable as g, parseSSEStream as h, PREVIEW_PROXY_HEADER as i, CommandClient as j, FileClient as k, BucketMountError as l, asyncIterableToSSEStream as m, getSandbox as n, PREVIEW_PROXY_PORT_HEADER as o, S3FSMountError as p, SessionTerminatedError as q, proxyTerminal as r, PREVIEW_PROXY_SANDBOX_ID_HEADER as s, Sandbox as t, BucketUnmountError as u, SandboxSecurityError as v, SandboxClient as w, validateTunnelName as x, sanitizeSandboxId as y, DesktopNotStartedError as z };
|
|
9704
|
+
//# sourceMappingURL=sandbox-DQxTkLyY.js.map
|