@cloudflare/sandbox 0.10.3 → 0.11.0

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