@cloudflare/sandbox 0.10.2 → 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/Dockerfile +51 -20
- package/README.md +0 -2
- 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.d.ts.map +1 -1
- package/dist/opencode/index.js +1 -1
- package/dist/{sandbox-KdzTTnWq.d.ts → sandbox-B9LOT0cg.d.ts} +196 -121
- package/dist/sandbox-B9LOT0cg.d.ts.map +1 -0
- package/dist/{sandbox-BcEq4aUF.js → sandbox-DQxTkLyY.js} +1704 -404
- package/dist/sandbox-DQxTkLyY.js.map +1 -0
- package/package.json +3 -3
- package/dist/contexts-D_shbnJs.d.ts.map +0 -1
- package/dist/errors-8Hvune8K.js.map +0 -1
- package/dist/sandbox-BcEq4aUF.js.map +0 -1
- package/dist/sandbox-KdzTTnWq.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
|
|
@@ -2765,6 +2734,10 @@ function normalizeBackupExcludePattern(pattern) {
|
|
|
2765
2734
|
return normalized;
|
|
2766
2735
|
}
|
|
2767
2736
|
|
|
2737
|
+
//#endregion
|
|
2738
|
+
//#region ../shared/src/internal.ts
|
|
2739
|
+
const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
|
|
2740
|
+
|
|
2768
2741
|
//#endregion
|
|
2769
2742
|
//#region src/container-control/connection.ts
|
|
2770
2743
|
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -3057,12 +3030,33 @@ const IDLE_EXPORT_THRESHOLD = 1;
|
|
|
3057
3030
|
/**
|
|
3058
3031
|
* Translate a capnweb-propagated error into a typed SandboxError.
|
|
3059
3032
|
*
|
|
3060
|
-
*
|
|
3061
|
-
*
|
|
3062
|
-
*
|
|
3033
|
+
* Two wire formats are supported for backward compatibility with older
|
|
3034
|
+
* container images:
|
|
3035
|
+
*
|
|
3036
|
+
* 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
|
|
3037
|
+
* `ServiceError`-shaped object with own enumerable `code` and `details`
|
|
3038
|
+
* properties. capnweb walks `Object.keys()` and reconstructs those fields
|
|
3039
|
+
* on the SDK side.
|
|
3040
|
+
* 2. Legacy JSON-encoded message. Older containers encoded the structured
|
|
3041
|
+
* payload as a JSON string in `error.message`.
|
|
3042
|
+
*
|
|
3043
|
+
* The JSON-fallback branch can be removed once all older container images are
|
|
3044
|
+
* no longer in service.
|
|
3063
3045
|
*/
|
|
3064
3046
|
function translateRPCError(error) {
|
|
3065
3047
|
if (error instanceof Error) {
|
|
3048
|
+
const propagated = error;
|
|
3049
|
+
if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
|
|
3050
|
+
const code = propagated.code;
|
|
3051
|
+
const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
|
|
3052
|
+
throw createErrorFromResponse({
|
|
3053
|
+
code,
|
|
3054
|
+
message: error.message,
|
|
3055
|
+
context,
|
|
3056
|
+
httpStatus: getHttpStatus(code),
|
|
3057
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3066
3060
|
let payload;
|
|
3067
3061
|
try {
|
|
3068
3062
|
payload = JSON.parse(error.message);
|
|
@@ -3365,6 +3359,89 @@ var ContainerControlClient = class {
|
|
|
3365
3359
|
}
|
|
3366
3360
|
};
|
|
3367
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
|
+
|
|
3368
3445
|
//#endregion
|
|
3369
3446
|
//#region src/file-stream.ts
|
|
3370
3447
|
/**
|
|
@@ -3557,6 +3634,32 @@ function validateLanguage(language) {
|
|
|
3557
3634
|
const normalized = language.toLowerCase();
|
|
3558
3635
|
if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
|
|
3559
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
|
+
}
|
|
3560
3663
|
|
|
3561
3664
|
//#endregion
|
|
3562
3665
|
//#region src/interpreter.ts
|
|
@@ -4212,118 +4315,138 @@ function base64ToUint8Array(base64) {
|
|
|
4212
4315
|
}
|
|
4213
4316
|
|
|
4214
4317
|
//#endregion
|
|
4215
|
-
//#region src/
|
|
4216
|
-
async function
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
const params = new URLSearchParams({ sessionId });
|
|
4220
|
-
if (options?.cols) params.set("cols", String(options.cols));
|
|
4221
|
-
if (options?.rows) params.set("rows", String(options.rows));
|
|
4222
|
-
if (options?.shell) params.set("shell", options.shell);
|
|
4223
|
-
const ptyUrl = `http://localhost/ws/pty?${params}`;
|
|
4224
|
-
const ptyRequest = new Request(ptyUrl, request);
|
|
4225
|
-
return stub.fetch(switchPort(ptyRequest, 3e3));
|
|
4226
|
-
}
|
|
4227
|
-
|
|
4228
|
-
//#endregion
|
|
4229
|
-
//#region src/request-handler.ts
|
|
4230
|
-
async function proxyToSandbox(request, env$1) {
|
|
4231
|
-
const logger = createLogger({
|
|
4232
|
-
component: "sandbox-do",
|
|
4233
|
-
traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
|
|
4234
|
-
operation: "proxy"
|
|
4235
|
-
});
|
|
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();
|
|
4236
4322
|
try {
|
|
4237
|
-
const
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
if (
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
url: request.url,
|
|
4250
|
-
method: request.method,
|
|
4251
|
-
userAgent: request.headers.get("User-Agent") || "unknown"
|
|
4252
|
-
});
|
|
4253
|
-
return new Response(JSON.stringify({
|
|
4254
|
-
error: `Access denied: Invalid token or port not exposed`,
|
|
4255
|
-
code: "INVALID_TOKEN"
|
|
4256
|
-
}), {
|
|
4257
|
-
status: 404,
|
|
4258
|
-
headers: { "Content-Type": "application/json" }
|
|
4259
|
-
});
|
|
4260
|
-
}
|
|
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
|
+
};
|
|
4261
4335
|
}
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
const headers = {
|
|
4267
|
-
"X-Original-URL": request.url,
|
|
4268
|
-
"X-Forwarded-Host": url.hostname,
|
|
4269
|
-
"X-Forwarded-Proto": url.protocol.replace(":", ""),
|
|
4270
|
-
"X-Sandbox-Name": sandboxId
|
|
4336
|
+
settleForward();
|
|
4337
|
+
return {
|
|
4338
|
+
status: "response",
|
|
4339
|
+
response
|
|
4271
4340
|
};
|
|
4272
|
-
request.headers.forEach((value, key) => {
|
|
4273
|
-
headers[key] = value;
|
|
4274
|
-
});
|
|
4275
|
-
const proxyRequest = new Request(proxyUrl, {
|
|
4276
|
-
method: request.method,
|
|
4277
|
-
headers,
|
|
4278
|
-
body: request.body,
|
|
4279
|
-
duplex: "half",
|
|
4280
|
-
redirect: "manual"
|
|
4281
|
-
});
|
|
4282
|
-
return await sandbox.containerFetch(proxyRequest, port);
|
|
4283
4341
|
} catch (error) {
|
|
4284
|
-
|
|
4285
|
-
|
|
4342
|
+
settleForward();
|
|
4343
|
+
if (error instanceof Error && error.message.includes("Network connection lost.")) return { status: "network-lost" };
|
|
4344
|
+
throw error;
|
|
4286
4345
|
}
|
|
4287
4346
|
}
|
|
4288
|
-
function
|
|
4289
|
-
const
|
|
4290
|
-
if (
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
const firstHyphen = subdomain.indexOf("-");
|
|
4294
|
-
if (firstHyphen === -1) return null;
|
|
4295
|
-
const portStr = subdomain.slice(0, firstHyphen);
|
|
4296
|
-
if (!/^\d{4,5}$/.test(portStr)) return null;
|
|
4297
|
-
const port = parseInt(portStr, 10);
|
|
4298
|
-
if (!validatePort(port)) return null;
|
|
4299
|
-
const rest = subdomain.slice(firstHyphen + 1);
|
|
4300
|
-
const lastHyphen = rest.lastIndexOf("-");
|
|
4301
|
-
if (lastHyphen === -1) return null;
|
|
4302
|
-
const sandboxId = rest.slice(0, lastHyphen);
|
|
4303
|
-
const token = rest.slice(lastHyphen + 1);
|
|
4304
|
-
if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) return null;
|
|
4305
|
-
if (sandboxId.length === 0 || sandboxId.length > 63) return null;
|
|
4306
|
-
let sanitizedSandboxId;
|
|
4307
|
-
try {
|
|
4308
|
-
sanitizedSandboxId = sanitizeSandboxId(sandboxId);
|
|
4309
|
-
} catch {
|
|
4310
|
-
return null;
|
|
4347
|
+
function bridgePreviewWebSocket(response, lifecycle, settleForward) {
|
|
4348
|
+
const containerWebSocket = response.webSocket;
|
|
4349
|
+
if (containerWebSocket === null) {
|
|
4350
|
+
settleForward();
|
|
4351
|
+
return response;
|
|
4311
4352
|
}
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4353
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
4354
|
+
let settled = false;
|
|
4355
|
+
const settle = () => {
|
|
4356
|
+
if (!settled) {
|
|
4357
|
+
settled = true;
|
|
4358
|
+
settleForward();
|
|
4359
|
+
}
|
|
4317
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
|
+
});
|
|
4318
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
|
|
4319
4426
|
function isLocalhostPattern(hostname) {
|
|
4320
|
-
if (hostname.startsWith("["))
|
|
4321
|
-
|
|
4427
|
+
if (hostname.startsWith("[")) {
|
|
4428
|
+
if (hostname.includes("]:")) return hostname.substring(0, hostname.indexOf("]:") + 1) === "[::1]";
|
|
4429
|
+
return hostname === "[::1]";
|
|
4430
|
+
}
|
|
4322
4431
|
if (hostname === "::1") return true;
|
|
4323
4432
|
const hostPart = hostname.split(":")[0];
|
|
4324
4433
|
return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
|
|
4325
4434
|
}
|
|
4326
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
|
+
|
|
4327
4450
|
//#endregion
|
|
4328
4451
|
//#region src/storage-mount/r2-egress-handler.ts
|
|
4329
4452
|
const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
|
|
@@ -4652,6 +4775,178 @@ const r2EgressHandler = async (request, env$1, ctx) => {
|
|
|
4652
4775
|
}
|
|
4653
4776
|
};
|
|
4654
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
|
+
|
|
4655
4950
|
//#endregion
|
|
4656
4951
|
//#region src/tunnels/sandbox-control-callback.ts
|
|
4657
4952
|
var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
@@ -4674,6 +4969,229 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
|
4674
4969
|
}
|
|
4675
4970
|
};
|
|
4676
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
|
+
|
|
4677
5195
|
//#endregion
|
|
4678
5196
|
//#region src/tunnels/tunnels-handler.ts
|
|
4679
5197
|
/**
|
|
@@ -4688,6 +5206,14 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
|
4688
5206
|
*/
|
|
4689
5207
|
/** DO storage key for the `port → TunnelInfo` map. */
|
|
4690
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";
|
|
4691
5217
|
function validateTunnelPort(port) {
|
|
4692
5218
|
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
|
|
4693
5219
|
}
|
|
@@ -4697,47 +5223,142 @@ function shortId() {
|
|
|
4697
5223
|
crypto.getRandomValues(buf);
|
|
4698
5224
|
return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
4699
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
|
+
}
|
|
4700
5243
|
function isTunnelNotFoundError(error) {
|
|
4701
|
-
return (error
|
|
5244
|
+
return hasErrorCode(error, "TUNNEL_NOT_FOUND");
|
|
5245
|
+
}
|
|
5246
|
+
function isTunnelAlreadyRunningError(error) {
|
|
5247
|
+
return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
|
|
4702
5248
|
}
|
|
4703
5249
|
async function readMap(storage) {
|
|
4704
5250
|
return await storage.get(STORAGE_KEY) ?? {};
|
|
4705
5251
|
}
|
|
5252
|
+
async function readMetaMap(storage) {
|
|
5253
|
+
return await storage.get(META_STORAGE_KEY) ?? {};
|
|
5254
|
+
}
|
|
4706
5255
|
/**
|
|
4707
|
-
*
|
|
4708
|
-
*
|
|
4709
|
-
*
|
|
4710
|
-
*
|
|
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
|
+
}
|
|
5278
|
+
/**
|
|
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.
|
|
4711
5289
|
*/
|
|
4712
5290
|
var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
4713
5291
|
#host;
|
|
4714
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;
|
|
4715
5304
|
constructor(host, withPortLock) {
|
|
4716
5305
|
super();
|
|
4717
5306
|
this.#host = host;
|
|
4718
5307
|
this.#withPortLock = withPortLock;
|
|
4719
5308
|
}
|
|
4720
|
-
|
|
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) {
|
|
4721
5334
|
const startTime = Date.now();
|
|
4722
5335
|
let outcome = "error";
|
|
4723
5336
|
let cacheState = "miss";
|
|
4724
5337
|
let caughtError;
|
|
4725
5338
|
try {
|
|
4726
5339
|
validateTunnelPort(port);
|
|
5340
|
+
if (options?.name !== void 0) validateTunnelName(options.name);
|
|
5341
|
+
const requestedHash = computeOptionsHash(options);
|
|
4727
5342
|
const info = await this.#withPortLock(port, async () => {
|
|
4728
5343
|
const existing = (await readMap(this.#host.storage))[port.toString()];
|
|
4729
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
|
+
}
|
|
4730
5357
|
cacheState = "hit";
|
|
4731
5358
|
return existing;
|
|
4732
5359
|
}
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
await this.#host.storage.transaction(async (txn) => {
|
|
4736
|
-
const nextMap = await readMap(txn);
|
|
4737
|
-
nextMap[port.toString()] = spawned;
|
|
4738
|
-
await txn.put(STORAGE_KEY, nextMap);
|
|
4739
|
-
});
|
|
4740
|
-
return spawned;
|
|
5360
|
+
if (options?.name) return await this.#provisionNamedTunnel(port, options.name);
|
|
5361
|
+
return await this.#provisionQuickTunnel(port);
|
|
4741
5362
|
});
|
|
4742
5363
|
outcome = "success";
|
|
4743
5364
|
return info;
|
|
@@ -4755,6 +5376,129 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
|
4755
5376
|
});
|
|
4756
5377
|
}
|
|
4757
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
|
+
}
|
|
4758
5502
|
async destroy(portOrInfo) {
|
|
4759
5503
|
const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
|
|
4760
5504
|
const startTime = Date.now();
|
|
@@ -4766,16 +5510,68 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
|
4766
5510
|
const existing = (await readMap(this.#host.storage))[port.toString()];
|
|
4767
5511
|
if (!existing) return;
|
|
4768
5512
|
tunnelId = existing.id;
|
|
5513
|
+
const metaBefore = (await readMetaMap(this.#host.storage))[port.toString()];
|
|
4769
5514
|
await this.#host.storage.transaction(async (txn) => {
|
|
4770
5515
|
const current = await readMap(txn);
|
|
4771
5516
|
delete current[port.toString()];
|
|
4772
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);
|
|
4773
5521
|
});
|
|
4774
5522
|
try {
|
|
4775
5523
|
await this.#host.client.tunnels.destroyTunnel(existing.id);
|
|
4776
5524
|
} catch (error) {
|
|
4777
|
-
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;
|
|
4778
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;
|
|
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
|
+
})]);
|
|
4779
5575
|
});
|
|
4780
5576
|
outcome = "success";
|
|
4781
5577
|
} catch (error) {
|
|
@@ -4807,29 +5603,126 @@ function createTunnelsHandler(host) {
|
|
|
4807
5603
|
const tunnels = new TunnelsRpcTarget(host, withPortLock);
|
|
4808
5604
|
const handleTunnelExit = async (id, port, exitCode) => {
|
|
4809
5605
|
const startTime = Date.now();
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
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
|
+
}
|
|
4814
5624
|
delete map[port.toString()];
|
|
4815
5625
|
await txn.put(STORAGE_KEY, map);
|
|
4816
|
-
|
|
5626
|
+
const meta = await readMetaMap(txn);
|
|
5627
|
+
delete meta[port.toString()];
|
|
5628
|
+
await txn.put(META_STORAGE_KEY, meta);
|
|
5629
|
+
});
|
|
4817
5630
|
});
|
|
5631
|
+
outcome = "success";
|
|
5632
|
+
} catch (error) {
|
|
5633
|
+
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
5634
|
+
throw error;
|
|
5635
|
+
} finally {
|
|
4818
5636
|
logCanonicalEvent(host.logger, {
|
|
4819
5637
|
event: "tunnel.exit",
|
|
4820
|
-
outcome
|
|
5638
|
+
outcome,
|
|
4821
5639
|
port,
|
|
4822
5640
|
tunnelId: id,
|
|
4823
5641
|
exitCode: exitCode ?? void 0,
|
|
4824
|
-
durationMs: Date.now() - startTime
|
|
5642
|
+
durationMs: Date.now() - startTime,
|
|
5643
|
+
error: caughtError
|
|
4825
5644
|
});
|
|
4826
|
-
}
|
|
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
|
+
}
|
|
4827
5673
|
};
|
|
4828
5674
|
return {
|
|
4829
5675
|
tunnels,
|
|
4830
|
-
handleTunnelExit
|
|
5676
|
+
handleTunnelExit,
|
|
5677
|
+
destroyAll
|
|
4831
5678
|
};
|
|
4832
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
|
+
}
|
|
4833
5726
|
|
|
4834
5727
|
//#endregion
|
|
4835
5728
|
//#region src/version.ts
|
|
@@ -4838,10 +5731,12 @@ function createTunnelsHandler(host) {
|
|
|
4838
5731
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
4839
5732
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
4840
5733
|
*/
|
|
4841
|
-
const SDK_VERSION = "0.
|
|
5734
|
+
const SDK_VERSION = "0.11.0";
|
|
4842
5735
|
|
|
4843
5736
|
//#endregion
|
|
4844
5737
|
//#region src/sandbox.ts
|
|
5738
|
+
const PORT_TOKENS_STORAGE_KEY = "portTokens";
|
|
5739
|
+
const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
|
|
4845
5740
|
const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
|
|
4846
5741
|
var R2EgressProxyTarget = class extends Container {};
|
|
4847
5742
|
Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
|
|
@@ -4987,14 +5882,63 @@ function getSandbox(ns, id, options) {
|
|
|
4987
5882
|
});
|
|
4988
5883
|
}
|
|
4989
5884
|
const defaultSessionId = `sandbox-${effectiveId}`;
|
|
5885
|
+
const useDefaultSession = options?.enableDefaultSession !== false;
|
|
4990
5886
|
const enhancedMethods = {
|
|
4991
5887
|
fetch: (request) => stub.fetch(request),
|
|
5888
|
+
exec: (command, execOptions) => useDefaultSession ? stub.exec(command, execOptions) : stub.execWithSessionToken(command, DISABLE_SESSION_TOKEN, execOptions),
|
|
5889
|
+
startProcess: (command, processOptions) => useDefaultSession || processOptions?.sessionId !== void 0 ? stub.startProcess(command, processOptions) : stub.startProcess(command, {
|
|
5890
|
+
...processOptions,
|
|
5891
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5892
|
+
}),
|
|
5893
|
+
listProcesses: (sessionId) => useDefaultSession || sessionId !== void 0 ? stub.listProcesses(sessionId) : stub.listProcesses(DISABLE_SESSION_TOKEN),
|
|
5894
|
+
getProcess: (id$1, sessionId) => useDefaultSession || sessionId !== void 0 ? stub.getProcess(id$1, sessionId) : stub.getProcess(id$1, DISABLE_SESSION_TOKEN),
|
|
5895
|
+
execStream: (command, streamOptions) => {
|
|
5896
|
+
if (useDefaultSession || streamOptions?.sessionId !== void 0) return stub.execStream(command, streamOptions);
|
|
5897
|
+
return stub.execStreamWithSessionToken(command, DISABLE_SESSION_TOKEN, streamOptions);
|
|
5898
|
+
},
|
|
5899
|
+
writeFile: (path$1, content, fileOptions = {}) => useDefaultSession || fileOptions.sessionId !== void 0 ? stub.writeFile(path$1, content, fileOptions) : stub.writeFile(path$1, content, {
|
|
5900
|
+
...fileOptions,
|
|
5901
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5902
|
+
}),
|
|
5903
|
+
readFile: (path$1, fileOptions = {}) => {
|
|
5904
|
+
const options$1 = useDefaultSession || fileOptions.sessionId !== void 0 ? fileOptions : {
|
|
5905
|
+
...fileOptions,
|
|
5906
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5907
|
+
};
|
|
5908
|
+
if (options$1.encoding === "none") return stub.readFile(path$1, options$1);
|
|
5909
|
+
return stub.readFile(path$1, options$1);
|
|
5910
|
+
},
|
|
5911
|
+
readFileStream: (path$1, fileOptions = {}) => useDefaultSession || fileOptions.sessionId !== void 0 ? stub.readFileStream(path$1, fileOptions) : stub.readFileStream(path$1, { sessionId: DISABLE_SESSION_TOKEN }),
|
|
5912
|
+
mkdir: (path$1, mkdirOptions = {}) => useDefaultSession || mkdirOptions.sessionId !== void 0 ? stub.mkdir(path$1, mkdirOptions) : stub.mkdir(path$1, {
|
|
5913
|
+
...mkdirOptions,
|
|
5914
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5915
|
+
}),
|
|
5916
|
+
deleteFile: (path$1) => useDefaultSession ? stub.deleteFile(path$1) : stub.deleteFile(path$1, DISABLE_SESSION_TOKEN),
|
|
5917
|
+
renameFile: (oldPath, newPath) => useDefaultSession ? stub.renameFile(oldPath, newPath) : stub.renameFile(oldPath, newPath, DISABLE_SESSION_TOKEN),
|
|
5918
|
+
moveFile: (sourcePath, destinationPath) => useDefaultSession ? stub.moveFile(sourcePath, destinationPath) : stub.moveFile(sourcePath, destinationPath, DISABLE_SESSION_TOKEN),
|
|
5919
|
+
listFiles: (path$1, listOptions) => useDefaultSession || listOptions?.sessionId !== void 0 ? stub.listFiles(path$1, listOptions) : stub.listFiles(path$1, {
|
|
5920
|
+
...listOptions,
|
|
5921
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5922
|
+
}),
|
|
5923
|
+
exists: (path$1, sessionId) => useDefaultSession || sessionId !== void 0 ? stub.exists(path$1, sessionId) : stub.exists(path$1, DISABLE_SESSION_TOKEN),
|
|
5924
|
+
gitCheckout: (repoUrl, gitOptions) => useDefaultSession || gitOptions?.sessionId !== void 0 ? stub.gitCheckout(repoUrl, gitOptions) : stub.gitCheckout(repoUrl, {
|
|
5925
|
+
...gitOptions,
|
|
5926
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5927
|
+
}),
|
|
4992
5928
|
createSession: async (opts) => {
|
|
4993
5929
|
return enhanceSession(stub, await stub.createSession(opts));
|
|
4994
5930
|
},
|
|
4995
5931
|
getSession: async (sessionId) => {
|
|
4996
5932
|
return enhanceSession(stub, await stub.getSession(sessionId));
|
|
4997
5933
|
},
|
|
5934
|
+
watch: (path$1, options$1 = {}) => useDefaultSession || options$1.sessionId !== void 0 ? stub.watch(path$1, options$1) : stub.watch(path$1, {
|
|
5935
|
+
...options$1,
|
|
5936
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5937
|
+
}),
|
|
5938
|
+
checkChanges: (path$1, options$1 = {}) => useDefaultSession || options$1.sessionId !== void 0 ? stub.checkChanges(path$1, options$1) : stub.checkChanges(path$1, {
|
|
5939
|
+
...options$1,
|
|
5940
|
+
sessionId: DISABLE_SESSION_TOKEN
|
|
5941
|
+
}),
|
|
4998
5942
|
terminal: (request, opts) => proxyTerminal(stub, defaultSessionId, request, opts),
|
|
4999
5943
|
wsConnect: connect(stub),
|
|
5000
5944
|
desktop: new Proxy({}, { get(_, method) {
|
|
@@ -5032,6 +5976,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5032
5976
|
sandboxName = null;
|
|
5033
5977
|
tunnelsHandler = null;
|
|
5034
5978
|
tunnelExitHandler = null;
|
|
5979
|
+
destroyAllTunnels = null;
|
|
5035
5980
|
controlCallback;
|
|
5036
5981
|
normalizeId = false;
|
|
5037
5982
|
defaultSession = null;
|
|
@@ -5041,6 +5986,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5041
5986
|
logger;
|
|
5042
5987
|
keepAliveEnabled = false;
|
|
5043
5988
|
activeMounts = /* @__PURE__ */ new Map();
|
|
5989
|
+
currentRuntime;
|
|
5044
5990
|
transport = "http";
|
|
5045
5991
|
/**
|
|
5046
5992
|
* True once transport has been written to storage at least once (either
|
|
@@ -5066,8 +6012,23 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5066
6012
|
r2SecretAccessKey = null;
|
|
5067
6013
|
r2AccountId = null;
|
|
5068
6014
|
backupBucketName = null;
|
|
6015
|
+
backupBucketEndpoint = null;
|
|
5069
6016
|
r2Client = null;
|
|
5070
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
|
+
/**
|
|
5071
6032
|
* Default container startup timeouts (conservative for production)
|
|
5072
6033
|
* Based on Cloudflare docs: "Containers take several minutes to provision"
|
|
5073
6034
|
*/
|
|
@@ -5226,16 +6187,64 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5226
6187
|
component: "sandbox-do",
|
|
5227
6188
|
sandboxId: this.ctx.id.toString()
|
|
5228
6189
|
});
|
|
6190
|
+
this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
|
|
5229
6191
|
const transportEnv = envObj?.SANDBOX_TRANSPORT;
|
|
5230
6192
|
if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
|
|
5231
6193
|
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
|
|
5232
6194
|
this.logger.info(`Using ${this.transport} transport`);
|
|
5233
6195
|
const backupBucket = envObj?.BACKUP_BUCKET;
|
|
5234
6196
|
if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
|
|
5235
|
-
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
6197
|
+
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_R2_ACCOUNT_ID") ?? getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
5236
6198
|
this.r2AccessKeyId = getEnvString(envObj, "R2_ACCESS_KEY_ID") ?? null;
|
|
5237
6199
|
this.r2SecretAccessKey = getEnvString(envObj, "R2_SECRET_ACCESS_KEY") ?? null;
|
|
5238
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;
|
|
5239
6248
|
if (this.r2AccessKeyId && this.r2SecretAccessKey) this.r2Client = new AwsClient({
|
|
5240
6249
|
accessKeyId: this.r2AccessKeyId,
|
|
5241
6250
|
secretAccessKey: this.r2SecretAccessKey
|
|
@@ -5270,6 +6279,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5270
6279
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
5271
6280
|
this.tunnelsHandler = null;
|
|
5272
6281
|
this.tunnelExitHandler = null;
|
|
6282
|
+
this.destroyAllTunnels = null;
|
|
5273
6283
|
previousClient.disconnect();
|
|
5274
6284
|
}
|
|
5275
6285
|
if (storedTransport) this.hasStoredTransport = true;
|
|
@@ -5325,13 +6335,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5325
6335
|
}
|
|
5326
6336
|
}
|
|
5327
6337
|
}
|
|
5328
|
-
/**
|
|
5329
|
-
* RPC method to configure container startup timeouts. Idempotent once
|
|
5330
|
-
* the values have been persisted: re-applying the same timeout set is a
|
|
5331
|
-
* no-op. The transport retry budget is recomputed only when at least
|
|
5332
|
-
* one timeout actually changes. Storage is written before the in-memory
|
|
5333
|
-
* mirror and derived state are updated.
|
|
5334
|
-
*/
|
|
5335
6338
|
async setContainerTimeouts(timeouts) {
|
|
5336
6339
|
const validated = { ...this.containerTimeouts };
|
|
5337
6340
|
if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
|
|
@@ -5344,11 +6347,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5344
6347
|
this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
|
|
5345
6348
|
this.logger.debug("Container timeouts updated", this.containerTimeouts);
|
|
5346
6349
|
}
|
|
5347
|
-
/**
|
|
5348
|
-
* RPC method to set the transport protocol. Idempotent once the value
|
|
5349
|
-
* has been persisted: re-applying the same transport is a no-op.
|
|
5350
|
-
* Storage is written before the in-memory state and client are updated.
|
|
5351
|
-
*/
|
|
5352
6350
|
async setTransport(transport) {
|
|
5353
6351
|
if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
|
|
5354
6352
|
this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
|
|
@@ -5363,23 +6361,16 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5363
6361
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
5364
6362
|
this.tunnelsHandler = null;
|
|
5365
6363
|
this.tunnelExitHandler = null;
|
|
6364
|
+
this.destroyAllTunnels = null;
|
|
5366
6365
|
previousClient.disconnect();
|
|
5367
6366
|
this.renewActivityTimeout();
|
|
5368
6367
|
this.logger.debug("Transport updated", { transport });
|
|
5369
6368
|
}
|
|
5370
|
-
/**
|
|
5371
|
-
* Validate a timeout value is within acceptable range
|
|
5372
|
-
* Throws error if invalid - used for user-provided values
|
|
5373
|
-
*/
|
|
5374
6369
|
validateTimeout(value, name, min, max) {
|
|
5375
6370
|
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
|
|
5376
6371
|
if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
|
|
5377
6372
|
return value;
|
|
5378
6373
|
}
|
|
5379
|
-
/**
|
|
5380
|
-
* Get default timeouts with env var fallbacks and validation
|
|
5381
|
-
* Precedence: SDK defaults < Env vars < User config
|
|
5382
|
-
*/
|
|
5383
6374
|
getDefaultTimeouts(env$1) {
|
|
5384
6375
|
const parseAndValidate = (envVar, name, min, max) => {
|
|
5385
6376
|
const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
|
|
@@ -5742,7 +6733,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5742
6733
|
*/
|
|
5743
6734
|
async createPasswordFile(passwordFilePath, bucket, credentials) {
|
|
5744
6735
|
const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
|
|
5745
|
-
await this.writeFile(passwordFilePath, content);
|
|
6736
|
+
await this.client.files.writeFile(passwordFilePath, content, DISABLE_SESSION_TOKEN);
|
|
5746
6737
|
await this.execInternal(`chmod 0600 ${shellEscape(passwordFilePath)}`);
|
|
5747
6738
|
}
|
|
5748
6739
|
/**
|
|
@@ -5842,6 +6833,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5842
6833
|
let outcome = "error";
|
|
5843
6834
|
let caughtError;
|
|
5844
6835
|
try {
|
|
6836
|
+
await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY);
|
|
6837
|
+
await this.clearActivePreviewPorts();
|
|
6838
|
+
await this.currentRuntime.clear();
|
|
5845
6839
|
if (this.ctx.container?.running) try {
|
|
5846
6840
|
await this.client.desktop.stop();
|
|
5847
6841
|
} catch {}
|
|
@@ -5866,8 +6860,14 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5866
6860
|
await this.deletePasswordFile(mountInfo.passwordFilePath);
|
|
5867
6861
|
}
|
|
5868
6862
|
}
|
|
5869
|
-
|
|
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
|
+
}
|
|
5870
6869
|
await this.ctx.storage.delete("tunnels");
|
|
6870
|
+
await this.ctx.storage.delete("tunnels:meta");
|
|
5871
6871
|
this.client.disconnect();
|
|
5872
6872
|
outcome = "success";
|
|
5873
6873
|
await super.destroy();
|
|
@@ -5887,75 +6887,20 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5887
6887
|
}
|
|
5888
6888
|
async onStart() {
|
|
5889
6889
|
this.logger.debug("Sandbox started");
|
|
6890
|
+
await this.currentRuntime.markStarted();
|
|
5890
6891
|
this.checkVersionCompatibility().catch((error) => {
|
|
5891
6892
|
this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
|
|
5892
6893
|
});
|
|
5893
6894
|
try {
|
|
5894
|
-
await this.
|
|
5895
|
-
} catch (error) {
|
|
5896
|
-
this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
|
|
5897
|
-
}
|
|
5898
|
-
try {
|
|
5899
|
-
await this.ctx.storage.delete("tunnels");
|
|
6895
|
+
await pruneTunnelsForRestart(this.ctx.storage);
|
|
5900
6896
|
} catch (error) {
|
|
5901
|
-
this.logger.error("Failed to
|
|
6897
|
+
this.logger.error("Failed to reconcile tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
|
|
5902
6898
|
}
|
|
5903
6899
|
}
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
* The DO storage holds the source of truth for which ports should be
|
|
5909
|
-
* exposed, which tokens authorize them, and the friendly name (if any)
|
|
5910
|
-
* that the caller set when first exposing the port. If a port is already
|
|
5911
|
-
* exposed on the container this is a no-op for that port. Individual port
|
|
5912
|
-
* failures are logged but do not abort the overall restore — a transient
|
|
5913
|
-
* failure for one port must not prevent the others from being restored.
|
|
5914
|
-
*/
|
|
5915
|
-
async restoreExposedPorts() {
|
|
5916
|
-
const savedTokens = await this.readPortTokens();
|
|
5917
|
-
const portEntries = Object.entries(savedTokens);
|
|
5918
|
-
if (portEntries.length === 0) return;
|
|
5919
|
-
const startTime = Date.now();
|
|
5920
|
-
let restored = 0;
|
|
5921
|
-
let skipped = 0;
|
|
5922
|
-
let failed = 0;
|
|
5923
|
-
const sessionId = await this.ensureDefaultSession();
|
|
5924
|
-
const exposedSet = await this.client.ports.getExposedPorts(sessionId).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
|
|
5925
|
-
this.logger.warn("Failed to fetch exposed ports for restore; assuming none exposed", { error: error instanceof Error ? error.message : String(error) });
|
|
5926
|
-
return /* @__PURE__ */ new Set();
|
|
5927
|
-
});
|
|
5928
|
-
for (const [portStr, entry] of portEntries) {
|
|
5929
|
-
const port = Number.parseInt(portStr, 10);
|
|
5930
|
-
if (!Number.isFinite(port) || !validatePort(port)) {
|
|
5931
|
-
this.logger.warn("Skipping restore of invalid port in storage", { port: portStr });
|
|
5932
|
-
failed++;
|
|
5933
|
-
continue;
|
|
5934
|
-
}
|
|
5935
|
-
if (exposedSet.has(port)) {
|
|
5936
|
-
skipped++;
|
|
5937
|
-
continue;
|
|
5938
|
-
}
|
|
5939
|
-
try {
|
|
5940
|
-
await this.client.ports.exposePort(port, sessionId, entry.name);
|
|
5941
|
-
restored++;
|
|
5942
|
-
} catch (error) {
|
|
5943
|
-
failed++;
|
|
5944
|
-
this.logger.warn("Failed to re-expose port on container restart", {
|
|
5945
|
-
port,
|
|
5946
|
-
error: error instanceof Error ? error.message : String(error)
|
|
5947
|
-
});
|
|
5948
|
-
}
|
|
5949
|
-
}
|
|
5950
|
-
logCanonicalEvent(this.logger, {
|
|
5951
|
-
event: "port.restore",
|
|
5952
|
-
outcome: failed === 0 ? "success" : "error",
|
|
5953
|
-
durationMs: Date.now() - startTime,
|
|
5954
|
-
restored,
|
|
5955
|
-
skipped,
|
|
5956
|
-
failed,
|
|
5957
|
-
total: portEntries.length
|
|
5958
|
-
});
|
|
6900
|
+
async stop(signal) {
|
|
6901
|
+
await this.currentRuntime.clear();
|
|
6902
|
+
await this.clearActivePreviewPorts();
|
|
6903
|
+
await super.stop(signal);
|
|
5959
6904
|
}
|
|
5960
6905
|
/**
|
|
5961
6906
|
* Read the `portTokens` map from DO storage, normalizing the legacy
|
|
@@ -5963,12 +6908,32 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5963
6908
|
* ({ token, name? }). The legacy format predates port-name persistence and
|
|
5964
6909
|
* can appear on any DO whose storage was written before that change.
|
|
5965
6910
|
*/
|
|
5966
|
-
async readPortTokens() {
|
|
5967
|
-
const raw = await
|
|
6911
|
+
async readPortTokens(storage = this.ctx.storage) {
|
|
6912
|
+
const raw = await storage.get(PORT_TOKENS_STORAGE_KEY) ?? {};
|
|
5968
6913
|
const normalized = {};
|
|
5969
6914
|
for (const [port, value] of Object.entries(raw)) normalized[port] = typeof value === "string" ? { token: value } : value;
|
|
5970
6915
|
return normalized;
|
|
5971
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
|
+
}
|
|
5972
6937
|
/**
|
|
5973
6938
|
* Check if the container version matches the SDK version
|
|
5974
6939
|
* Logs a warning if there's a mismatch
|
|
@@ -6001,6 +6966,13 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6001
6966
|
this.containerGeneration++;
|
|
6002
6967
|
this.defaultSession = null;
|
|
6003
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
|
+
}
|
|
6004
6976
|
this.client.disconnect();
|
|
6005
6977
|
let hadR2EgressMount = false;
|
|
6006
6978
|
for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
|
|
@@ -6222,6 +7194,99 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6222
7194
|
await super.onActivityExpired();
|
|
6223
7195
|
}
|
|
6224
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
|
+
}
|
|
6225
7290
|
async fetch(request) {
|
|
6226
7291
|
const traceId = TraceContext.fromHeaders(request.headers) || TraceContext.generate();
|
|
6227
7292
|
const requestLogger = this.logger.child({
|
|
@@ -6229,6 +7294,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6229
7294
|
operation: "fetch"
|
|
6230
7295
|
});
|
|
6231
7296
|
const url = new URL(request.url);
|
|
7297
|
+
if (this.isPreviewProxyRequest(request)) return await this.proxyPreviewRequest(request);
|
|
6232
7298
|
if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
|
|
6233
7299
|
const name = request.headers.get("X-Sandbox-Name");
|
|
6234
7300
|
this.sandboxName = name;
|
|
@@ -6320,10 +7386,78 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6320
7386
|
if (containerPlacementId === void 0) return;
|
|
6321
7387
|
await this.ctx.storage.put("containerPlacementId", containerPlacementId);
|
|
6322
7388
|
}
|
|
7389
|
+
async resolveExecution(explicitSessionId) {
|
|
7390
|
+
if (explicitSessionId !== void 0) {
|
|
7391
|
+
this.validateExplicitSessionId(explicitSessionId);
|
|
7392
|
+
if (explicitSessionId === DISABLE_SESSION_TOKEN) return { kind: "sessionless" };
|
|
7393
|
+
return {
|
|
7394
|
+
kind: "session",
|
|
7395
|
+
sessionId: explicitSessionId
|
|
7396
|
+
};
|
|
7397
|
+
}
|
|
7398
|
+
return {
|
|
7399
|
+
kind: "session",
|
|
7400
|
+
sessionId: await this.ensureDefaultSession()
|
|
7401
|
+
};
|
|
7402
|
+
}
|
|
7403
|
+
validateExplicitSessionId(sessionId) {
|
|
7404
|
+
if (sessionId.trim().length === 0) throw new Error("sessionId must not be empty or whitespace");
|
|
7405
|
+
}
|
|
7406
|
+
serializeExecutionContext(context) {
|
|
7407
|
+
if (context.kind === "sessionless") return DISABLE_SESSION_TOKEN;
|
|
7408
|
+
return context.sessionId;
|
|
7409
|
+
}
|
|
7410
|
+
getPublicExecutionSessionId(sessionId) {
|
|
7411
|
+
return sessionId === DISABLE_SESSION_TOKEN ? void 0 : sessionId;
|
|
7412
|
+
}
|
|
7413
|
+
/**
|
|
7414
|
+
* Resolves the session ID to annotate returned Process objects.
|
|
7415
|
+
*
|
|
7416
|
+
* Unlike `resolveExecution`, this is synchronous and never creates a
|
|
7417
|
+
* session. When the default session hasn't been established yet, it returns
|
|
7418
|
+
* `undefined` rather than triggering session creation. The resolved value is
|
|
7419
|
+
* only used to populate `Process.sessionId` on the returned object — it is
|
|
7420
|
+
* never sent to the container API.
|
|
7421
|
+
*/
|
|
7422
|
+
getProcessSessionBinding(explicitSessionId) {
|
|
7423
|
+
if (explicitSessionId !== void 0) {
|
|
7424
|
+
this.validateExplicitSessionId(explicitSessionId);
|
|
7425
|
+
if (explicitSessionId === DISABLE_SESSION_TOKEN) return;
|
|
7426
|
+
return explicitSessionId;
|
|
7427
|
+
}
|
|
7428
|
+
return this.defaultSession ?? void 0;
|
|
7429
|
+
}
|
|
7430
|
+
resolveExecutionEnv(sessionId, env$1) {
|
|
7431
|
+
if (sessionId === DISABLE_SESSION_TOKEN) {
|
|
7432
|
+
const mergedEnv = filterEnvVars({
|
|
7433
|
+
...this.envVars,
|
|
7434
|
+
...env$1 ?? {}
|
|
7435
|
+
});
|
|
7436
|
+
return Object.keys(mergedEnv).length > 0 ? mergedEnv : void 0;
|
|
7437
|
+
}
|
|
7438
|
+
if (env$1 === void 0) return;
|
|
7439
|
+
const filteredEnv = filterEnvVars(env$1);
|
|
7440
|
+
return Object.keys(filteredEnv).length > 0 ? filteredEnv : void 0;
|
|
7441
|
+
}
|
|
7442
|
+
buildExecutionRequestOptions(sessionId, options) {
|
|
7443
|
+
const env$1 = this.resolveExecutionEnv(sessionId, options?.env);
|
|
7444
|
+
if (options?.timeout === void 0 && env$1 === void 0 && options?.cwd === void 0 && options?.origin === void 0) return;
|
|
7445
|
+
return {
|
|
7446
|
+
...options?.timeout !== void 0 && { timeoutMs: options.timeout },
|
|
7447
|
+
...env$1 !== void 0 && { env: env$1 },
|
|
7448
|
+
...options?.cwd !== void 0 && { cwd: options.cwd },
|
|
7449
|
+
...options?.origin !== void 0 && { origin: options.origin }
|
|
7450
|
+
};
|
|
7451
|
+
}
|
|
6323
7452
|
async exec(command, options) {
|
|
6324
|
-
const
|
|
7453
|
+
const context = await this.resolveExecution();
|
|
7454
|
+
const session = this.serializeExecutionContext(context);
|
|
6325
7455
|
return this.execWithSession(command, session, options);
|
|
6326
7456
|
}
|
|
7457
|
+
async execWithSessionToken(command, sessionId, options) {
|
|
7458
|
+
this.validateExplicitSessionId(sessionId);
|
|
7459
|
+
return this.execWithSession(command, sessionId, options);
|
|
7460
|
+
}
|
|
6327
7461
|
/**
|
|
6328
7462
|
* Execute an infrastructure command (backup, mount, env setup, etc.)
|
|
6329
7463
|
* tagged with origin: 'internal' so logging demotes it to debug level.
|
|
@@ -6346,15 +7480,11 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6346
7480
|
let result;
|
|
6347
7481
|
if (options?.stream && options?.onOutput) result = await this.executeWithStreaming(command, sessionId, options, startTime, timestamp);
|
|
6348
7482
|
else {
|
|
6349
|
-
const commandOptions =
|
|
6350
|
-
timeoutMs: options.timeout,
|
|
6351
|
-
env: options.env,
|
|
6352
|
-
cwd: options.cwd,
|
|
6353
|
-
origin: options.origin
|
|
6354
|
-
} : void 0;
|
|
7483
|
+
const commandOptions = this.buildExecutionRequestOptions(sessionId, options);
|
|
6355
7484
|
const response = await this.client.commands.execute(command, sessionId, commandOptions);
|
|
6356
7485
|
const duration = Date.now() - startTime;
|
|
6357
|
-
|
|
7486
|
+
const publicSessionId = this.getPublicExecutionSessionId(sessionId);
|
|
7487
|
+
result = this.mapExecuteResponseToExecResult(response, duration, publicSessionId);
|
|
6358
7488
|
}
|
|
6359
7489
|
execOutcome = {
|
|
6360
7490
|
exitCode: result.exitCode,
|
|
@@ -6373,7 +7503,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6373
7503
|
command,
|
|
6374
7504
|
exitCode: execOutcome?.exitCode,
|
|
6375
7505
|
durationMs: Date.now() - startTime,
|
|
6376
|
-
sessionId,
|
|
7506
|
+
sessionId: this.getPublicExecutionSessionId(sessionId),
|
|
6377
7507
|
origin: options?.origin ?? "user",
|
|
6378
7508
|
error: execError ?? void 0,
|
|
6379
7509
|
errorMessage: execError?.message
|
|
@@ -6384,12 +7514,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6384
7514
|
let stdout = "";
|
|
6385
7515
|
let stderr = "";
|
|
6386
7516
|
try {
|
|
6387
|
-
const
|
|
6388
|
-
|
|
6389
|
-
env: options.env,
|
|
6390
|
-
cwd: options.cwd,
|
|
6391
|
-
origin: options.origin
|
|
6392
|
-
});
|
|
7517
|
+
const commandOptions = this.buildExecutionRequestOptions(sessionId, options);
|
|
7518
|
+
const stream = await this.client.commands.executeStream(command, sessionId, commandOptions);
|
|
6393
7519
|
for await (const event of parseSSEStream(stream)) {
|
|
6394
7520
|
if (options.signal?.aborted) throw new Error("Operation was aborted");
|
|
6395
7521
|
switch (event.type) {
|
|
@@ -6411,7 +7537,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6411
7537
|
command,
|
|
6412
7538
|
duration,
|
|
6413
7539
|
timestamp,
|
|
6414
|
-
sessionId
|
|
7540
|
+
sessionId: this.getPublicExecutionSessionId(sessionId)
|
|
6415
7541
|
};
|
|
6416
7542
|
}
|
|
6417
7543
|
case "error": throw new Error(event.data || "Command execution failed");
|
|
@@ -6687,12 +7813,16 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6687
7813
|
}
|
|
6688
7814
|
async startProcess(command, options, sessionId) {
|
|
6689
7815
|
try {
|
|
6690
|
-
const
|
|
7816
|
+
const execution = await this.resolveExecution(sessionId);
|
|
7817
|
+
const session = this.serializeExecutionContext(execution);
|
|
7818
|
+
const processSession = this.getProcessSessionBinding(session);
|
|
6691
7819
|
const requestOptions = {
|
|
7820
|
+
...this.buildExecutionRequestOptions(session, {
|
|
7821
|
+
timeout: options?.timeout,
|
|
7822
|
+
env: options?.env,
|
|
7823
|
+
cwd: options?.cwd
|
|
7824
|
+
}),
|
|
6692
7825
|
...options?.processId !== void 0 && { processId: options.processId },
|
|
6693
|
-
...options?.timeout !== void 0 && { timeoutMs: options.timeout },
|
|
6694
|
-
...options?.env !== void 0 && { env: filterEnvVars(options.env) },
|
|
6695
|
-
...options?.cwd !== void 0 && { cwd: options.cwd },
|
|
6696
7826
|
...options?.encoding !== void 0 && { encoding: options.encoding },
|
|
6697
7827
|
...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
|
|
6698
7828
|
};
|
|
@@ -6705,7 +7835,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6705
7835
|
startTime: /* @__PURE__ */ new Date(),
|
|
6706
7836
|
endTime: void 0,
|
|
6707
7837
|
exitCode: void 0
|
|
6708
|
-
},
|
|
7838
|
+
}, processSession);
|
|
6709
7839
|
if (options?.onStart) options.onStart(processObj);
|
|
6710
7840
|
if (options?.onOutput || options?.onExit) this.startProcessCallbackStream(response.processId, options).catch(() => {});
|
|
6711
7841
|
return processObj;
|
|
@@ -6733,13 +7863,14 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6733
7863
|
if (options.onExit) options.onExit(event.exitCode ?? null);
|
|
6734
7864
|
return;
|
|
6735
7865
|
}
|
|
7866
|
+
throw new Error("Stream ended without completion event");
|
|
6736
7867
|
} catch (error) {
|
|
6737
7868
|
if (options.onError && error instanceof Error) options.onError(error);
|
|
6738
7869
|
this.logger.error("Background process streaming failed", error instanceof Error ? error : new Error(String(error)), { processId });
|
|
6739
7870
|
}
|
|
6740
7871
|
}
|
|
6741
7872
|
async listProcesses(sessionId) {
|
|
6742
|
-
const session =
|
|
7873
|
+
const session = this.getProcessSessionBinding(sessionId);
|
|
6743
7874
|
return (await this.client.processes.listProcesses()).processes.map((processData) => this.createProcessFromDTO({
|
|
6744
7875
|
id: processData.id,
|
|
6745
7876
|
pid: processData.pid,
|
|
@@ -6751,22 +7882,24 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6751
7882
|
}, session));
|
|
6752
7883
|
}
|
|
6753
7884
|
async getProcess(id, sessionId) {
|
|
6754
|
-
const session =
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
7885
|
+
const session = this.getProcessSessionBinding(sessionId);
|
|
7886
|
+
try {
|
|
7887
|
+
const response = await this.client.processes.getProcess(id);
|
|
7888
|
+
if (!response.process) return null;
|
|
7889
|
+
const processData = response.process;
|
|
7890
|
+
return this.createProcessFromDTO({
|
|
7891
|
+
id: processData.id,
|
|
7892
|
+
pid: processData.pid,
|
|
7893
|
+
command: processData.command,
|
|
7894
|
+
status: processData.status,
|
|
7895
|
+
startTime: processData.startTime,
|
|
7896
|
+
endTime: processData.endTime,
|
|
7897
|
+
exitCode: processData.exitCode
|
|
7898
|
+
}, session);
|
|
7899
|
+
} catch (error) {
|
|
7900
|
+
if (error instanceof ProcessNotFoundError) return null;
|
|
7901
|
+
throw error;
|
|
7902
|
+
}
|
|
6770
7903
|
}
|
|
6771
7904
|
async killProcess(id, signal, sessionId) {
|
|
6772
7905
|
await this.client.processes.killProcess(id);
|
|
@@ -6787,23 +7920,29 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6787
7920
|
}
|
|
6788
7921
|
async execStream(command, options) {
|
|
6789
7922
|
if (options?.signal?.aborted) throw new Error("Operation was aborted");
|
|
6790
|
-
const
|
|
6791
|
-
|
|
6792
|
-
|
|
7923
|
+
const context = await this.resolveExecution(options?.sessionId);
|
|
7924
|
+
const session = this.serializeExecutionContext(context);
|
|
7925
|
+
const executionOptions = this.buildExecutionRequestOptions(session, {
|
|
7926
|
+
timeout: options?.timeout,
|
|
6793
7927
|
env: options?.env,
|
|
6794
7928
|
cwd: options?.cwd
|
|
6795
7929
|
});
|
|
7930
|
+
return this.client.commands.executeStream(command, session, executionOptions);
|
|
7931
|
+
}
|
|
7932
|
+
async execStreamWithSessionToken(command, sessionId, options) {
|
|
7933
|
+
this.validateExplicitSessionId(sessionId);
|
|
7934
|
+
return this.execStreamWithSession(command, sessionId, options);
|
|
6796
7935
|
}
|
|
6797
7936
|
/**
|
|
6798
7937
|
* Internal session-aware execStream implementation
|
|
6799
7938
|
*/
|
|
6800
7939
|
async execStreamWithSession(command, sessionId, options) {
|
|
6801
7940
|
if (options?.signal?.aborted) throw new Error("Operation was aborted");
|
|
6802
|
-
return this.client.commands.executeStream(command, sessionId, {
|
|
6803
|
-
|
|
7941
|
+
return this.client.commands.executeStream(command, sessionId, this.buildExecutionRequestOptions(sessionId, {
|
|
7942
|
+
timeout: options?.timeout,
|
|
6804
7943
|
env: options?.env,
|
|
6805
7944
|
cwd: options?.cwd
|
|
6806
|
-
});
|
|
7945
|
+
}));
|
|
6807
7946
|
}
|
|
6808
7947
|
/**
|
|
6809
7948
|
* Stream logs from a background process as a ReadableStream.
|
|
@@ -6813,7 +7952,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6813
7952
|
return this.client.processes.streamProcessLogs(processId);
|
|
6814
7953
|
}
|
|
6815
7954
|
async gitCheckout(repoUrl, options) {
|
|
6816
|
-
const
|
|
7955
|
+
const execution = await this.resolveExecution(options?.sessionId);
|
|
7956
|
+
const session = this.serializeExecutionContext(execution);
|
|
6817
7957
|
return this.client.git.checkout(repoUrl, session, {
|
|
6818
7958
|
branch: options?.branch,
|
|
6819
7959
|
targetDir: options?.targetDir,
|
|
@@ -6822,28 +7962,34 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6822
7962
|
});
|
|
6823
7963
|
}
|
|
6824
7964
|
async mkdir(path$1, options = {}) {
|
|
6825
|
-
const
|
|
7965
|
+
const execution = await this.resolveExecution(options.sessionId);
|
|
7966
|
+
const session = this.serializeExecutionContext(execution);
|
|
6826
7967
|
return this.client.files.mkdir(path$1, session, { recursive: options.recursive });
|
|
6827
7968
|
}
|
|
6828
7969
|
async writeFile(path$1, content, options = {}) {
|
|
6829
|
-
const
|
|
7970
|
+
const execution = await this.resolveExecution(options.sessionId);
|
|
7971
|
+
const session = this.serializeExecutionContext(execution);
|
|
6830
7972
|
if (content instanceof ReadableStream) return this.client.files.writeFileStream(path$1, content, session);
|
|
6831
7973
|
return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
|
|
6832
7974
|
}
|
|
6833
7975
|
async deleteFile(path$1, sessionId) {
|
|
6834
|
-
const
|
|
7976
|
+
const execution = await this.resolveExecution(sessionId);
|
|
7977
|
+
const session = this.serializeExecutionContext(execution);
|
|
6835
7978
|
return this.client.files.deleteFile(path$1, session);
|
|
6836
7979
|
}
|
|
6837
7980
|
async renameFile(oldPath, newPath, sessionId) {
|
|
6838
|
-
const
|
|
7981
|
+
const execution = await this.resolveExecution(sessionId);
|
|
7982
|
+
const session = this.serializeExecutionContext(execution);
|
|
6839
7983
|
return this.client.files.renameFile(oldPath, newPath, session);
|
|
6840
7984
|
}
|
|
6841
7985
|
async moveFile(sourcePath, destinationPath, sessionId) {
|
|
6842
|
-
const
|
|
7986
|
+
const execution = await this.resolveExecution(sessionId);
|
|
7987
|
+
const session = this.serializeExecutionContext(execution);
|
|
6843
7988
|
return this.client.files.moveFile(sourcePath, destinationPath, session);
|
|
6844
7989
|
}
|
|
6845
7990
|
async readFile(path$1, options = {}) {
|
|
6846
|
-
const
|
|
7991
|
+
const execution = await this.resolveExecution(options.sessionId);
|
|
7992
|
+
const session = this.serializeExecutionContext(execution);
|
|
6847
7993
|
if (options.encoding === "none") return this.client.files.readFile(path$1, session, { encoding: "none" });
|
|
6848
7994
|
return this.client.files.readFile(path$1, session, { encoding: options.encoding });
|
|
6849
7995
|
}
|
|
@@ -6854,15 +8000,18 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6854
8000
|
* @param options - Optional session ID
|
|
6855
8001
|
*/
|
|
6856
8002
|
async readFileStream(path$1, options = {}) {
|
|
6857
|
-
const
|
|
8003
|
+
const execution = await this.resolveExecution(options.sessionId);
|
|
8004
|
+
const session = this.serializeExecutionContext(execution);
|
|
6858
8005
|
return this.client.files.readFileStream(path$1, session);
|
|
6859
8006
|
}
|
|
6860
8007
|
async listFiles(path$1, options) {
|
|
6861
|
-
const
|
|
8008
|
+
const context = await this.resolveExecution(options?.sessionId);
|
|
8009
|
+
const session = this.serializeExecutionContext(context);
|
|
6862
8010
|
return this.client.files.listFiles(path$1, session, options);
|
|
6863
8011
|
}
|
|
6864
8012
|
async exists(path$1, sessionId) {
|
|
6865
|
-
const
|
|
8013
|
+
const execution = await this.resolveExecution(sessionId);
|
|
8014
|
+
const session = this.serializeExecutionContext(execution);
|
|
6866
8015
|
return this.client.files.exists(path$1, session);
|
|
6867
8016
|
}
|
|
6868
8017
|
/**
|
|
@@ -6879,17 +8028,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6879
8028
|
*/
|
|
6880
8029
|
async getDesktopStreamUrl(hostname, options) {
|
|
6881
8030
|
if ((await this.client.desktop.status()).status === "inactive") throw new Error("Desktop is not running. Call sandbox.desktop.start() first.");
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
token: options?.token
|
|
6887
|
-
})).url;
|
|
6888
|
-
} catch {
|
|
6889
|
-
const existingEntry = (await this.readPortTokens())["6080"];
|
|
6890
|
-
if (existingEntry && this.sandboxName) url = this.constructPreviewUrl(6080, this.sandboxName, hostname, existingEntry.token);
|
|
6891
|
-
else throw new Error("Failed to get desktop stream URL: port 6080 could not be exposed and no existing token found.");
|
|
6892
|
-
}
|
|
8031
|
+
const url = (await this.exposePort(6080, {
|
|
8032
|
+
hostname,
|
|
8033
|
+
token: options?.token
|
|
8034
|
+
})).url;
|
|
6893
8035
|
try {
|
|
6894
8036
|
await this.waitForPort({
|
|
6895
8037
|
portToCheck: 6080,
|
|
@@ -6913,7 +8055,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6913
8055
|
* @param options - Watch options
|
|
6914
8056
|
*/
|
|
6915
8057
|
async watch(path$1, options = {}) {
|
|
6916
|
-
const
|
|
8058
|
+
const execution = await this.resolveExecution(options.sessionId);
|
|
8059
|
+
const sessionId = this.serializeExecutionContext(execution);
|
|
6917
8060
|
return this.client.watch.watch({
|
|
6918
8061
|
path: path$1,
|
|
6919
8062
|
recursive: options.recursive,
|
|
@@ -6933,7 +8076,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6933
8076
|
* @param options - Change-check options
|
|
6934
8077
|
*/
|
|
6935
8078
|
async checkChanges(path$1, options = {}) {
|
|
6936
|
-
const
|
|
8079
|
+
const execution = await this.resolveExecution(options.sessionId);
|
|
8080
|
+
const sessionId = this.serializeExecutionContext(execution);
|
|
6937
8081
|
return this.client.watch.checkChanges({
|
|
6938
8082
|
path: path$1,
|
|
6939
8083
|
recursive: options.recursive,
|
|
@@ -6946,11 +8090,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6946
8090
|
/**
|
|
6947
8091
|
* Expose a port and get a preview URL for accessing services running in the sandbox
|
|
6948
8092
|
*
|
|
6949
|
-
* Preview
|
|
6950
|
-
*
|
|
6951
|
-
*
|
|
6952
|
-
*
|
|
6953
|
-
* `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.
|
|
6954
8097
|
*
|
|
6955
8098
|
* @param port - Port number to expose (1024-65535)
|
|
6956
8099
|
* @param options - Configuration options
|
|
@@ -6987,27 +8130,33 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6987
8130
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6988
8131
|
});
|
|
6989
8132
|
if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
const
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
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);
|
|
7005
8154
|
const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname, token);
|
|
7006
8155
|
outcome = "success";
|
|
7007
8156
|
return {
|
|
7008
8157
|
url,
|
|
7009
8158
|
port,
|
|
7010
|
-
name: options
|
|
8159
|
+
name: options.name
|
|
7011
8160
|
};
|
|
7012
8161
|
} catch (error) {
|
|
7013
8162
|
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -7018,29 +8167,37 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7018
8167
|
outcome,
|
|
7019
8168
|
port,
|
|
7020
8169
|
durationMs: Date.now() - exposeStartTime,
|
|
7021
|
-
name: options
|
|
8170
|
+
name: options.name,
|
|
7022
8171
|
hostname: options.hostname,
|
|
7023
8172
|
error: caughtError
|
|
7024
8173
|
});
|
|
7025
8174
|
}
|
|
7026
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
|
+
*/
|
|
7027
8183
|
async unexposePort(port) {
|
|
7028
8184
|
const unexposeStartTime = Date.now();
|
|
7029
8185
|
let outcome = "error";
|
|
7030
8186
|
let caughtError;
|
|
7031
8187
|
try {
|
|
7032
8188
|
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
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
|
+
});
|
|
7044
8201
|
outcome = "success";
|
|
7045
8202
|
} catch (error) {
|
|
7046
8203
|
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -7055,23 +8212,17 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7055
8212
|
});
|
|
7056
8213
|
}
|
|
7057
8214
|
}
|
|
8215
|
+
/**
|
|
8216
|
+
* Returns preview URLs that are currently forwardable in the active runtime.
|
|
8217
|
+
* Durable authorization without current-runtime activation is omitted.
|
|
8218
|
+
*/
|
|
7058
8219
|
async getExposedPorts(hostname) {
|
|
7059
|
-
const sessionId = await this.ensureDefaultSession();
|
|
7060
|
-
const response = await this.client.ports.getExposedPorts(sessionId);
|
|
7061
8220
|
if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
return [];
|
|
7068
|
-
}
|
|
7069
|
-
return [{
|
|
7070
|
-
url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, entry.token),
|
|
7071
|
-
port: port.port,
|
|
7072
|
-
status: port.status
|
|
7073
|
-
}];
|
|
7074
|
-
});
|
|
8221
|
+
return (await this.getCurrentPreviewPorts()).map(({ port, entry }) => ({
|
|
8222
|
+
url: this.constructPreviewUrl(port, this.sandboxName, hostname, entry.token),
|
|
8223
|
+
port,
|
|
8224
|
+
status: "active"
|
|
8225
|
+
}));
|
|
7075
8226
|
}
|
|
7076
8227
|
/**
|
|
7077
8228
|
* Namespaced tunnel API. Quick tunnels are zero-config preview URLs
|
|
@@ -7107,26 +8258,172 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7107
8258
|
const built = createTunnelsHandler({
|
|
7108
8259
|
client: this.client,
|
|
7109
8260
|
storage: this.ctx.storage,
|
|
7110
|
-
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
|
+
}
|
|
7111
8274
|
});
|
|
7112
8275
|
this.tunnelsHandler = built.tunnels;
|
|
7113
8276
|
this.tunnelExitHandler = built.handleTunnelExit;
|
|
8277
|
+
this.destroyAllTunnels = built.destroyAll;
|
|
7114
8278
|
}
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
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
|
+
});
|
|
7122
8298
|
}
|
|
8299
|
+
return this.tunnelAccountIdPromise;
|
|
7123
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
|
+
});
|
|
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);
|
|
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
|
+
*/
|
|
7124
8338
|
async validatePortToken(port, token) {
|
|
7125
8339
|
const entry = (await this.readPortTokens())[port.toString()];
|
|
7126
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) {
|
|
7127
8424
|
const encoder = new TextEncoder();
|
|
7128
|
-
const a = encoder.encode(
|
|
7129
|
-
const b = encoder.encode(
|
|
8425
|
+
const a = encoder.encode(expected);
|
|
8426
|
+
const b = encoder.encode(actual);
|
|
7130
8427
|
try {
|
|
7131
8428
|
return crypto.subtle.timingSafeEqual(a, b);
|
|
7132
8429
|
} catch {
|
|
@@ -7174,6 +8471,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7174
8471
|
*/
|
|
7175
8472
|
async createSession(options) {
|
|
7176
8473
|
const sessionId = options?.id || `session-${Date.now()}`;
|
|
8474
|
+
if (sessionId === DISABLE_SESSION_TOKEN) throw new Error(`Session ID '${DISABLE_SESSION_TOKEN}' is reserved for internal use`);
|
|
7177
8475
|
const filteredEnv = filterEnvVars({
|
|
7178
8476
|
...this.envVars,
|
|
7179
8477
|
...options?.env ?? {}
|
|
@@ -7457,10 +8755,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7457
8755
|
* Returns validated presigned URL configuration or throws if not configured.
|
|
7458
8756
|
* All credential fields plus the R2 binding are required for backup to work.
|
|
7459
8757
|
*/
|
|
7460
|
-
|
|
8758
|
+
requirePresignedURLSupport() {
|
|
7461
8759
|
if (!this.r2Client || !this.r2AccountId || !this.backupBucketName) {
|
|
7462
8760
|
const missing = [];
|
|
7463
|
-
if (!this.r2AccountId) missing.push("CLOUDFLARE_ACCOUNT_ID");
|
|
8761
|
+
if (!this.r2AccountId) missing.push("CLOUDFLARE_R2_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID");
|
|
7464
8762
|
if (!this.r2AccessKeyId) missing.push("R2_ACCESS_KEY_ID");
|
|
7465
8763
|
if (!this.r2SecretAccessKey) missing.push("R2_SECRET_ACCESS_KEY");
|
|
7466
8764
|
if (!this.backupBucketName) missing.push("BACKUP_BUCKET_NAME");
|
|
@@ -7478,15 +8776,21 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7478
8776
|
bucketName: this.backupBucketName
|
|
7479
8777
|
};
|
|
7480
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
|
+
}
|
|
7481
8787
|
/**
|
|
7482
8788
|
* Generate a presigned GET URL for downloading an object from R2.
|
|
7483
8789
|
* The container can curl this URL directly without credentials.
|
|
7484
8790
|
*/
|
|
7485
|
-
async
|
|
7486
|
-
const { client, accountId, bucketName } = this.
|
|
7487
|
-
const
|
|
7488
|
-
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
7489
|
-
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);
|
|
7490
8794
|
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
7491
8795
|
return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
|
|
7492
8796
|
}
|
|
@@ -7494,11 +8798,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7494
8798
|
* Generate a presigned PUT URL for uploading an object to R2.
|
|
7495
8799
|
* The container can curl PUT to this URL directly without credentials.
|
|
7496
8800
|
*/
|
|
7497
|
-
async
|
|
7498
|
-
const { client, accountId, bucketName } = this.
|
|
7499
|
-
const
|
|
7500
|
-
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
7501
|
-
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);
|
|
7502
8804
|
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
7503
8805
|
return (await client.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } })).url;
|
|
7504
8806
|
}
|
|
@@ -7508,7 +8810,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7508
8810
|
* ~24 MB/s throughput vs ~0.6 MB/s for base64 readFile.
|
|
7509
8811
|
*/
|
|
7510
8812
|
async uploadBackupPresigned(archivePath, r2Key, archiveSize, backupId, dir, backupSession) {
|
|
7511
|
-
const
|
|
8813
|
+
const presignedURL = await this.generatePresignedPutURL(r2Key);
|
|
7512
8814
|
const curlCmd = [
|
|
7513
8815
|
"curl -sSf",
|
|
7514
8816
|
"-X PUT",
|
|
@@ -7518,7 +8820,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7518
8820
|
"--retry 2",
|
|
7519
8821
|
"--retry-max-time 60",
|
|
7520
8822
|
`-T ${shellEscape(archivePath)}`,
|
|
7521
|
-
shellEscape(
|
|
8823
|
+
shellEscape(presignedURL)
|
|
7522
8824
|
].join(" ");
|
|
7523
8825
|
const result = await this.execWithSession(curlCmd, backupSession, {
|
|
7524
8826
|
timeout: 181e4,
|
|
@@ -7552,11 +8854,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7552
8854
|
/**
|
|
7553
8855
|
* Generate a presigned PUT URL for a single part in a multipart upload.
|
|
7554
8856
|
*/
|
|
7555
|
-
async
|
|
7556
|
-
const { client, accountId, bucketName } = this.
|
|
7557
|
-
const
|
|
7558
|
-
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
7559
|
-
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);
|
|
7560
8860
|
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
7561
8861
|
url.searchParams.set("partNumber", String(partNumber));
|
|
7562
8862
|
url.searchParams.set("uploadId", uploadId);
|
|
@@ -7571,9 +8871,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7571
8871
|
const targetParts = calculatePartCount(sizeBytes, BACKUP_MULTIPART_TARGET_PARTS, BACKUP_MULTIPART_MAX_PARTS);
|
|
7572
8872
|
const numParts = Math.min(targetParts, Math.floor(sizeBytes / BACKUP_MULTIPART_MIN_PART_SIZE));
|
|
7573
8873
|
if (numParts <= 1) return this.uploadBackupPresigned(archivePath, r2Key, sizeBytes, backupId, dir, backupSession);
|
|
7574
|
-
const { client, accountId, bucketName } = this.
|
|
7575
|
-
const
|
|
7576
|
-
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" });
|
|
7577
8877
|
if (!createResp.ok) throw new BackupCreateError({
|
|
7578
8878
|
message: `Failed to initiate multipart upload: HTTP ${createResp.status}`,
|
|
7579
8879
|
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
@@ -7596,7 +8896,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7596
8896
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
7597
8897
|
});
|
|
7598
8898
|
const abortMultipart = async () => {
|
|
7599
|
-
await client.fetch(`${
|
|
8899
|
+
await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
|
|
7600
8900
|
};
|
|
7601
8901
|
try {
|
|
7602
8902
|
const partSize = Math.ceil(sizeBytes / numParts);
|
|
@@ -7607,7 +8907,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7607
8907
|
size: i === numParts - 1 ? sizeBytes - i * partSize : partSize
|
|
7608
8908
|
})).map(async (part) => ({
|
|
7609
8909
|
...part,
|
|
7610
|
-
url: await this.
|
|
8910
|
+
url: await this.generatePresignedPartURL(r2Key, uploadId, part.partNumber)
|
|
7611
8911
|
})));
|
|
7612
8912
|
let uploadResult;
|
|
7613
8913
|
try {
|
|
@@ -7638,7 +8938,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7638
8938
|
...uploadResult.parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`),
|
|
7639
8939
|
"</CompleteMultipartUpload>"
|
|
7640
8940
|
].join("");
|
|
7641
|
-
const completeResp = await client.fetch(`${
|
|
8941
|
+
const completeResp = await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, {
|
|
7642
8942
|
method: "POST",
|
|
7643
8943
|
headers: { "Content-Type": "application/xml" },
|
|
7644
8944
|
body: completeXml
|
|
@@ -7680,7 +8980,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7680
8980
|
* with dd using byte offsets, then atomically moved to the final path.
|
|
7681
8981
|
*/
|
|
7682
8982
|
async downloadBackupParallel(archivePath, r2Key, expectedSize, backupId, dir, backupSession) {
|
|
7683
|
-
const
|
|
8983
|
+
const presignedURL = await this.generatePresignedGetURL(r2Key);
|
|
7684
8984
|
await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
|
|
7685
8985
|
const tmpPath = `${archivePath}.tmp`;
|
|
7686
8986
|
if (expectedSize < BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE) {
|
|
@@ -7691,7 +8991,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7691
8991
|
"--retry 2",
|
|
7692
8992
|
"--retry-max-time 60",
|
|
7693
8993
|
`-o ${shellEscape(tmpPath)}`,
|
|
7694
|
-
shellEscape(
|
|
8994
|
+
shellEscape(presignedURL)
|
|
7695
8995
|
].join(" ");
|
|
7696
8996
|
const result = await this.execWithSession(curlCmd, backupSession, {
|
|
7697
8997
|
timeout: 181e4,
|
|
@@ -7724,7 +9024,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7724
9024
|
"--connect-timeout 10",
|
|
7725
9025
|
"--max-time 1800",
|
|
7726
9026
|
`-H ${shellEscape(`Range: bytes=${range}`)}`,
|
|
7727
|
-
shellEscape(
|
|
9027
|
+
shellEscape(presignedURL),
|
|
7728
9028
|
"|",
|
|
7729
9029
|
"dd",
|
|
7730
9030
|
`of=${shellEscape(tmpPath)}`,
|
|
@@ -7830,7 +9130,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7830
9130
|
}
|
|
7831
9131
|
async doCreateBackup(options) {
|
|
7832
9132
|
const bucket = this.requireBackupBucket();
|
|
7833
|
-
this.
|
|
9133
|
+
this.requirePresignedURLSupport();
|
|
7834
9134
|
const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [], compression, multipart = true } = options;
|
|
7835
9135
|
const backupStartTime = Date.now();
|
|
7836
9136
|
let backupId;
|
|
@@ -8130,7 +9430,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8130
9430
|
async doRestoreBackup(backup) {
|
|
8131
9431
|
const restoreStartTime = Date.now();
|
|
8132
9432
|
const bucket = this.requireBackupBucket();
|
|
8133
|
-
this.
|
|
9433
|
+
this.requirePresignedURLSupport();
|
|
8134
9434
|
const { id, dir } = backup;
|
|
8135
9435
|
let outcome = "error";
|
|
8136
9436
|
let caughtError;
|
|
@@ -8400,5 +9700,5 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8400
9700
|
};
|
|
8401
9701
|
|
|
8402
9702
|
//#endregion
|
|
8403
|
-
export {
|
|
8404
|
-
//# 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
|