@cloudflare/sandbox 0.10.0 → 0.10.2

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,9 +1,10 @@
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-CBi-O-pF.js";
2
+ import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-8Hvune8K.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
- import { RpcSession } from "capnweb";
5
+ import { RpcSession, RpcTarget } from "capnweb";
6
6
  import path from "node:path/posix";
7
+ import { RpcTarget as RpcTarget$1 } from "cloudflare:workers";
7
8
 
8
9
  //#region src/errors/classes.ts
9
10
  /**
@@ -1784,6 +1785,17 @@ var CommandClient = class extends BaseHttpClient {
1784
1785
  //#endregion
1785
1786
  //#region src/clients/desktop-client.ts
1786
1787
  /**
1788
+ * Decode a base64-encoded screenshot payload into a Uint8Array.
1789
+ * Shared with the RPC client wrapper, which receives base64 over the
1790
+ * wire and needs the same `format: 'bytes'` convenience.
1791
+ */
1792
+ function base64ToBytes(data) {
1793
+ const binaryString = atob(data);
1794
+ const bytes = new Uint8Array(binaryString.length);
1795
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1796
+ return bytes;
1797
+ }
1798
+ /**
1787
1799
  * Client for desktop environment lifecycle, input, and screen operations
1788
1800
  */
1789
1801
  var DesktopClient = class extends BaseHttpClient {
@@ -1828,15 +1840,10 @@ var DesktopClient = class extends BaseHttpClient {
1828
1840
  ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1829
1841
  };
1830
1842
  const response = await this.post("/api/desktop/screenshot", data);
1831
- if (wantsBytes) {
1832
- const binaryString = atob(response.data);
1833
- const bytes = new Uint8Array(binaryString.length);
1834
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1835
- return {
1836
- ...response,
1837
- data: bytes
1838
- };
1839
- }
1843
+ if (wantsBytes) return {
1844
+ ...response,
1845
+ data: base64ToBytes(response.data)
1846
+ };
1840
1847
  return response;
1841
1848
  }
1842
1849
  async screenshotRegion(region, options) {
@@ -1849,15 +1856,10 @@ var DesktopClient = class extends BaseHttpClient {
1849
1856
  ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1850
1857
  };
1851
1858
  const response = await this.post("/api/desktop/screenshot/region", data);
1852
- if (wantsBytes) {
1853
- const binaryString = atob(response.data);
1854
- const bytes = new Uint8Array(binaryString.length);
1855
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1856
- return {
1857
- ...response,
1858
- data: bytes
1859
- };
1860
- }
1859
+ if (wantsBytes) return {
1860
+ ...response,
1861
+ data: base64ToBytes(response.data)
1862
+ };
1861
1863
  return response;
1862
1864
  }
1863
1865
  /**
@@ -2010,7 +2012,7 @@ var DesktopClient = class extends BaseHttpClient {
2010
2012
  * Get health status for a specific desktop process.
2011
2013
  */
2012
2014
  async getProcessStatus(name) {
2013
- return await this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2015
+ return this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2014
2016
  }
2015
2017
  };
2016
2018
 
@@ -2050,13 +2052,8 @@ var FileClient = class extends BaseHttpClient {
2050
2052
  };
2051
2053
  return await this.post("/api/write", data);
2052
2054
  }
2053
- /**
2054
- * Read content from a file
2055
- * @param path - File path to read from
2056
- * @param sessionId - The session ID for this operation
2057
- * @param options - Optional settings (encoding)
2058
- */
2059
2055
  async readFile(path$1, sessionId, options) {
2056
+ if (options?.encoding === "none") throw new Error("readFile with encoding: 'none' requires the rpc transport. Set SANDBOX_TRANSPORT=rpc.");
2060
2057
  const data = {
2061
2058
  path: path$1,
2062
2059
  sessionId,
@@ -2143,6 +2140,13 @@ var FileClient = class extends BaseHttpClient {
2143
2140
  };
2144
2141
  return await this.post("/api/exists", data);
2145
2142
  }
2143
+ /**
2144
+ * Write a file via a raw binary stream over the RPC transport.
2145
+ * Throws on HTTP and WebSocket transports — use writeFile() with a string instead.
2146
+ */
2147
+ writeFileStream(_path, _content, _sessionId) {
2148
+ throw new Error("writeFileStream requires the rpc transport. Set SANDBOX_TRANSPORT=rpc.");
2149
+ }
2146
2150
  };
2147
2151
 
2148
2152
  //#endregion
@@ -2521,6 +2525,9 @@ var UtilityClient = class extends BaseHttpClient {
2521
2525
  return "unknown";
2522
2526
  }
2523
2527
  }
2528
+ listSessions() {
2529
+ throw new Error("listSessions requires the RPC transport. Set SANDBOX_TRANSPORT=rpc.");
2530
+ }
2524
2531
  };
2525
2532
 
2526
2533
  //#endregion
@@ -2644,6 +2651,13 @@ var SandboxClient = class {
2644
2651
  utils;
2645
2652
  desktop;
2646
2653
  watch;
2654
+ /**
2655
+ * Tunnels are RPC-only — the route-based transport does not implement them.
2656
+ * This getter exists so the `PublicKeys<SandboxClient> satisfies
2657
+ * PublicKeys<SandboxAPI>` compile-time check holds. Calling any method on
2658
+ * the returned proxy throws a clear `RPC transport required` error.
2659
+ */
2660
+ tunnels = createTunnelsNotImplemented();
2647
2661
  transport = null;
2648
2662
  constructor(options) {
2649
2663
  if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
@@ -2706,16 +2720,6 @@ var SandboxClient = class {
2706
2720
  return this.transport?.isConnected() ?? false;
2707
2721
  }
2708
2722
  /**
2709
- * Stream a file directly to the container over a binary RPC channel.
2710
- *
2711
- * Requires the container-control path (`transport: 'rpc'`). Calling this
2712
- * method with the HTTP or WebSocket route transports throws an error because
2713
- * those transports do not support binary streaming.
2714
- */
2715
- writeFileStream(_path, _content, _sessionId) {
2716
- throw new Error("writeFileStream requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.");
2717
- }
2718
- /**
2719
2723
  * Connect WebSocket transport (no-op in HTTP mode)
2720
2724
  * Called automatically on first request, but can be called explicitly
2721
2725
  * to establish connection upfront.
@@ -2731,6 +2735,14 @@ var SandboxClient = class {
2731
2735
  if (this.transport) this.transport.disconnect();
2732
2736
  }
2733
2737
  };
2738
+ function createTunnelsNotImplemented() {
2739
+ const message = "sandbox.tunnels.* requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.";
2740
+ return new Proxy({}, { get() {
2741
+ return () => {
2742
+ throw new Error(message);
2743
+ };
2744
+ } });
2745
+ }
2734
2746
 
2735
2747
  //#endregion
2736
2748
  //#region ../shared/src/backup.ts
@@ -2777,13 +2789,15 @@ var ContainerControlConnection = class {
2777
2789
  port;
2778
2790
  logger;
2779
2791
  retryTimeoutMs;
2792
+ onClose;
2780
2793
  constructor(options) {
2781
2794
  this.containerStub = options.stub;
2782
2795
  this.port = options.port ?? 3e3;
2783
2796
  this.logger = options.logger ?? createNoOpLogger();
2784
2797
  this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
2798
+ this.onClose = options.onClose;
2785
2799
  this.transport = new DeferredTransport();
2786
- this.session = new RpcSession(this.transport);
2800
+ this.session = new RpcSession(this.transport, options.localMain);
2787
2801
  this.stub = this.session.getRemoteMain();
2788
2802
  }
2789
2803
  /**
@@ -2823,6 +2837,8 @@ var ContainerControlConnection = class {
2823
2837
  this.stub[Symbol.dispose]?.();
2824
2838
  } catch {}
2825
2839
  if (this.ws) {
2840
+ this.ws.removeEventListener("close", this.onWebSocketClose);
2841
+ this.ws.removeEventListener("error", this.onWebSocketError);
2826
2842
  try {
2827
2843
  this.ws.close();
2828
2844
  } catch {}
@@ -2839,6 +2855,42 @@ var ContainerControlConnection = class {
2839
2855
  setRetryTimeoutMs(ms) {
2840
2856
  this.retryTimeoutMs = ms;
2841
2857
  }
2858
+ /**
2859
+ * Run the owner-provided `onClose` callback exactly once per call,
2860
+ * swallowing any errors so a buggy listener can't keep the connection
2861
+ * object in a half-torn-down state.
2862
+ */
2863
+ fireOnClose() {
2864
+ if (!this.onClose) return;
2865
+ try {
2866
+ this.onClose();
2867
+ } catch (err) {
2868
+ this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
2869
+ }
2870
+ }
2871
+ /**
2872
+ * WebSocket `close` listener. Defined as a bound arrow field so the
2873
+ * same reference can be passed to both `addEventListener` and
2874
+ * `removeEventListener` — a fresh anonymous lambda would silently
2875
+ * fail to unbind.
2876
+ */
2877
+ onWebSocketClose = () => {
2878
+ const wasConnected = this.connected;
2879
+ this.connected = false;
2880
+ this.ws = null;
2881
+ this.logger.debug("ContainerControlConnection WebSocket closed");
2882
+ if (wasConnected) this.fireOnClose();
2883
+ };
2884
+ /**
2885
+ * WebSocket `error` listener. Same field-form rationale as
2886
+ * {@link onWebSocketClose}.
2887
+ */
2888
+ onWebSocketError = () => {
2889
+ const wasConnected = this.connected;
2890
+ this.connected = false;
2891
+ this.ws = null;
2892
+ if (wasConnected) this.fireOnClose();
2893
+ };
2842
2894
  async doConnect() {
2843
2895
  try {
2844
2896
  const response = await this.fetchUpgradeWithRetry();
@@ -2846,15 +2898,8 @@ var ContainerControlConnection = class {
2846
2898
  const ws = response.webSocket;
2847
2899
  if (!ws) throw new Error("No WebSocket in upgrade response");
2848
2900
  ws.accept();
2849
- ws.addEventListener("close", () => {
2850
- this.connected = false;
2851
- this.ws = null;
2852
- this.logger.debug("ContainerControlConnection WebSocket closed");
2853
- });
2854
- ws.addEventListener("error", () => {
2855
- this.connected = false;
2856
- this.ws = null;
2857
- });
2901
+ ws.addEventListener("close", this.onWebSocketClose);
2902
+ ws.addEventListener("error", this.onWebSocketError);
2858
2903
  this.ws = ws;
2859
2904
  this.transport.activate(ws);
2860
2905
  this.connected = true;
@@ -3134,19 +3179,16 @@ var ContainerControlClient = class {
3134
3179
  busyPollTimer = null;
3135
3180
  /** Tracks whether we currently believe the session is busy. */
3136
3181
  busy = false;
3137
- /**
3138
- * Set the first time the poller observes `conn.isConnected() === true`,
3139
- * cleared in `destroyConnection()`. Lets us distinguish "the WebSocket
3140
- * upgrade is still in progress" (don't tear down) from "we were
3141
- * connected and the peer went away" (do tear down).
3142
- */
3143
- wasEverConnected = false;
3144
3182
  constructor(options) {
3145
3183
  this.connOptions = {
3146
3184
  stub: options.stub,
3147
3185
  port: options.port,
3186
+ localMain: options.localMain,
3148
3187
  logger: options.logger,
3149
- retryTimeoutMs: options.retryTimeoutMs
3188
+ retryTimeoutMs: options.retryTimeoutMs,
3189
+ onClose: () => {
3190
+ if (this.conn) this.destroyConnection();
3191
+ }
3150
3192
  };
3151
3193
  this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
3152
3194
  this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
@@ -3188,11 +3230,7 @@ var ContainerControlClient = class {
3188
3230
  pollBusyState = () => {
3189
3231
  const conn = this.conn;
3190
3232
  if (!conn) return;
3191
- if (!conn.isConnected()) {
3192
- if (this.wasEverConnected) this.destroyConnection();
3193
- return;
3194
- }
3195
- this.wasEverConnected = true;
3233
+ if (!conn.isConnected()) return;
3196
3234
  const { imports, exports } = conn.getStats();
3197
3235
  if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
3198
3236
  if (!this.busy) {
@@ -3225,7 +3263,7 @@ var ContainerControlClient = class {
3225
3263
  if (!conn || !conn.isConnected()) return;
3226
3264
  const { imports, exports } = conn.getStats();
3227
3265
  if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
3228
- this.logger.debug("Disconnecting idle capnweb connection");
3266
+ this.logger.debug("Disconnecting idle RPC connection");
3229
3267
  this.destroyConnection();
3230
3268
  }
3231
3269
  }, this.idleDisconnectMs);
@@ -3247,7 +3285,6 @@ var ContainerControlClient = class {
3247
3285
  this.conn.disconnect();
3248
3286
  this.conn = null;
3249
3287
  }
3250
- this.wasEverConnected = false;
3251
3288
  }
3252
3289
  get commands() {
3253
3290
  return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
@@ -3271,11 +3308,37 @@ var ContainerControlClient = class {
3271
3308
  return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3272
3309
  }
3273
3310
  get desktop() {
3274
- return wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3311
+ const stub = wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3312
+ const wire = stub;
3313
+ return new Proxy(stub, { get(target, prop, receiver) {
3314
+ if (prop === "screenshot") return async (options) => {
3315
+ const { format, ...rest } = options ?? {};
3316
+ const result = await wire.screenshot(rest);
3317
+ return format === "bytes" ? {
3318
+ ...result,
3319
+ data: base64ToBytes(result.data)
3320
+ } : result;
3321
+ };
3322
+ if (prop === "screenshotRegion") return async (region, options) => {
3323
+ const { format, ...rest } = options ?? {};
3324
+ const result = await wire.screenshotRegion({
3325
+ region,
3326
+ ...rest
3327
+ });
3328
+ return format === "bytes" ? {
3329
+ ...result,
3330
+ data: base64ToBytes(result.data)
3331
+ } : result;
3332
+ };
3333
+ return Reflect.get(target, prop, receiver);
3334
+ } });
3275
3335
  }
3276
3336
  get watch() {
3277
3337
  return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3278
3338
  }
3339
+ get tunnels() {
3340
+ return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
3341
+ }
3279
3342
  get interpreter() {
3280
3343
  return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3281
3344
  }
@@ -3300,9 +3363,6 @@ var ContainerControlClient = class {
3300
3363
  disconnect() {
3301
3364
  this.destroyConnection();
3302
3365
  }
3303
- async writeFileStream(path$1, stream, sessionId) {
3304
- return this.files.writeFileStream(path$1, stream, sessionId);
3305
- }
3306
3366
  };
3307
3367
 
3308
3368
  //#endregion
@@ -3819,6 +3879,13 @@ function resolveS3fsOptions(provider, userOptions) {
3819
3879
 
3820
3880
  //#endregion
3821
3881
  //#region src/storage-mount/validation.ts
3882
+ /**
3883
+ * Type guard for R2Bucket binding.
3884
+ * Checks for the minimal R2Bucket interface methods we use.
3885
+ */
3886
+ function isR2Bucket(value) {
3887
+ return typeof value === "object" && value !== null && "put" in value && typeof value.put === "function" && "get" in value && typeof value.get === "function" && "head" in value && typeof value.head === "function" && "delete" in value && typeof value.delete === "function" && "list" in value && typeof value.list === "function";
3888
+ }
3822
3889
  function validatePrefix(prefix) {
3823
3890
  if (!prefix.startsWith("/")) throw new InvalidMountConfigError(`Prefix must start with '/': "${prefix}"`);
3824
3891
  }
@@ -3829,6 +3896,13 @@ function validateBucketName(bucket, mountPath) {
3829
3896
  }
3830
3897
  if (!/^[a-z0-9]([a-z0-9.-]{0,61}[a-z0-9])?$/.test(bucket)) throw new InvalidMountConfigError(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, dots, or hyphens, and cannot start/end with dots or hyphens.`);
3831
3898
  }
3899
+ function validateBucketBindingName(bucketBinding, mountPath) {
3900
+ if (bucketBinding.includes(":")) {
3901
+ const [bucketName, prefixPart] = bucketBinding.split(":");
3902
+ throw new InvalidMountConfigError(`Bucket name cannot contain ':'. To mount a prefix, use the 'prefix' option:\n mountBucket('${bucketName}', '${mountPath}', { ...options, prefix: '${prefixPart}' })`);
3903
+ }
3904
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(bucketBinding)) throw new InvalidMountConfigError(`Invalid R2 binding name: "${bucketBinding}". Binding names must start with a letter or underscore and contain only letters, numbers, or underscores.`);
3905
+ }
3832
3906
  /**
3833
3907
  * Builds the s3fs source string from bucket name and optional prefix.
3834
3908
  * Format: "bucket" or "bucket:/prefix/" for subdirectory mounts.
@@ -4153,7 +4227,7 @@ async function proxyTerminal(stub, sessionId, request, options) {
4153
4227
 
4154
4228
  //#endregion
4155
4229
  //#region src/request-handler.ts
4156
- async function proxyToSandbox(request, env) {
4230
+ async function proxyToSandbox(request, env$1) {
4157
4231
  const logger = createLogger({
4158
4232
  component: "sandbox-do",
4159
4233
  traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
@@ -4164,7 +4238,7 @@ async function proxyToSandbox(request, env) {
4164
4238
  const routeInfo = extractSandboxRoute(url);
4165
4239
  if (!routeInfo) return null;
4166
4240
  const { sandboxId, port, path: path$1, token } = routeInfo;
4167
- const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
4241
+ const sandbox = getSandbox(env$1.Sandbox, sandboxId, { normalizeId: true });
4168
4242
  if (port !== 3e3) {
4169
4243
  if (!await sandbox.validatePortToken(port, token)) {
4170
4244
  logger.warn("Invalid token access blocked", {
@@ -4250,6 +4324,513 @@ function isLocalhostPattern(hostname) {
4250
4324
  return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
4251
4325
  }
4252
4326
 
4327
+ //#endregion
4328
+ //#region src/storage-mount/r2-egress-handler.ts
4329
+ const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
4330
+ const XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
4331
+ function escapeXML(s) {
4332
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4333
+ }
4334
+ function xmlResponse(body, status = 200) {
4335
+ return new Response(XML_DECL + body, {
4336
+ status,
4337
+ headers: { "Content-Type": "application/xml" }
4338
+ });
4339
+ }
4340
+ function normalizeObjectKey(value) {
4341
+ return value.replace(/^\/+/, "");
4342
+ }
4343
+ function trimTrailingSlashes(s) {
4344
+ let end = s.length;
4345
+ while (end > 0 && s[end - 1] === "/") end--;
4346
+ return s.slice(0, end);
4347
+ }
4348
+ function parsePath(pathname) {
4349
+ const stripped = pathname.startsWith("/") ? pathname.slice(1) : pathname;
4350
+ if (!stripped) return null;
4351
+ const slash = stripped.indexOf("/");
4352
+ if (slash === -1) return {
4353
+ bucket: stripped,
4354
+ key: ""
4355
+ };
4356
+ return {
4357
+ bucket: stripped.slice(0, slash),
4358
+ key: normalizeObjectKey(stripped.slice(slash + 1))
4359
+ };
4360
+ }
4361
+ function resolveR2Bucket(env$1, name) {
4362
+ if (typeof env$1 !== "object" || env$1 === null) return null;
4363
+ const val = env$1[name];
4364
+ return isR2Bucket(val) ? val : null;
4365
+ }
4366
+ function parseRange(header) {
4367
+ if (!header) return void 0;
4368
+ const m = header.match(/^bytes=(\d*)-(\d*)$/);
4369
+ if (!m) return void 0;
4370
+ const start = m[1] ? parseInt(m[1], 10) : void 0;
4371
+ const end = m[2] ? parseInt(m[2], 10) : void 0;
4372
+ if (start === void 0 && end !== void 0) return { suffix: end };
4373
+ if (start !== void 0 && end !== void 0) return {
4374
+ offset: start,
4375
+ length: end - start + 1
4376
+ };
4377
+ if (start !== void 0) return { offset: start };
4378
+ }
4379
+ function buildListObjectsV2Xml(bucketName, prefix, delimiter, maxKeys, result) {
4380
+ const contents = result.objects.map((obj) => `<Contents><Key>${escapeXML(obj.key)}</Key><LastModified>${obj.uploaded.toISOString()}</LastModified><ETag>${escapeXML(obj.httpEtag)}</ETag><Size>${obj.size}</Size><StorageClass>STANDARD</StorageClass></Contents>`).join("");
4381
+ const commonPrefixes = result.delimitedPrefixes.map((p) => `<CommonPrefixes><Prefix>${escapeXML(p)}</Prefix></CommonPrefixes>`).join("");
4382
+ const nextToken = result.truncated && result.cursor ? `<NextContinuationToken>${escapeXML(result.cursor)}</NextContinuationToken>` : "";
4383
+ const keyCount = result.objects.length + result.delimitedPrefixes.length;
4384
+ return `<ListBucketResult ${XML_NS}><Name>${escapeXML(bucketName)}</Name><Prefix>${escapeXML(prefix)}</Prefix><KeyCount>${keyCount}</KeyCount><MaxKeys>${maxKeys}</MaxKeys>` + (delimiter ? `<Delimiter>${escapeXML(delimiter)}</Delimiter>` : "") + `<IsTruncated>${result.truncated}</IsTruncated>` + nextToken + contents + commonPrefixes + `</ListBucketResult>`;
4385
+ }
4386
+ function buildLocationXml() {
4387
+ return `<LocationConstraint ${XML_NS}/>`;
4388
+ }
4389
+ function buildInitiateMultipartUploadXml(bucketName, key, uploadId) {
4390
+ return `<InitiateMultipartUploadResult ${XML_NS}><Bucket>${escapeXML(bucketName)}</Bucket><Key>${escapeXML(key)}</Key><UploadId>${escapeXML(uploadId)}</UploadId></InitiateMultipartUploadResult>`;
4391
+ }
4392
+ function buildCompleteMultipartUploadXml(bucketName, key, etag) {
4393
+ return `<CompleteMultipartUploadResult ${XML_NS}><Location>http://r2.internal/${escapeXML(bucketName)}/${escapeXML(key)}</Location><Bucket>${escapeXML(bucketName)}</Bucket><Key>${escapeXML(key)}</Key><ETag>${escapeXML(etag)}</ETag></CompleteMultipartUploadResult>`;
4394
+ }
4395
+ function buildCopyObjectXml(etag, uploaded) {
4396
+ return `<CopyObjectResult ${XML_NS}><LastModified>${uploaded.toISOString()}</LastModified><ETag>${escapeXML(etag)}</ETag></CopyObjectResult>`;
4397
+ }
4398
+ function extractXmlTagContent(segment, tagName) {
4399
+ const openTag = `<${tagName}>`;
4400
+ const closeTag = `</${tagName}>`;
4401
+ const start = segment.indexOf(openTag);
4402
+ if (start === -1) return null;
4403
+ const contentStart = start + openTag.length;
4404
+ const end = segment.indexOf(closeTag, contentStart);
4405
+ if (end === -1) return null;
4406
+ return segment.slice(contentStart, end);
4407
+ }
4408
+ function parseCompleteMultipartUploadBody(body) {
4409
+ const parts = [];
4410
+ let pos = 0;
4411
+ while (pos < body.length) {
4412
+ const start = body.indexOf("<Part>", pos);
4413
+ if (start === -1) break;
4414
+ const end = body.indexOf("</Part>", start + 6);
4415
+ if (end === -1) break;
4416
+ const segment = body.slice(start, end + 7);
4417
+ pos = end + 7;
4418
+ const partNumberText = extractXmlTagContent(segment, "PartNumber");
4419
+ const etagText = extractXmlTagContent(segment, "ETag");
4420
+ const partNumber = partNumberText ? parseInt(partNumberText, 10) : NaN;
4421
+ if (Number.isFinite(partNumber) && etagText) parts.push({
4422
+ partNumber,
4423
+ etag: etagText.replace(/^"|"$/g, "")
4424
+ });
4425
+ }
4426
+ return parts;
4427
+ }
4428
+ function buildResponseHeaders(obj) {
4429
+ const headers = new Headers();
4430
+ headers.set("ETag", obj.httpEtag);
4431
+ headers.set("Content-Length", String(obj.size));
4432
+ headers.set("Last-Modified", obj.uploaded.toUTCString());
4433
+ headers.set("Accept-Ranges", "bytes");
4434
+ if (obj.httpMetadata?.contentType) headers.set("Content-Type", obj.httpMetadata.contentType);
4435
+ if (obj.httpMetadata?.contentDisposition) headers.set("Content-Disposition", obj.httpMetadata.contentDisposition);
4436
+ if (obj.httpMetadata?.contentEncoding) headers.set("Content-Encoding", obj.httpMetadata.contentEncoding);
4437
+ if (obj.httpMetadata?.contentLanguage) headers.set("Content-Language", obj.httpMetadata.contentLanguage);
4438
+ if (obj.httpMetadata?.cacheControl) headers.set("Cache-Control", obj.httpMetadata.cacheControl);
4439
+ return headers;
4440
+ }
4441
+ function buildContentRange(range, totalSize) {
4442
+ if ("suffix" in range) return `bytes ${Math.max(0, totalSize - range.suffix)}-${totalSize - 1}/${totalSize}`;
4443
+ const start = range.offset ?? 0;
4444
+ return `bytes ${start}-${range.length !== void 0 ? start + range.length - 1 : totalSize - 1}/${totalSize}`;
4445
+ }
4446
+ function getRangeContentLength(range, totalSize) {
4447
+ if ("suffix" in range) return Math.min(range.suffix, totalSize);
4448
+ const start = range.offset ?? 0;
4449
+ if (start >= totalSize) return 0;
4450
+ const requestedLength = range.length !== void 0 ? range.length : totalSize - start;
4451
+ return Math.min(requestedLength, totalSize - start);
4452
+ }
4453
+ function extractHttpMetadata(request) {
4454
+ const meta = {};
4455
+ const ct = request.headers.get("Content-Type");
4456
+ if (ct) meta.contentType = ct;
4457
+ const cd = request.headers.get("Content-Disposition");
4458
+ if (cd) meta.contentDisposition = cd;
4459
+ const ce = request.headers.get("Content-Encoding");
4460
+ if (ce) meta.contentEncoding = ce;
4461
+ const cl = request.headers.get("Content-Language");
4462
+ if (cl) meta.contentLanguage = cl;
4463
+ const cc = request.headers.get("Cache-Control");
4464
+ if (cc) meta.cacheControl = cc;
4465
+ return meta;
4466
+ }
4467
+ function parseCopySource(header) {
4468
+ const sourcePath = header.split("?")[0] ?? "";
4469
+ if (!sourcePath) return null;
4470
+ const decoded = decodeURIComponent(sourcePath);
4471
+ const parsed = parsePath(decoded.startsWith("/") ? decoded : `/${decoded}`);
4472
+ return parsed ? {
4473
+ bucket: parsed.bucket,
4474
+ key: normalizeObjectKey(parsed.key)
4475
+ } : null;
4476
+ }
4477
+ function normalizeStorageClass(storageClass) {
4478
+ if (storageClass === "Standard" || storageClass === "InfrequentAccess") return storageClass;
4479
+ }
4480
+ async function putRequestBody(r2, key, request, options) {
4481
+ const contentLength = request.headers.get("Content-Length");
4482
+ const length = contentLength ? Number.parseInt(contentLength, 10) : NaN;
4483
+ if (!Number.isFinite(length) || length < 0) return new Response("Bad Request: missing or invalid Content-Length", { status: 400 });
4484
+ if (length === 0) return r2.put(key, new Uint8Array(0), options);
4485
+ if (!request.body) return new Response("Bad Request: missing request body", { status: 400 });
4486
+ const { readable, writable } = new FixedLengthStream(length);
4487
+ const pipe = request.body.pipeTo(writable);
4488
+ const result = await r2.put(key, readable, options);
4489
+ await pipe;
4490
+ return result;
4491
+ }
4492
+ async function handleListObjects(r2, bucketName, url, mountPrefix) {
4493
+ const queryPrefix = normalizeObjectKey(url.searchParams.get("prefix") ?? "");
4494
+ const delimiter = url.searchParams.get("delimiter") ?? "";
4495
+ const maxKeys = Math.min(parseInt(url.searchParams.get("max-keys") ?? "1000", 10) || 1e3, 1e3);
4496
+ const continuationToken = url.searchParams.get("continuation-token") ?? void 0;
4497
+ const listOpts = {
4498
+ prefix: (mountPrefix ? `${mountPrefix}/${queryPrefix}` : queryPrefix) || void 0,
4499
+ delimiter: delimiter || void 0,
4500
+ limit: maxKeys,
4501
+ cursor: continuationToken
4502
+ };
4503
+ const result = await r2.list(listOpts);
4504
+ const stripKey = mountPrefix ? (k) => k.startsWith(`${mountPrefix}/`) ? k.slice(mountPrefix.length + 1) : k : (k) => k;
4505
+ return xmlResponse(buildListObjectsV2Xml(bucketName, queryPrefix, delimiter, maxKeys, {
4506
+ objects: result.objects.map((obj) => ({
4507
+ key: stripKey(obj.key),
4508
+ uploaded: obj.uploaded,
4509
+ httpEtag: obj.httpEtag,
4510
+ size: obj.size
4511
+ })),
4512
+ delimitedPrefixes: result.delimitedPrefixes.map(stripKey),
4513
+ truncated: result.truncated,
4514
+ cursor: result.truncated ? result.cursor : void 0
4515
+ }));
4516
+ }
4517
+ async function handleHeadObject(r2, key) {
4518
+ const obj = await r2.head(key);
4519
+ if (!obj) return new Response(null, { status: 404 });
4520
+ return new Response(null, {
4521
+ status: 200,
4522
+ headers: buildResponseHeaders(obj)
4523
+ });
4524
+ }
4525
+ async function handleGetObject(r2, key, request) {
4526
+ const range = parseRange(request.headers.get("Range"));
4527
+ if (!range) {
4528
+ const obj = await r2.get(key);
4529
+ if (!obj) return new Response(null, { status: 404 });
4530
+ return new Response(obj.body, {
4531
+ status: 200,
4532
+ headers: buildResponseHeaders(obj)
4533
+ });
4534
+ }
4535
+ const [headObj, rangeObj] = await Promise.all([r2.head(key), r2.get(key, { range })]);
4536
+ if (!headObj || !rangeObj) return new Response(null, { status: 404 });
4537
+ const headers = buildResponseHeaders(rangeObj);
4538
+ headers.set("Content-Range", buildContentRange(range, headObj.size));
4539
+ headers.set("Content-Length", String(getRangeContentLength(range, headObj.size)));
4540
+ return new Response(rangeObj.body, {
4541
+ status: 206,
4542
+ headers
4543
+ });
4544
+ }
4545
+ async function handlePutObject(r2, bucketName, key, request, env$1, permitted, mountPrefix) {
4546
+ const copySourceHeader = request.headers.get("x-amz-copy-source");
4547
+ if (copySourceHeader) {
4548
+ const copySource = parseCopySource(copySourceHeader);
4549
+ if (!copySource || !copySource.key) return new Response("Bad Request: invalid x-amz-copy-source", { status: 400 });
4550
+ if (!permitted.has(copySource.bucket)) return new Response(`Access to R2 bucket "${copySource.bucket}" is not permitted. Call mountBucket() with this bucket before accessing it.`, { status: 403 });
4551
+ const sourceBucket = copySource.bucket === bucketName ? r2 : resolveR2Bucket(env$1, copySource.bucket);
4552
+ if (!sourceBucket) return new Response(`R2 binding "${copySource.bucket}" not found in Worker env. Ensure the binding name matches the bucket name passed to mountBucket().`, { status: 500 });
4553
+ const sourceKey = mountPrefix && copySource.bucket === bucketName ? `${mountPrefix}/${copySource.key}` : copySource.key;
4554
+ const sourceObject = await sourceBucket.get(sourceKey);
4555
+ if (!sourceObject) return new Response(null, { status: 404 });
4556
+ const httpMetadata = request.headers.get("x-amz-metadata-directive")?.toUpperCase() === "REPLACE" ? extractHttpMetadata(request) : sourceObject.httpMetadata;
4557
+ const result$1 = await r2.put(key, sourceObject.body, {
4558
+ httpMetadata,
4559
+ customMetadata: sourceObject.customMetadata,
4560
+ storageClass: normalizeStorageClass(sourceObject.storageClass)
4561
+ });
4562
+ return xmlResponse(buildCopyObjectXml(result$1.httpEtag, result$1.uploaded));
4563
+ }
4564
+ const result = await putRequestBody(r2, key, request, { httpMetadata: extractHttpMetadata(request) });
4565
+ if (result instanceof Response) return result;
4566
+ const headers = new Headers();
4567
+ headers.set("ETag", result.httpEtag);
4568
+ return new Response(null, {
4569
+ status: 200,
4570
+ headers
4571
+ });
4572
+ }
4573
+ async function handleDeleteObject(r2, key) {
4574
+ await r2.delete(key);
4575
+ return new Response(null, { status: 204 });
4576
+ }
4577
+ async function handleCreateMultipartUpload(r2, bucketName, key, request) {
4578
+ const httpMetadata = extractHttpMetadata(request);
4579
+ return xmlResponse(buildInitiateMultipartUploadXml(bucketName, key, (await r2.createMultipartUpload(key, { httpMetadata })).uploadId));
4580
+ }
4581
+ async function handleUploadPart(r2, key, url, request) {
4582
+ const uploadId = url.searchParams.get("uploadId") ?? "";
4583
+ const partNumber = parseInt(url.searchParams.get("partNumber") ?? "0", 10);
4584
+ if (!uploadId || !partNumber) return new Response("Bad Request: missing uploadId or partNumber", { status: 400 });
4585
+ if (!request.body) return new Response("Bad Request: missing request body", { status: 400 });
4586
+ const contentLength = request.headers.get("Content-Length");
4587
+ const partLength = contentLength ? Number.parseInt(contentLength, 10) : NaN;
4588
+ if (!Number.isFinite(partLength) || partLength < 0) return new Response("Bad Request: missing or invalid Content-Length", { status: 400 });
4589
+ const upload = r2.resumeMultipartUpload(key, uploadId);
4590
+ let part;
4591
+ if (partLength === 0) part = await upload.uploadPart(partNumber, new Uint8Array(0));
4592
+ else {
4593
+ const { readable, writable } = new FixedLengthStream(partLength);
4594
+ const pipe = request.body.pipeTo(writable);
4595
+ part = await upload.uploadPart(partNumber, readable);
4596
+ await pipe;
4597
+ }
4598
+ const headers = new Headers();
4599
+ headers.set("ETag", `"${part.etag}"`);
4600
+ return new Response(null, {
4601
+ status: 200,
4602
+ headers
4603
+ });
4604
+ }
4605
+ async function handleCompleteMultipartUpload(r2, bucketName, key, url, request) {
4606
+ const uploadId = url.searchParams.get("uploadId") ?? "";
4607
+ if (!uploadId) return new Response("Bad Request: missing uploadId", { status: 400 });
4608
+ const r2Parts = parseCompleteMultipartUploadBody(await request.text()).map((p) => ({
4609
+ partNumber: p.partNumber,
4610
+ etag: p.etag
4611
+ }));
4612
+ return xmlResponse(buildCompleteMultipartUploadXml(bucketName, key, (await r2.resumeMultipartUpload(key, uploadId).complete(r2Parts)).httpEtag));
4613
+ }
4614
+ async function handleAbortMultipartUpload(r2, key, url) {
4615
+ const uploadId = url.searchParams.get("uploadId") ?? "";
4616
+ if (!uploadId) return new Response("Bad Request: missing uploadId", { status: 400 });
4617
+ await r2.resumeMultipartUpload(key, uploadId).abort();
4618
+ return new Response(null, { status: 204 });
4619
+ }
4620
+ const r2EgressHandler = async (request, env$1, ctx) => {
4621
+ const url = new URL(request.url);
4622
+ const parsed = parsePath(url.pathname);
4623
+ if (!parsed) return new Response("Bad Request: empty path", { status: 400 });
4624
+ const { bucket: bucketName, key } = parsed;
4625
+ if (!ctx.params?.buckets || !(bucketName in ctx.params.buckets)) return new Response(`Access to R2 bucket "${bucketName}" is not permitted. Call mountBucket() with this bucket before accessing it.`, { status: 403 });
4626
+ const bucketParams = ctx.params.buckets[bucketName];
4627
+ const rawPrefix = bucketParams.prefix;
4628
+ const mountPrefix = rawPrefix ? trimTrailingSlashes(normalizeObjectKey(rawPrefix)) : void 0;
4629
+ const readOnly = bucketParams.readOnly ?? false;
4630
+ const r2 = resolveR2Bucket(env$1, bucketName);
4631
+ if (!r2) return new Response(`R2 binding "${bucketName}" not found in Worker env. Ensure the binding name matches the bucket name passed to mountBucket().`, { status: 500 });
4632
+ const { method } = request;
4633
+ if (!key) {
4634
+ if (method === "GET" && url.searchParams.has("location")) return xmlResponse(buildLocationXml());
4635
+ if (method === "GET" && url.searchParams.get("list-type") === "2") return handleListObjects(r2, bucketName, url, mountPrefix);
4636
+ if (method === "GET") return handleListObjects(r2, bucketName, url, mountPrefix);
4637
+ return new Response("Method Not Allowed", { status: 405 });
4638
+ }
4639
+ const fullKey = mountPrefix ? `${mountPrefix}/${key}` : key;
4640
+ const permitted = new Set(Object.keys(ctx.params.buckets));
4641
+ if (readOnly && (method === "PUT" || method === "DELETE" || method === "POST" && (url.searchParams.has("uploads") || url.searchParams.has("uploadId")))) return new Response("Forbidden: bucket mount is read-only", { status: 403 });
4642
+ if (method === "POST" && url.searchParams.has("uploads")) return handleCreateMultipartUpload(r2, bucketName, fullKey, request);
4643
+ if (method === "POST" && url.searchParams.has("uploadId")) return handleCompleteMultipartUpload(r2, bucketName, fullKey, url, request);
4644
+ if (method === "PUT" && url.searchParams.has("partNumber") && url.searchParams.has("uploadId")) return handleUploadPart(r2, fullKey, url, request);
4645
+ if (method === "DELETE" && url.searchParams.has("uploadId")) return handleAbortMultipartUpload(r2, fullKey, url);
4646
+ switch (method) {
4647
+ case "HEAD": return handleHeadObject(r2, fullKey);
4648
+ case "GET": return handleGetObject(r2, fullKey, request);
4649
+ case "PUT": return handlePutObject(r2, bucketName, fullKey, request, env$1, permitted, mountPrefix);
4650
+ case "DELETE": return handleDeleteObject(r2, fullKey);
4651
+ default: return new Response("Method Not Allowed", { status: 405 });
4652
+ }
4653
+ };
4654
+
4655
+ //#endregion
4656
+ //#region src/tunnels/sandbox-control-callback.ts
4657
+ var SandboxControlCallbackImpl = class extends RpcTarget {
4658
+ constructor(getHandler, logger) {
4659
+ super();
4660
+ this.getHandler = getHandler;
4661
+ this.logger = logger;
4662
+ }
4663
+ async onTunnelExit(id, port, exitCode) {
4664
+ const handler = this.getHandler();
4665
+ if (!handler) {
4666
+ this.logger.debug("onTunnelExit: no handler bound; ignoring", {
4667
+ id,
4668
+ port,
4669
+ exitCode
4670
+ });
4671
+ return;
4672
+ }
4673
+ await handler(id, port, exitCode);
4674
+ }
4675
+ };
4676
+
4677
+ //#endregion
4678
+ //#region src/tunnels/tunnels-handler.ts
4679
+ /**
4680
+ * Tunnels namespace handler. Created once per Sandbox DO instance via
4681
+ * `createTunnelsHandler(host)` and exposed as `sandbox.tunnels`.
4682
+ *
4683
+ * Storage is the source of truth. The DO holds a `Record<portString, TunnelInfo>`
4684
+ * under the `tunnels` storage key. `Sandbox.onStart()` clears the key on every
4685
+ * container restart so any record in storage is by construction backed by a
4686
+ * running `cloudflared` process; the handler never needs to verify that
4687
+ * separately against the container.
4688
+ */
4689
+ /** DO storage key for the `port → TunnelInfo` map. */
4690
+ const STORAGE_KEY = "tunnels";
4691
+ function validateTunnelPort(port) {
4692
+ if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
4693
+ }
4694
+ /** 8-char hex id derived from `crypto.getRandomValues`. Unique per sandbox. */
4695
+ function shortId() {
4696
+ const buf = new Uint8Array(4);
4697
+ crypto.getRandomValues(buf);
4698
+ return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
4699
+ }
4700
+ function isTunnelNotFoundError(error) {
4701
+ return (error instanceof Error ? error.message : String(error)).includes("TUNNEL_NOT_FOUND");
4702
+ }
4703
+ async function readMap(storage) {
4704
+ return await storage.get(STORAGE_KEY) ?? {};
4705
+ }
4706
+ /**
4707
+ * Concrete `TunnelsHandler` implementation. Extends `RpcTarget` so it
4708
+ * can cross the Workers RPC boundary: the Sandbox DO is reachable from
4709
+ * Workers via Workers RPC (`stub.tunnels.get(port)`), and only
4710
+ * `RpcTarget` instances are passed by reference across that boundary.
4711
+ */
4712
+ var TunnelsRpcTarget = class extends RpcTarget$1 {
4713
+ #host;
4714
+ #withPortLock;
4715
+ constructor(host, withPortLock) {
4716
+ super();
4717
+ this.#host = host;
4718
+ this.#withPortLock = withPortLock;
4719
+ }
4720
+ async get(port) {
4721
+ const startTime = Date.now();
4722
+ let outcome = "error";
4723
+ let cacheState = "miss";
4724
+ let caughtError;
4725
+ try {
4726
+ validateTunnelPort(port);
4727
+ const info = await this.#withPortLock(port, async () => {
4728
+ const existing = (await readMap(this.#host.storage))[port.toString()];
4729
+ if (existing) {
4730
+ cacheState = "hit";
4731
+ return existing;
4732
+ }
4733
+ const id = `quick-${shortId()}`;
4734
+ const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
4735
+ await this.#host.storage.transaction(async (txn) => {
4736
+ const nextMap = await readMap(txn);
4737
+ nextMap[port.toString()] = spawned;
4738
+ await txn.put(STORAGE_KEY, nextMap);
4739
+ });
4740
+ return spawned;
4741
+ });
4742
+ outcome = "success";
4743
+ return info;
4744
+ } catch (error) {
4745
+ caughtError = error instanceof Error ? error : new Error(String(error));
4746
+ throw error;
4747
+ } finally {
4748
+ logCanonicalEvent(this.#host.logger, {
4749
+ event: "tunnel.get",
4750
+ outcome,
4751
+ port,
4752
+ cacheState,
4753
+ durationMs: Date.now() - startTime,
4754
+ error: caughtError
4755
+ });
4756
+ }
4757
+ }
4758
+ async destroy(portOrInfo) {
4759
+ const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
4760
+ const startTime = Date.now();
4761
+ let outcome = "error";
4762
+ let caughtError;
4763
+ let tunnelId;
4764
+ try {
4765
+ await this.#withPortLock(port, async () => {
4766
+ const existing = (await readMap(this.#host.storage))[port.toString()];
4767
+ if (!existing) return;
4768
+ tunnelId = existing.id;
4769
+ await this.#host.storage.transaction(async (txn) => {
4770
+ const current = await readMap(txn);
4771
+ delete current[port.toString()];
4772
+ await txn.put(STORAGE_KEY, current);
4773
+ });
4774
+ try {
4775
+ await this.#host.client.tunnels.destroyTunnel(existing.id);
4776
+ } catch (error) {
4777
+ if (!isTunnelNotFoundError(error)) throw error;
4778
+ }
4779
+ });
4780
+ outcome = "success";
4781
+ } catch (error) {
4782
+ caughtError = error instanceof Error ? error : new Error(String(error));
4783
+ throw error;
4784
+ } finally {
4785
+ logCanonicalEvent(this.#host.logger, {
4786
+ event: "tunnel.destroy",
4787
+ outcome,
4788
+ port,
4789
+ tunnelId,
4790
+ durationMs: Date.now() - startTime,
4791
+ error: caughtError
4792
+ });
4793
+ }
4794
+ }
4795
+ async list() {
4796
+ const map = await readMap(this.#host.storage);
4797
+ return Object.values(map);
4798
+ }
4799
+ };
4800
+ function createTunnelsHandler(host) {
4801
+ const portLocks = /* @__PURE__ */ new Map();
4802
+ const withPortLock = (port, fn) => {
4803
+ const next = (portLocks.get(port) ?? Promise.resolve()).then(fn, fn);
4804
+ portLocks.set(port, next.catch(() => void 0));
4805
+ return next;
4806
+ };
4807
+ const tunnels = new TunnelsRpcTarget(host, withPortLock);
4808
+ const handleTunnelExit = async (id, port, exitCode) => {
4809
+ const startTime = Date.now();
4810
+ await withPortLock(port, async () => {
4811
+ await host.storage.transaction(async (txn) => {
4812
+ const map = await readMap(txn);
4813
+ if (map[port.toString()]?.id === id) {
4814
+ delete map[port.toString()];
4815
+ await txn.put(STORAGE_KEY, map);
4816
+ }
4817
+ });
4818
+ logCanonicalEvent(host.logger, {
4819
+ event: "tunnel.exit",
4820
+ outcome: "success",
4821
+ port,
4822
+ tunnelId: id,
4823
+ exitCode: exitCode ?? void 0,
4824
+ durationMs: Date.now() - startTime
4825
+ });
4826
+ });
4827
+ };
4828
+ return {
4829
+ tunnels,
4830
+ handleTunnelExit
4831
+ };
4832
+ }
4833
+
4253
4834
  //#endregion
4254
4835
  //#region src/version.ts
4255
4836
  /**
@@ -4257,11 +4838,23 @@ function isLocalhostPattern(hostname) {
4257
4838
  * This file is auto-updated by .github/changeset-version.ts during releases
4258
4839
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
4259
4840
  */
4260
- const SDK_VERSION = "0.10.0";
4841
+ const SDK_VERSION = "0.10.2";
4261
4842
 
4262
4843
  //#endregion
4263
4844
  //#region src/sandbox.ts
4845
+ const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
4846
+ var R2EgressProxyTarget = class extends Container {};
4847
+ Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
4848
+ R2EgressProxyTarget.outboundHandlers = { r2EgressMount: r2EgressHandler };
4849
+ function isFetcher(value) {
4850
+ return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
4851
+ }
4264
4852
  const sandboxConfigurationCache = /* @__PURE__ */ new WeakMap();
4853
+ const R2_DEFAULT_S3FS_OPTIONS = {
4854
+ stat_cache_expire: "60",
4855
+ enable_noobj_cache: true,
4856
+ multipart_size: "5"
4857
+ };
4265
4858
  const BACKUP_DEFAULT_TTL_SECONDS = 259200;
4266
4859
  const BACKUP_MAX_NAME_LENGTH = 256;
4267
4860
  const BACKUP_CONTAINER_DIR = "/var/backups";
@@ -4407,6 +5000,10 @@ function getSandbox(ns, id, options) {
4407
5000
  desktop: new Proxy({}, { get(_, method) {
4408
5001
  if (typeof method !== "string" || method === "then") return void 0;
4409
5002
  return (...args) => stub.callDesktop(method, args);
5003
+ } }),
5004
+ tunnels: new Proxy({}, { get: (_, method) => {
5005
+ if (typeof method !== "string" || method === "then") return void 0;
5006
+ return (...args) => stub.callTunnels(method, args);
4410
5007
  } })
4411
5008
  };
4412
5009
  return new Proxy(stub, { get(target, prop) {
@@ -4427,19 +5024,15 @@ function connect(stub) {
4427
5024
  return await stub.fetch(portSwitchedRequest);
4428
5025
  };
4429
5026
  }
4430
- /**
4431
- * Type guard for R2Bucket binding.
4432
- * Checks for the minimal R2Bucket interface methods we use.
4433
- */
4434
- function isR2Bucket(value) {
4435
- return typeof value === "object" && value !== null && "put" in value && typeof value.put === "function" && "get" in value && typeof value.get === "function" && "head" in value && typeof value.head === "function" && "delete" in value && typeof value.delete === "function";
4436
- }
4437
5027
  var Sandbox = class Sandbox extends Container {
4438
5028
  defaultPort = 3e3;
4439
5029
  sleepAfter = "10m";
4440
5030
  client;
4441
5031
  codeInterpreter;
4442
5032
  sandboxName = null;
5033
+ tunnelsHandler = null;
5034
+ tunnelExitHandler = null;
5035
+ controlCallback;
4443
5036
  normalizeId = false;
4444
5037
  defaultSession = null;
4445
5038
  containerGeneration = 0;
@@ -4538,13 +5131,30 @@ var Sandbox = class Sandbox extends Container {
4538
5131
  * Dispatch method for desktop operations.
4539
5132
  * Called by the client-side proxy created in getSandbox() to provide
4540
5133
  * the `sandbox.desktop.status()` API without relying on RPC pipelining
4541
- * through property getters.
5134
+ * through property getters which is broken when using vite-plugin.
4542
5135
  */
4543
5136
  async callDesktop(method, args) {
4544
5137
  if (!Sandbox.DESKTOP_METHODS.has(method)) throw new Error(`Unknown desktop method: ${method}`);
4545
5138
  const client = this.client.desktop;
4546
5139
  const fn = client[method];
4547
- if (typeof fn !== "function") throw new Error(`Unknown desktop method: ${method}`);
5140
+ if (typeof fn !== "function") throw new Error(`sandbox.desktop missing method: ${method}`);
5141
+ return fn.apply(client, args);
5142
+ }
5143
+ /**
5144
+ * Dispatch method for tunnel operations.
5145
+ * Called by the client-side proxy created in getSandbox() to provide
5146
+ * the `sandbox.tunnels` API without relying on RPC pipelining
5147
+ * through property getters which is broken when using vite-plugin.
5148
+ */
5149
+ async callTunnels(method, args) {
5150
+ if (![
5151
+ "get",
5152
+ "list",
5153
+ "destroy"
5154
+ ].includes(method)) throw new Error(`Unknown tunnels method: ${method}`);
5155
+ const client = this.tunnels;
5156
+ const fn = client[method];
5157
+ if (typeof fn !== "function") throw new Error(`sandbox.tunnels missing method: ${method}`);
4548
5158
  return fn.apply(client, args);
4549
5159
  }
4550
5160
  /**
@@ -4590,6 +5200,7 @@ var Sandbox = class Sandbox extends Container {
4590
5200
  port: 3e3,
4591
5201
  logger: this.logger,
4592
5202
  retryTimeoutMs: this.computeRetryTimeoutMs(),
5203
+ localMain: this.controlCallback,
4593
5204
  onActivity: () => {
4594
5205
  this.renewActivityTimeout();
4595
5206
  },
@@ -4604,9 +5215,9 @@ var Sandbox = class Sandbox extends Container {
4604
5215
  }
4605
5216
  return this.createSandboxClient();
4606
5217
  }
4607
- constructor(ctx, env) {
4608
- super(ctx, env);
4609
- const envObj = env;
5218
+ constructor(ctx, env$1) {
5219
+ super(ctx, env$1);
5220
+ const envObj = env$1;
4610
5221
  ["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
4611
5222
  if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
4612
5223
  });
@@ -4629,6 +5240,7 @@ var Sandbox = class Sandbox extends Container {
4629
5240
  accessKeyId: this.r2AccessKeyId,
4630
5241
  secretAccessKey: this.r2SecretAccessKey
4631
5242
  });
5243
+ this.controlCallback = new SandboxControlCallbackImpl(() => this.tunnelExitHandler, this.logger);
4632
5244
  this.client = this.createClientForTransport(this.transport);
4633
5245
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
4634
5246
  this.ctx.blockConcurrencyWhile(async () => {
@@ -4656,6 +5268,8 @@ var Sandbox = class Sandbox extends Container {
4656
5268
  const previousClient = this.client;
4657
5269
  this.client = this.createClientForTransport(storedTransport);
4658
5270
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5271
+ this.tunnelsHandler = null;
5272
+ this.tunnelExitHandler = null;
4659
5273
  previousClient.disconnect();
4660
5274
  }
4661
5275
  if (storedTransport) this.hasStoredTransport = true;
@@ -4747,6 +5361,8 @@ var Sandbox = class Sandbox extends Container {
4747
5361
  this.hasStoredTransport = true;
4748
5362
  this.client = this.createClientForTransport(transport);
4749
5363
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5364
+ this.tunnelsHandler = null;
5365
+ this.tunnelExitHandler = null;
4750
5366
  previousClient.disconnect();
4751
5367
  this.renewActivityTimeout();
4752
5368
  this.logger.debug("Transport updated", { transport });
@@ -4764,7 +5380,7 @@ var Sandbox = class Sandbox extends Container {
4764
5380
  * Get default timeouts with env var fallbacks and validation
4765
5381
  * Precedence: SDK defaults < Env vars < User config
4766
5382
  */
4767
- getDefaultTimeouts(env) {
5383
+ getDefaultTimeouts(env$1) {
4768
5384
  const parseAndValidate = (envVar, name, min, max) => {
4769
5385
  const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
4770
5386
  if (envVar === void 0) return defaultValue;
@@ -4780,9 +5396,9 @@ var Sandbox = class Sandbox extends Container {
4780
5396
  return parsed;
4781
5397
  };
4782
5398
  return {
4783
- instanceGetTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
4784
- portReadyTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
4785
- waitIntervalMS: parseAndValidate(getEnvString(env, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
5399
+ instanceGetTimeoutMS: parseAndValidate(getEnvString(env$1, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
5400
+ portReadyTimeoutMS: parseAndValidate(getEnvString(env$1, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
5401
+ waitIntervalMS: parseAndValidate(getEnvString(env$1, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
4786
5402
  };
4787
5403
  }
4788
5404
  /**
@@ -4804,7 +5420,16 @@ var Sandbox = class Sandbox extends Container {
4804
5420
  await this.mountBucketLocal(bucket, mountPath, options);
4805
5421
  return;
4806
5422
  }
4807
- await this.mountBucketFuse(bucket, mountPath, options);
5423
+ const remoteOptions = options;
5424
+ if (remoteOptions.endpoint === void 0) {
5425
+ const binding = this.env[bucket];
5426
+ if (isR2Bucket(binding)) {
5427
+ await this.mountBucketR2Egress(bucket, mountPath, options);
5428
+ return;
5429
+ }
5430
+ throw new InvalidMountConfigError(`R2 binding "${bucket}" not found in Worker env. Ensure the binding name matches the bucket binding configured in wrangler.jsonc.`);
5431
+ }
5432
+ await this.mountBucketFuse(bucket, mountPath, remoteOptions);
4808
5433
  }
4809
5434
  /**
4810
5435
  * Local dev mount: bidirectional sync via R2 binding + file/watch APIs
@@ -4861,12 +5486,109 @@ var Sandbox = class Sandbox extends Container {
4861
5486
  });
4862
5487
  }
4863
5488
  }
5489
+ getR2EgressParams() {
5490
+ const buckets = {};
5491
+ for (const [, m] of this.activeMounts) if (m.mountType === "r2-egress") buckets[m.bucket] = {
5492
+ prefix: m.prefix,
5493
+ readOnly: m.readOnly
5494
+ };
5495
+ return { buckets };
5496
+ }
5497
+ validateR2EgressS3fsOptions(options) {
5498
+ if (!options) return;
5499
+ const protectedOptions = new Set(["passwd_file", "url"]);
5500
+ for (const option of options) {
5501
+ const [key] = option.split("=");
5502
+ if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for R2 binding mounts`);
5503
+ }
5504
+ }
5505
+ /**
5506
+ * Credential-less R2 mount: egress interception routes s3fs requests to the
5507
+ * R2 binding. No S3 credentials are needed in the container or Worker env.
5508
+ */
5509
+ async mountBucketR2Egress(bucket, mountPath, options) {
5510
+ const mountStartTime = Date.now();
5511
+ const prefix = options.prefix;
5512
+ let mountOutcome = "error";
5513
+ let mountError;
5514
+ try {
5515
+ validateBucketBindingName(bucket, mountPath);
5516
+ this.validateMountPath(mountPath);
5517
+ this.validateR2EgressS3fsOptions(options.s3fsOptions);
5518
+ for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
5519
+ if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.prefix !== prefix) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different prefix. Mount the same binding only once, or use the same prefix for additional mounts.`);
5520
+ if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.readOnly !== (options.readOnly ?? false)) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different readOnly setting. Mount the same binding only once, or use the same readOnly value for additional mounts.`);
5521
+ }
5522
+ const passwordFilePath = this.generatePasswordFilePath();
5523
+ await this.createPasswordFile(passwordFilePath, bucket, {
5524
+ accessKeyId: "x",
5525
+ secretAccessKey: "x"
5526
+ });
5527
+ const mountInfo = {
5528
+ mountType: "r2-egress",
5529
+ bucket,
5530
+ mountPath,
5531
+ passwordFilePath,
5532
+ mounted: false,
5533
+ prefix,
5534
+ readOnly: options.readOnly ?? false
5535
+ };
5536
+ this.activeMounts.set(mountPath, mountInfo);
5537
+ await this.configureR2EgressOutbound(this.getR2EgressParams());
5538
+ await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
5539
+ const s3fsSource = bucket;
5540
+ const optionsStr = shellEscape(serializeS3fsOptions({
5541
+ passwd_file: passwordFilePath,
5542
+ ...R2_DEFAULT_S3FS_OPTIONS,
5543
+ ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
5544
+ use_path_request_style: true,
5545
+ url: "http://r2.internal",
5546
+ ...options.readOnly ? { ro: true } : {}
5547
+ }));
5548
+ const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
5549
+ this.logger.debug("r2-egress: running s3fs", { mountCmd });
5550
+ const result = await this.execInternal(mountCmd);
5551
+ this.logger.debug("r2-egress: s3fs exited", {
5552
+ exitCode: result.exitCode,
5553
+ stdout: result.stdout,
5554
+ stderr: result.stderr
5555
+ });
5556
+ if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
5557
+ const mountpointCheck = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && echo 'FUSE_MOUNTED' || echo 'NOT_FUSE_MOUNTED'`);
5558
+ this.logger.debug("r2-egress: mountpoint check", {
5559
+ stdout: mountpointCheck.stdout.trim(),
5560
+ exitCode: mountpointCheck.exitCode
5561
+ });
5562
+ if (mountpointCheck.stdout.trim() !== "FUSE_MOUNTED") throw new S3FSMountError(`s3fs exited 0 but mount was not established at ${mountPath}`);
5563
+ mountInfo.mounted = true;
5564
+ mountOutcome = "success";
5565
+ } catch (error) {
5566
+ mountError = error instanceof Error ? error : new Error(String(error));
5567
+ const failedMount = this.activeMounts.get(mountPath);
5568
+ this.activeMounts.delete(mountPath);
5569
+ if (failedMount?.mountType === "r2-egress") await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
5570
+ const remainingParams = this.getR2EgressParams();
5571
+ await this.configureR2EgressOutbound(remainingParams).catch(() => {});
5572
+ throw error;
5573
+ } finally {
5574
+ logCanonicalEvent(this.logger, {
5575
+ event: "bucket.mount",
5576
+ outcome: mountOutcome,
5577
+ durationMs: Date.now() - mountStartTime,
5578
+ bucket,
5579
+ mountPath,
5580
+ provider: "r2",
5581
+ prefix,
5582
+ error: mountError
5583
+ });
5584
+ }
5585
+ }
4864
5586
  /**
4865
5587
  * Production mount: S3FS-FUSE inside the container
4866
5588
  */
4867
5589
  async mountBucketFuse(bucket, mountPath, options) {
4868
5590
  const mountStartTime = Date.now();
4869
- const prefix = options.prefix || void 0;
5591
+ const prefix = options.prefix;
4870
5592
  let mountOutcome = "error";
4871
5593
  let mountError;
4872
5594
  let passwordFilePath;
@@ -4957,6 +5679,7 @@ var Sandbox = class Sandbox extends Container {
4957
5679
  }
4958
5680
  mountInfo.mounted = false;
4959
5681
  this.activeMounts.delete(mountPath);
5682
+ if (mountInfo.mountType === "r2-egress") await this.configureR2EgressOutbound(this.getR2EgressParams());
4960
5683
  try {
4961
5684
  const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
4962
5685
  if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
@@ -4989,7 +5712,14 @@ var Sandbox = class Sandbox extends Container {
4989
5712
  }
4990
5713
  }
4991
5714
  /**
4992
- * Validate mount options
5715
+ * Shared validation for mount path (absolute, not already in use).
5716
+ */
5717
+ validateMountPath(mountPath) {
5718
+ if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
5719
+ if (this.activeMounts.has(mountPath)) throw new InvalidMountConfigError(`Mount path "${mountPath}" is already in use by bucket "${this.activeMounts.get(mountPath)?.bucket}". Unmount the existing bucket first or use a different mount path.`);
5720
+ }
5721
+ /**
5722
+ * Validate mount options for remote (FUSE) mounts
4993
5723
  */
4994
5724
  validateMountOptions(bucket, mountPath, options) {
4995
5725
  try {
@@ -4998,8 +5728,7 @@ var Sandbox = class Sandbox extends Container {
4998
5728
  throw new InvalidMountConfigError(`Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.`);
4999
5729
  }
5000
5730
  validateBucketName(bucket, mountPath);
5001
- if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
5002
- if (this.activeMounts.has(mountPath)) throw new InvalidMountConfigError(`Mount path "${mountPath}" is already in use by bucket "${this.activeMounts.get(mountPath)?.bucket}". Unmount the existing bucket first or use a different mount path.`);
5731
+ this.validateMountPath(mountPath);
5003
5732
  }
5004
5733
  /**
5005
5734
  * Generate unique password file path for s3fs credentials
@@ -5059,6 +5788,13 @@ var Sandbox = class Sandbox extends Container {
5059
5788
  if (result.exitCode === 2) throw new S3FSMountError(`S3FS mount failed: ${detail || "Unknown error"}`);
5060
5789
  throw new S3FSMountError(`S3FS mount failed: FUSE filesystem never appeared at ${mountPath}. ${detail ? `s3fs log: ${detail}` : "No s3fs log output captured. The s3fs daemon may have exited before writing logs."}`);
5061
5790
  }
5791
+ async unmountTrackedFuseMount(mountPath, mountInfo) {
5792
+ if (!mountInfo.mounted) return;
5793
+ this.logger.debug(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
5794
+ const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
5795
+ if (result.exitCode !== 0) throw new Error(`fusermount -u failed (exit ${result.exitCode}): ${result.stderr || "unknown error"}`);
5796
+ mountInfo.mounted = false;
5797
+ }
5062
5798
  /**
5063
5799
  * In-flight `destroy()` promise. While set, concurrent callers coalesce
5064
5800
  * onto the same teardown instead of triggering a second one. Cleared when
@@ -5120,10 +5856,8 @@ var Sandbox = class Sandbox extends Container {
5120
5856
  this.logger.warn(`Failed to stop local sync for ${mountPath}: ${errorMsg}`);
5121
5857
  }
5122
5858
  else {
5123
- if (mountInfo.mounted) try {
5124
- this.logger.debug(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
5125
- await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
5126
- mountInfo.mounted = false;
5859
+ try {
5860
+ await this.unmountTrackedFuseMount(mountPath, mountInfo);
5127
5861
  } catch (error) {
5128
5862
  mountFailures++;
5129
5863
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -5133,6 +5867,7 @@ var Sandbox = class Sandbox extends Container {
5133
5867
  }
5134
5868
  }
5135
5869
  await this.ctx.storage.delete("portTokens");
5870
+ await this.ctx.storage.delete("tunnels");
5136
5871
  this.client.disconnect();
5137
5872
  outcome = "success";
5138
5873
  await super.destroy();
@@ -5160,6 +5895,11 @@ var Sandbox = class Sandbox extends Container {
5160
5895
  } catch (error) {
5161
5896
  this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
5162
5897
  }
5898
+ try {
5899
+ await this.ctx.storage.delete("tunnels");
5900
+ } catch (error) {
5901
+ this.logger.error("Failed to clear tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
5902
+ }
5163
5903
  }
5164
5904
  /**
5165
5905
  * Re-expose ports on the container runtime using tokens persisted in DO
@@ -5262,7 +6002,10 @@ var Sandbox = class Sandbox extends Container {
5262
6002
  this.defaultSession = null;
5263
6003
  this.defaultSessionInit = null;
5264
6004
  this.client.disconnect();
6005
+ let hadR2EgressMount = false;
5265
6006
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
6007
+ else if (m.mountType === "r2-egress") hadR2EgressMount = true;
6008
+ if (hadR2EgressMount) await this.configureR2EgressOutbound({ buckets: {} }).catch(() => {});
5266
6009
  this.activeMounts.clear();
5267
6010
  await this.ctx.storage.delete("defaultSession");
5268
6011
  }
@@ -6009,8 +6752,11 @@ var Sandbox = class Sandbox extends Container {
6009
6752
  }
6010
6753
  async getProcess(id, sessionId) {
6011
6754
  const session = sessionId ?? await this.ensureDefaultSession();
6012
- const response = await this.client.processes.getProcess(id);
6013
- if (!response.process) return null;
6755
+ const response = await this.client.processes.getProcess(id).catch((e) => {
6756
+ if (e instanceof ProcessNotFoundError) return null;
6757
+ throw e;
6758
+ });
6759
+ if (!response?.process) return null;
6014
6760
  const processData = response.process;
6015
6761
  return this.createProcessFromDTO({
6016
6762
  id: processData.id,
@@ -6081,7 +6827,7 @@ var Sandbox = class Sandbox extends Container {
6081
6827
  }
6082
6828
  async writeFile(path$1, content, options = {}) {
6083
6829
  const session = options.sessionId ?? await this.ensureDefaultSession();
6084
- if (content instanceof ReadableStream) return this.client.writeFileStream(path$1, content, session);
6830
+ if (content instanceof ReadableStream) return this.client.files.writeFileStream(path$1, content, session);
6085
6831
  return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
6086
6832
  }
6087
6833
  async deleteFile(path$1, sessionId) {
@@ -6098,6 +6844,7 @@ var Sandbox = class Sandbox extends Container {
6098
6844
  }
6099
6845
  async readFile(path$1, options = {}) {
6100
6846
  const session = options.sessionId ?? await this.ensureDefaultSession();
6847
+ if (options.encoding === "none") return this.client.files.readFile(path$1, session, { encoding: "none" });
6101
6848
  return this.client.files.readFile(path$1, session, { encoding: options.encoding });
6102
6849
  }
6103
6850
  /**
@@ -6326,6 +7073,45 @@ var Sandbox = class Sandbox extends Container {
6326
7073
  }];
6327
7074
  });
6328
7075
  }
7076
+ /**
7077
+ * Namespaced tunnel API. Quick tunnels are zero-config preview URLs
7078
+ * backed by Cloudflare's trycloudflare service.
7079
+ *
7080
+ * - `tunnels.get(port)` — idempotent. Returns the cached tunnel for
7081
+ * `port` if one exists in DO storage, otherwise spawns a fresh
7082
+ * cloudflared process and persists the record.
7083
+ * - `tunnels.list()` — records currently known to this sandbox, from
7084
+ * DO storage.
7085
+ * - `tunnels.destroy(portOrInfo)` — tear down by port number or by
7086
+ * the record returned from `get()`.
7087
+ *
7088
+ * Storage is cleared on container restart (`onStart`), so URLs do
7089
+ * not survive a container restart — the next `get(port)` call will
7090
+ * spawn a fresh tunnel with a new URL.
7091
+ *
7092
+ * Requires the RPC transport. Calling this on a route-based transport
7093
+ * throws "RPC transport required".
7094
+ */
7095
+ get tunnels() {
7096
+ this.ensureTunnelsBuilt();
7097
+ return this.tunnelsHandler;
7098
+ }
7099
+ /**
7100
+ * Lazily construct both the public tunnels handler and its sibling
7101
+ * exit-handler callback. Called from the `tunnels` getter on first
7102
+ * access and on every access after a transport swap clears both
7103
+ * fields.
7104
+ */
7105
+ ensureTunnelsBuilt() {
7106
+ if (this.tunnelsHandler) return;
7107
+ const built = createTunnelsHandler({
7108
+ client: this.client,
7109
+ storage: this.ctx.storage,
7110
+ logger: this.logger
7111
+ });
7112
+ this.tunnelsHandler = built.tunnels;
7113
+ this.tunnelExitHandler = built.handleTunnelExit;
7114
+ }
6329
7115
  async isPortExposed(port) {
6330
7116
  try {
6331
7117
  const sessionId = await this.ensureDefaultSession();
@@ -6472,9 +7258,16 @@ var Sandbox = class Sandbox extends Container {
6472
7258
  ...options,
6473
7259
  sessionId
6474
7260
  }),
6475
- readFile: (path$1, options) => this.readFile(path$1, {
6476
- ...options,
6477
- sessionId
7261
+ readFile: ((path$1, options) => {
7262
+ const encoding = options?.encoding;
7263
+ if (encoding === "none") return this.readFile(path$1, {
7264
+ encoding: "none",
7265
+ sessionId
7266
+ });
7267
+ return this.readFile(path$1, {
7268
+ encoding,
7269
+ sessionId
7270
+ });
6478
7271
  }),
6479
7272
  readFileStream: (path$1) => this.readFileStream(path$1, { sessionId }),
6480
7273
  watch: (path$1, options) => this.watch(path$1, {
@@ -7534,7 +8327,7 @@ var Sandbox = class Sandbox extends Container {
7534
8327
  },
7535
8328
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7536
8329
  });
7537
- await this.client.writeFileStream(archivePath, body, backupSession);
8330
+ await this.client.files.writeFileStream(archivePath, body, backupSession);
7538
8331
  } else {
7539
8332
  const archiveBuffer = await archiveObject.arrayBuffer();
7540
8333
  const base64Content = Buffer.from(archiveBuffer).toString("base64");
@@ -7588,8 +8381,24 @@ var Sandbox = class Sandbox extends Container {
7588
8381
  });
7589
8382
  }
7590
8383
  }
8384
+ async configureR2EgressOutbound(params) {
8385
+ const ctx = this.ctx;
8386
+ if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
8387
+ if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
8388
+ const fetcher = ctx.exports.ContainerProxy({ props: {
8389
+ enableInternet: this.enableInternet,
8390
+ containerId: this.ctx.id.toString(),
8391
+ className: R2_EGRESS_PROXY_TARGET_CLASS_NAME,
8392
+ outboundByHostOverrides: { "r2.internal": {
8393
+ method: "r2EgressMount",
8394
+ params
8395
+ } }
8396
+ } });
8397
+ if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
8398
+ await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
8399
+ }
7591
8400
  };
7592
8401
 
7593
8402
  //#endregion
7594
8403
  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 };
7595
- //# sourceMappingURL=sandbox-2bHZZmy5.js.map
8404
+ //# sourceMappingURL=sandbox-BcEq4aUF.js.map