@cloudflare/sandbox 0.9.0 → 0.9.1

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,7 +1,8 @@
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-Ilf8VjmX.js";
2
- import { t as ErrorCode } from "./errors-Dk2rApYI.js";
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 ErrorCode, t as getHttpStatus } from "./errors-LE3HHcRb.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
+ import { RpcSession } from "capnweb";
5
6
  import path from "node:path/posix";
6
7
 
7
8
  //#region src/errors/classes.ts
@@ -194,6 +195,9 @@ var SessionAlreadyExistsError = class extends SandboxError {
194
195
  get sessionId() {
195
196
  return this.context.sessionId;
196
197
  }
198
+ get containerPlacementId() {
199
+ return this.context.containerPlacementId;
200
+ }
197
201
  };
198
202
  /**
199
203
  * Error thrown when a session was destroyed while a command was executing
@@ -873,8 +877,8 @@ var HttpTransport = class extends BaseTransport {
873
877
  */
874
878
  const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
875
879
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
876
- const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
877
- const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
880
+ const DEFAULT_CONNECT_TIMEOUT_MS$1 = 3e4;
881
+ const DEFAULT_IDLE_DISCONNECT_MS$1 = 1e3;
878
882
  const MIN_TIME_FOR_CONNECT_RETRY_MS = 15e3;
879
883
  /**
880
884
  * WebSocket transport implementation
@@ -1030,7 +1034,7 @@ var WebSocketTransport = class extends BaseTransport {
1030
1034
  * parent Container class that supports the WebSocket protocol.
1031
1035
  */
1032
1036
  async connectViaFetch() {
1033
- const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
1037
+ const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
1034
1038
  try {
1035
1039
  const wsPath = new URL(this.config.wsUrl).pathname;
1036
1040
  const httpUrl = `http://localhost:${this.config.port || 3e3}${wsPath}`;
@@ -1071,7 +1075,7 @@ var WebSocketTransport = class extends BaseTransport {
1071
1075
  */
1072
1076
  connectViaWebSocket() {
1073
1077
  return new Promise((resolve, reject) => {
1074
- const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
1078
+ const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
1075
1079
  const timeout = setTimeout(() => {
1076
1080
  this.cleanup();
1077
1081
  reject(/* @__PURE__ */ new Error(`WebSocket connection timeout after ${timeoutMs}ms`));
@@ -1453,7 +1457,7 @@ var WebSocketTransport = class extends BaseTransport {
1453
1457
  this.logger.debug("Disconnecting idle WebSocket transport");
1454
1458
  this.cleanup();
1455
1459
  }
1456
- }, DEFAULT_IDLE_DISCONNECT_MS);
1460
+ }, DEFAULT_IDLE_DISCONNECT_MS$1);
1457
1461
  }
1458
1462
  clearIdleDisconnectTimer() {
1459
1463
  if (this.idleDisconnectTimer) {
@@ -1663,12 +1667,12 @@ var BackupClient = class extends BaseHttpClient {
1663
1667
  * @param archivePath - Where the container should write the archive
1664
1668
  * @param sessionId - Session context
1665
1669
  */
1666
- async createArchive(dir, archivePath, sessionId, gitignore = false, excludes = []) {
1670
+ async createArchive(dir, archivePath, sessionId, options) {
1667
1671
  const data = {
1668
1672
  dir,
1669
1673
  archivePath,
1670
- gitignore,
1671
- excludes,
1674
+ gitignore: options?.gitignore ?? false,
1675
+ excludes: options?.excludes ?? [],
1672
1676
  sessionId
1673
1677
  };
1674
1678
  return await this.post("/api/backup/create", data);
@@ -2589,14 +2593,13 @@ var WatchClient = class extends BaseHttpClient {
2589
2593
  //#endregion
2590
2594
  //#region src/clients/sandbox-client.ts
2591
2595
  /**
2592
- * Main sandbox client that composes all domain-specific clients
2593
- * Provides organized access to all sandbox functionality
2596
+ * Main sandbox client that composes all domain-specific clients.
2597
+ * Provides organized access to all sandbox functionality.
2594
2598
  *
2595
2599
  * Supports two transport modes:
2596
2600
  * - HTTP (default): Each request is a separate HTTP call
2597
- * - WebSocket: All requests multiplexed over a single connection
2598
- *
2599
- * WebSocket mode reduces sub-request count when running inside Workers/Durable Objects.
2601
+ * - WebSocket: All requests multiplexed over a single connection,
2602
+ * reducing sub-request count inside Workers/Durable Objects
2600
2603
  */
2601
2604
  var SandboxClient = class {
2602
2605
  backup;
@@ -2612,7 +2615,7 @@ var SandboxClient = class {
2612
2615
  transport = null;
2613
2616
  constructor(options) {
2614
2617
  if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
2615
- mode: "websocket",
2618
+ mode: options.transportMode,
2616
2619
  wsUrl: options.wsUrl,
2617
2620
  baseUrl: options.baseUrl,
2618
2621
  logger: options.logger,
@@ -2671,6 +2674,16 @@ var SandboxClient = class {
2671
2674
  return this.transport?.isConnected() ?? false;
2672
2675
  }
2673
2676
  /**
2677
+ * Stream a file directly to the container over a binary RPC channel.
2678
+ *
2679
+ * Requires the capnweb transport (`useWebSocket: 'rpc'`). Calling this
2680
+ * method with the HTTP or WebSocket transports throws an error because those
2681
+ * transports do not support binary streaming.
2682
+ */
2683
+ writeFileStream(_path, _content, _sessionId) {
2684
+ throw new Error("writeFileStream requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.");
2685
+ }
2686
+ /**
2674
2687
  * Connect WebSocket transport (no-op in HTTP mode)
2675
2688
  * Called automatically on first request, but can be called explicitly
2676
2689
  * to establish connection upfront.
@@ -2700,6 +2713,452 @@ const BACKUP_ALLOWED_PREFIXES = [
2700
2713
  "/app"
2701
2714
  ];
2702
2715
 
2716
+ //#endregion
2717
+ //#region src/container-connection.ts
2718
+ const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
2719
+ /**
2720
+ * Manages a capnweb WebSocket RPC session to the container.
2721
+ *
2722
+ * The RPC stub is created eagerly in the constructor using a deferred
2723
+ * transport. Calls made before `connect()` completes are queued in the
2724
+ * transport and flushed once the WebSocket is established.
2725
+ */
2726
+ var ContainerConnection = class {
2727
+ stub;
2728
+ session;
2729
+ transport;
2730
+ ws = null;
2731
+ connected = false;
2732
+ connectPromise = null;
2733
+ containerStub;
2734
+ port;
2735
+ logger;
2736
+ constructor(options) {
2737
+ this.containerStub = options.stub;
2738
+ this.port = options.port ?? 3e3;
2739
+ this.logger = options.logger ?? createNoOpLogger();
2740
+ this.transport = new DeferredTransport();
2741
+ this.session = new RpcSession(this.transport);
2742
+ this.stub = this.session.getRemoteMain();
2743
+ }
2744
+ /**
2745
+ * Get the typed RPC stub.
2746
+ *
2747
+ * The stub is available immediately — calls made before connect()
2748
+ * completes are queued in the deferred transport and flushed once
2749
+ * the WebSocket is established.
2750
+ */
2751
+ rpc() {
2752
+ if (!this.connected && !this.connectPromise) this.connect().catch(() => {});
2753
+ return this.stub;
2754
+ }
2755
+ /**
2756
+ * Return capnweb session statistics. The `imports` and `exports` counts
2757
+ * reflect all in-flight RPC calls, streams, and peer-held references.
2758
+ * An idle session has imports <= 1 && exports <= 1 (the bootstrap stubs).
2759
+ */
2760
+ getStats() {
2761
+ return this.session.getStats();
2762
+ }
2763
+ isConnected() {
2764
+ return this.connected;
2765
+ }
2766
+ async connect() {
2767
+ if (this.connected) return;
2768
+ if (this.connectPromise) return this.connectPromise;
2769
+ this.connectPromise = this.doConnect();
2770
+ try {
2771
+ await this.connectPromise;
2772
+ } finally {
2773
+ this.connectPromise = null;
2774
+ }
2775
+ }
2776
+ disconnect() {
2777
+ try {
2778
+ this.stub[Symbol.dispose]?.();
2779
+ } catch {}
2780
+ if (this.ws) {
2781
+ try {
2782
+ this.ws.close();
2783
+ } catch {}
2784
+ this.ws = null;
2785
+ }
2786
+ this.connected = false;
2787
+ this.connectPromise = null;
2788
+ }
2789
+ async doConnect() {
2790
+ const controller = new AbortController();
2791
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
2792
+ try {
2793
+ const url = `http://localhost:${this.port}/rpc`;
2794
+ const request = new Request(url, {
2795
+ headers: {
2796
+ Upgrade: "websocket",
2797
+ Connection: "Upgrade"
2798
+ },
2799
+ signal: controller.signal
2800
+ });
2801
+ const response = await this.containerStub.fetch(request);
2802
+ clearTimeout(timeout);
2803
+ if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
2804
+ const ws = response.webSocket;
2805
+ if (!ws) throw new Error("No WebSocket in upgrade response");
2806
+ ws.accept();
2807
+ ws.addEventListener("close", () => {
2808
+ this.connected = false;
2809
+ this.ws = null;
2810
+ this.logger.debug("ContainerConnection WebSocket closed");
2811
+ });
2812
+ ws.addEventListener("error", () => {
2813
+ this.connected = false;
2814
+ this.ws = null;
2815
+ });
2816
+ this.ws = ws;
2817
+ this.transport.activate(ws);
2818
+ this.connected = true;
2819
+ this.logger.debug("ContainerConnection established", { port: this.port });
2820
+ } catch (error) {
2821
+ clearTimeout(timeout);
2822
+ this.connected = false;
2823
+ this.transport.abort(error);
2824
+ this.logger.error("ContainerConnection failed", error instanceof Error ? error : new Error(String(error)));
2825
+ throw error;
2826
+ }
2827
+ }
2828
+ };
2829
+ /**
2830
+ * RPC transport that queues sends and blocks receives until a WebSocket
2831
+ * is provided via `activate()`. Allows the RPC stub to be created before
2832
+ * the connection is established — queued calls flush automatically.
2833
+ */
2834
+ var DeferredTransport = class {
2835
+ #ws = null;
2836
+ #sendQueue = [];
2837
+ #receiveQueue = [];
2838
+ #receiveResolver;
2839
+ #receiveRejecter;
2840
+ #error;
2841
+ activate(ws) {
2842
+ this.#ws = ws;
2843
+ ws.addEventListener("message", (event) => {
2844
+ if (this.#error) return;
2845
+ if (typeof event.data === "string") if (this.#receiveResolver) {
2846
+ this.#receiveResolver(event.data);
2847
+ this.#receiveResolver = void 0;
2848
+ this.#receiveRejecter = void 0;
2849
+ } else this.#receiveQueue.push(event.data);
2850
+ });
2851
+ ws.addEventListener("close", (event) => {
2852
+ this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
2853
+ });
2854
+ ws.addEventListener("error", () => {
2855
+ this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed"));
2856
+ });
2857
+ for (const msg of this.#sendQueue) ws.send(msg);
2858
+ this.#sendQueue = [];
2859
+ }
2860
+ async send(message) {
2861
+ if (this.#ws) this.#ws.send(message);
2862
+ else this.#sendQueue.push(message);
2863
+ }
2864
+ async receive() {
2865
+ if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
2866
+ if (this.#error) throw this.#error;
2867
+ return new Promise((resolve, reject) => {
2868
+ this.#receiveResolver = resolve;
2869
+ this.#receiveRejecter = reject;
2870
+ });
2871
+ }
2872
+ abort(reason) {
2873
+ this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
2874
+ if (this.#ws) {
2875
+ const message = reason instanceof Error ? reason.message : String(reason);
2876
+ this.#ws.close(3e3, message);
2877
+ }
2878
+ }
2879
+ #fail(err) {
2880
+ if (this.#error) return;
2881
+ this.#error = err;
2882
+ this.#receiveRejecter?.(err);
2883
+ this.#receiveResolver = void 0;
2884
+ this.#receiveRejecter = void 0;
2885
+ }
2886
+ };
2887
+
2888
+ //#endregion
2889
+ //#region src/clients/rpc-sandbox-client.ts
2890
+ /** Close the idle capnweb WebSocket promptly so the DO can sleep. */
2891
+ const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
2892
+ /**
2893
+ * How often the busy/idle poller samples `getStats()`.
2894
+ *
2895
+ * Sets two worst-case bounds:
2896
+ *
2897
+ * 1. **Idle-detection lag.** Time between the session going idle on
2898
+ * the wire and the DO observing it (and arming the disconnect).
2899
+ * Bounded by `pollInterval`.
2900
+ * 2. **Activity-renewal lag while busy.** While a stream is active we
2901
+ * renew the DO's activity timeout once per tick. The alarm could
2902
+ * fire as late as `sleepAfter` after the last renew, so the
2903
+ * effective margin against a mid-stream sleep is
2904
+ * `sleepAfter - pollInterval`.
2905
+ *
2906
+ * **Invariant: `pollInterval` must be comfortably less than the
2907
+ * smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
2908
+ * The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
2909
+ * 3× margin and at least two renewals during a 3s window. If a smaller
2910
+ * `sleepAfter` is ever supported, drop this proportionally.
2911
+ */
2912
+ const BUSY_POLL_INTERVAL_MS = 1e3;
2913
+ /**
2914
+ * Baseline getStats() values for an idle session. The bootstrap stub on each
2915
+ * side accounts for 1 import and 1 export.
2916
+ */
2917
+ const IDLE_IMPORT_THRESHOLD = 1;
2918
+ const IDLE_EXPORT_THRESHOLD = 1;
2919
+ /**
2920
+ * Translate a capnweb-propagated error into a typed SandboxError.
2921
+ *
2922
+ * capnweb only preserves `error.name` and `error.message` across the wire.
2923
+ * The container encodes the full error as a JSON object in the message
2924
+ * string: `{"code":"...","message":"...","context":{...}}`.
2925
+ */
2926
+ function translateRPCError(error) {
2927
+ if (error instanceof Error) try {
2928
+ const payload = JSON.parse(error.message);
2929
+ if (typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
2930
+ code: payload.code,
2931
+ message: payload.message,
2932
+ context: payload.context ?? {},
2933
+ httpStatus: getHttpStatus(payload.code),
2934
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2935
+ });
2936
+ } catch (e) {
2937
+ if (e instanceof Error && e !== error) throw e;
2938
+ }
2939
+ throw error;
2940
+ }
2941
+ /**
2942
+ * Wrap a capnweb RPC stub so that every method call translates errors
2943
+ * from the JSON wire format into typed SandboxError instances and signals
2944
+ * activity at call start.
2945
+ *
2946
+ * `onCallStarted` fires synchronously when an RPC method is invoked. The
2947
+ * RPCSandboxClient uses this to renew the DO's activity timeout
2948
+ * immediately, so even a call that completes entirely between two
2949
+ * busy-poll ticks still pushes the sleepAfter deadline forward.
2950
+ *
2951
+ * Note: there is no `onCallSettled` hook. A method whose returned promise
2952
+ * resolves with a `ReadableStream` is *not* finished when the promise
2953
+ * settles — capnweb keeps the export alive until the stream ends. The
2954
+ * busy/idle poll on `getStats()` is the source of truth for that.
2955
+ */
2956
+ function wrapStub(stub, onCallStarted) {
2957
+ return new Proxy(stub, { get(target, prop, receiver) {
2958
+ const value = Reflect.get(target, prop, receiver);
2959
+ if (typeof value !== "function") return value;
2960
+ return (...args) => {
2961
+ onCallStarted();
2962
+ try {
2963
+ const result = Reflect.apply(value, target, args);
2964
+ if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
2965
+ return result;
2966
+ } catch (err) {
2967
+ translateRPCError(err);
2968
+ }
2969
+ };
2970
+ } });
2971
+ }
2972
+ /**
2973
+ * SandboxClient backed by direct capnweb RPC.
2974
+ *
2975
+ * Drop-in replacement for SandboxClient when the capnweb transport is active.
2976
+ * All operations call the container's SandboxRPCAPI directly over capnweb,
2977
+ * bypassing the HTTP handler/router layer entirely.
2978
+ *
2979
+ * Manages its own WebSocket lifecycle: a fresh `ContainerConnection` is
2980
+ * created on demand and torn down after `idleDisconnectMs` of inactivity.
2981
+ * Busy/idle detection relies on `RpcSession.getStats()` which tracks all
2982
+ * in-flight RPC calls and stream exports — including long-lived streaming
2983
+ * RPCs that would be invisible to a simple per-call request counter (see
2984
+ * the file-level comment for the full rationale).
2985
+ */
2986
+ var RPCSandboxClient = class {
2987
+ connOptions;
2988
+ idleDisconnectMs;
2989
+ busyPollIntervalMs;
2990
+ logger;
2991
+ onActivity;
2992
+ onSessionBusy;
2993
+ onSessionIdle;
2994
+ conn = null;
2995
+ idleTimer = null;
2996
+ busyPollTimer = null;
2997
+ /** Tracks whether we currently believe the session is busy. */
2998
+ busy = false;
2999
+ /**
3000
+ * Set the first time the poller observes `conn.isConnected() === true`,
3001
+ * cleared in `destroyConnection()`. Lets us distinguish "the WebSocket
3002
+ * upgrade is still in progress" (don't tear down) from "we were
3003
+ * connected and the peer went away" (do tear down).
3004
+ */
3005
+ wasEverConnected = false;
3006
+ constructor(options) {
3007
+ this.connOptions = {
3008
+ stub: options.stub,
3009
+ port: options.port,
3010
+ logger: options.logger
3011
+ };
3012
+ this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
3013
+ this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
3014
+ this.logger = options.logger ?? createNoOpLogger();
3015
+ this.onActivity = options.onActivity;
3016
+ this.onSessionBusy = options.onSessionBusy;
3017
+ this.onSessionIdle = options.onSessionIdle;
3018
+ }
3019
+ /**
3020
+ * Return the current connection, creating a new one if none exists or the
3021
+ * previous one was torn down by an idle disconnect. Starts the busy-poll
3022
+ * timer the first time a connection is materialized.
3023
+ */
3024
+ getConnection() {
3025
+ if (!this.conn) {
3026
+ this.conn = new ContainerConnection(this.connOptions);
3027
+ this.startBusyPoll();
3028
+ }
3029
+ return this.conn;
3030
+ }
3031
+ /**
3032
+ * Called synchronously at the start of each RPC method invocation.
3033
+ * Renews the DO activity timeout so the sleepAfter alarm is pushed
3034
+ * forward before the container processes the call.
3035
+ */
3036
+ renewActivity = () => {
3037
+ this.onActivity?.();
3038
+ };
3039
+ /**
3040
+ * Sample `getStats()` and update busy/idle state. While busy, renews the
3041
+ * activity timeout each tick so an in-flight stream keeps pushing the
3042
+ * sleepAfter deadline forward. On the busy → idle edge, fires
3043
+ * `onSessionIdle` and schedules the WebSocket disconnect.
3044
+ *
3045
+ * If the WebSocket has dropped underneath us (container crash, network
3046
+ * blip) we tear the connection down here. `destroyConnection()` fires
3047
+ * `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
3048
+ * stay pinned forever waiting for a peer that's never going to reply.
3049
+ */
3050
+ pollBusyState = () => {
3051
+ const conn = this.conn;
3052
+ if (!conn) return;
3053
+ if (!conn.isConnected()) {
3054
+ if (this.wasEverConnected) this.destroyConnection();
3055
+ return;
3056
+ }
3057
+ this.wasEverConnected = true;
3058
+ const { imports, exports } = conn.getStats();
3059
+ if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
3060
+ if (!this.busy) {
3061
+ this.busy = true;
3062
+ this.onSessionBusy?.();
3063
+ }
3064
+ this.onActivity?.();
3065
+ this.clearIdleTimer();
3066
+ } else if (this.busy) {
3067
+ this.busy = false;
3068
+ this.onSessionIdle?.();
3069
+ this.scheduleIdleDisconnect();
3070
+ } else if (!this.idleTimer) this.scheduleIdleDisconnect();
3071
+ };
3072
+ startBusyPoll() {
3073
+ if (this.busyPollTimer) return;
3074
+ this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
3075
+ }
3076
+ stopBusyPoll() {
3077
+ if (this.busyPollTimer) {
3078
+ clearInterval(this.busyPollTimer);
3079
+ this.busyPollTimer = null;
3080
+ }
3081
+ }
3082
+ scheduleIdleDisconnect() {
3083
+ this.clearIdleTimer();
3084
+ this.idleTimer = setTimeout(() => {
3085
+ this.idleTimer = null;
3086
+ const conn = this.conn;
3087
+ if (!conn || !conn.isConnected()) return;
3088
+ const { imports, exports } = conn.getStats();
3089
+ if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
3090
+ this.logger.debug("Disconnecting idle capnweb connection");
3091
+ this.destroyConnection();
3092
+ }
3093
+ }, this.idleDisconnectMs);
3094
+ }
3095
+ clearIdleTimer() {
3096
+ if (this.idleTimer) {
3097
+ clearTimeout(this.idleTimer);
3098
+ this.idleTimer = null;
3099
+ }
3100
+ }
3101
+ destroyConnection() {
3102
+ this.stopBusyPoll();
3103
+ this.clearIdleTimer();
3104
+ if (this.busy) {
3105
+ this.busy = false;
3106
+ this.onSessionIdle?.();
3107
+ }
3108
+ if (this.conn) {
3109
+ this.conn.disconnect();
3110
+ this.conn = null;
3111
+ }
3112
+ this.wasEverConnected = false;
3113
+ }
3114
+ get commands() {
3115
+ return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
3116
+ }
3117
+ get files() {
3118
+ return wrapStub(this.getConnection().rpc().files, this.renewActivity);
3119
+ }
3120
+ get processes() {
3121
+ return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
3122
+ }
3123
+ get ports() {
3124
+ return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
3125
+ }
3126
+ get git() {
3127
+ return wrapStub(this.getConnection().rpc().git, this.renewActivity);
3128
+ }
3129
+ get utils() {
3130
+ return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
3131
+ }
3132
+ get backup() {
3133
+ return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3134
+ }
3135
+ get desktop() {
3136
+ return wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3137
+ }
3138
+ get watch() {
3139
+ return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3140
+ }
3141
+ get interpreter() {
3142
+ return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3143
+ }
3144
+ setRetryTimeoutMs(_ms) {}
3145
+ getTransportMode() {
3146
+ return "rpc";
3147
+ }
3148
+ isWebSocketConnected() {
3149
+ return this.conn?.isConnected() ?? false;
3150
+ }
3151
+ async connect() {
3152
+ await this.getConnection().connect();
3153
+ }
3154
+ disconnect() {
3155
+ this.destroyConnection();
3156
+ }
3157
+ async writeFileStream(path$1, stream, sessionId) {
3158
+ return this.files.writeFileStream(path$1, stream, sessionId);
3159
+ }
3160
+ };
3161
+
2703
3162
  //#endregion
2704
3163
  //#region src/file-stream.ts
2705
3164
  /**
@@ -2834,11 +3293,11 @@ async function collectFile(stream) {
2834
3293
  * - Host header injection
2835
3294
  * - Open redirect vulnerabilities
2836
3295
  */
2837
- var SecurityError = class extends Error {
3296
+ var SandboxSecurityError = class extends Error {
2838
3297
  constructor(message, code) {
2839
3298
  super(message);
2840
3299
  this.code = code;
2841
- this.name = "SecurityError";
3300
+ this.name = "SandboxSecurityError";
2842
3301
  }
2843
3302
  };
2844
3303
  /**
@@ -2859,8 +3318,8 @@ function validatePort(port) {
2859
3318
  * Only enforces critical requirements - allows maximum developer flexibility
2860
3319
  */
2861
3320
  function sanitizeSandboxId(id) {
2862
- if (!id || id.length > 63) throw new SecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
2863
- if (id.startsWith("-") || id.endsWith("-")) throw new SecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
3321
+ if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
3322
+ if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
2864
3323
  const reservedNames = [
2865
3324
  "www",
2866
3325
  "api",
@@ -2871,7 +3330,7 @@ function sanitizeSandboxId(id) {
2871
3330
  "workers"
2872
3331
  ];
2873
3332
  const lowerCaseId = id.toLowerCase();
2874
- if (reservedNames.includes(lowerCaseId)) throw new SecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
3333
+ if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
2875
3334
  return id;
2876
3335
  }
2877
3336
  /**
@@ -2890,23 +3349,23 @@ function validateLanguage(language) {
2890
3349
  "ts"
2891
3350
  ];
2892
3351
  const normalized = language.toLowerCase();
2893
- if (!supportedLanguages.includes(normalized)) throw new SecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
3352
+ if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
2894
3353
  }
2895
3354
 
2896
3355
  //#endregion
2897
3356
  //#region src/interpreter.ts
2898
3357
  var CodeInterpreter = class {
2899
- interpreterClient;
3358
+ getInterpreterClient;
2900
3359
  contexts = /* @__PURE__ */ new Map();
2901
- constructor(sandbox) {
2902
- this.interpreterClient = sandbox.client.interpreter;
3360
+ constructor(interpreterClient) {
3361
+ this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
2903
3362
  }
2904
3363
  /**
2905
3364
  * Create a new code execution context
2906
3365
  */
2907
3366
  async createCodeContext(options = {}) {
2908
3367
  validateLanguage(options.language);
2909
- const context = await this.interpreterClient.createCodeContext(options);
3368
+ const context = await this.getInterpreterClient().createCodeContext(options);
2910
3369
  this.contexts.set(context.id, context);
2911
3370
  return context;
2912
3371
  }
@@ -2920,7 +3379,7 @@ var CodeInterpreter = class {
2920
3379
  context = await this.getOrCreateDefaultContext(language);
2921
3380
  }
2922
3381
  const execution = new Execution(code, context);
2923
- await this.interpreterClient.runCodeStream(context.id, code, options.language, {
3382
+ await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
2924
3383
  onStdout: (output) => {
2925
3384
  execution.logs.stdout.push(output.text);
2926
3385
  if (options.onStdout) return options.onStdout(output);
@@ -2949,13 +3408,13 @@ var CodeInterpreter = class {
2949
3408
  const language = options.language || "python";
2950
3409
  context = await this.getOrCreateDefaultContext(language);
2951
3410
  }
2952
- return this.interpreterClient.streamCode(context.id, code, options.language);
3411
+ return this.getInterpreterClient().streamCode(context.id, code, options.language);
2953
3412
  }
2954
3413
  /**
2955
3414
  * List all code contexts
2956
3415
  */
2957
3416
  async listCodeContexts() {
2958
- const contexts = await this.interpreterClient.listCodeContexts();
3417
+ const contexts = await this.getInterpreterClient().listCodeContexts();
2959
3418
  for (const context of contexts) this.contexts.set(context.id, context);
2960
3419
  return contexts;
2961
3420
  }
@@ -2963,7 +3422,7 @@ var CodeInterpreter = class {
2963
3422
  * Delete a code context
2964
3423
  */
2965
3424
  async deleteCodeContext(contextId) {
2966
- await this.interpreterClient.deleteCodeContext(contextId);
3425
+ await this.getInterpreterClient().deleteCodeContext(contextId);
2967
3426
  this.contexts.delete(contextId);
2968
3427
  }
2969
3428
  async getOrCreateDefaultContext(language) {
@@ -3652,7 +4111,7 @@ function isLocalhostPattern(hostname) {
3652
4111
  * This file is auto-updated by .github/changeset-version.ts during releases
3653
4112
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
3654
4113
  */
3655
- const SDK_VERSION = "0.9.0";
4114
+ const SDK_VERSION = "0.9.1";
3656
4115
 
3657
4116
  //#endregion
3658
4117
  //#region src/sandbox.ts
@@ -3760,7 +4219,7 @@ function enhanceSession(stub, rpcSession) {
3760
4219
  }
3761
4220
  function connect(stub) {
3762
4221
  return async (request, port) => {
3763
- if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
4222
+ if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
3764
4223
  const portSwitchedRequest = switchPort(request, port);
3765
4224
  return await stub.fetch(portSwitchedRequest);
3766
4225
  };
@@ -3913,6 +4372,30 @@ var Sandbox = class Sandbox extends Container {
3913
4372
  }
3914
4373
  });
3915
4374
  }
4375
+ /**
4376
+ * Create the appropriate client for a given transport protocol.
4377
+ */
4378
+ createClientForTransport(transport) {
4379
+ if (transport === "rpc") {
4380
+ const self = this;
4381
+ return new RPCSandboxClient({
4382
+ stub: this,
4383
+ port: 3e3,
4384
+ logger: this.logger,
4385
+ onActivity: () => {
4386
+ this.renewActivityTimeout();
4387
+ },
4388
+ onSessionBusy: () => {
4389
+ self.inflightRequests++;
4390
+ },
4391
+ onSessionIdle: () => {
4392
+ self.inflightRequests = Math.max(0, self.inflightRequests - 1);
4393
+ if (self.inflightRequests === 0) this.renewActivityTimeout();
4394
+ }
4395
+ });
4396
+ }
4397
+ return this.createSandboxClient();
4398
+ }
3916
4399
  constructor(ctx, env) {
3917
4400
  super(ctx, env);
3918
4401
  const envObj = env;
@@ -3925,8 +4408,9 @@ var Sandbox = class Sandbox extends Container {
3925
4408
  sandboxId: this.ctx.id.toString()
3926
4409
  });
3927
4410
  const transportEnv = envObj?.SANDBOX_TRANSPORT;
3928
- if (transportEnv === "websocket") this.transport = "websocket";
3929
- else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http" or "websocket". Defaulting to "http".`);
4411
+ if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
4412
+ else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
4413
+ this.logger.info(`Using ${this.transport} transport`);
3930
4414
  const backupBucket = envObj?.BACKUP_BUCKET;
3931
4415
  if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
3932
4416
  this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
@@ -3937,8 +4421,8 @@ var Sandbox = class Sandbox extends Container {
3937
4421
  accessKeyId: this.r2AccessKeyId,
3938
4422
  secretAccessKey: this.r2SecretAccessKey
3939
4423
  });
3940
- this.client = this.createSandboxClient();
3941
- this.codeInterpreter = new CodeInterpreter(this);
4424
+ this.client = this.createClientForTransport(this.transport);
4425
+ this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
3942
4426
  this.ctx.blockConcurrencyWhile(async () => {
3943
4427
  this.sandboxName = await this.ctx.storage.get("sandboxName") ?? null;
3944
4428
  this.normalizeId = await this.ctx.storage.get("normalizeId") ?? false;
@@ -3962,8 +4446,8 @@ var Sandbox = class Sandbox extends Container {
3962
4446
  if (storedTransport && storedTransport !== this.transport) {
3963
4447
  this.transport = storedTransport;
3964
4448
  const previousClient = this.client;
3965
- this.client = this.createSandboxClient();
3966
- this.codeInterpreter = new CodeInterpreter(this);
4449
+ this.client = this.createClientForTransport(storedTransport);
4450
+ this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
3967
4451
  previousClient.disconnect();
3968
4452
  }
3969
4453
  if (storedTransport) this.hasStoredTransport = true;
@@ -4044,8 +4528,8 @@ var Sandbox = class Sandbox extends Container {
4044
4528
  * Storage is written before the in-memory state and client are updated.
4045
4529
  */
4046
4530
  async setTransport(transport) {
4047
- if (transport !== "http" && transport !== "websocket") {
4048
- this.logger.warn(`Invalid transport value: "${transport}". Must be "http" or "websocket". Ignoring.`);
4531
+ if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
4532
+ this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
4049
4533
  return;
4050
4534
  }
4051
4535
  if (this.hasStoredTransport && this.transport === transport) return;
@@ -4053,9 +4537,10 @@ var Sandbox = class Sandbox extends Container {
4053
4537
  const previousClient = this.client;
4054
4538
  this.transport = transport;
4055
4539
  this.hasStoredTransport = true;
4056
- this.client = this.createSandboxClient();
4057
- this.codeInterpreter = new CodeInterpreter(this);
4540
+ this.client = this.createClientForTransport(transport);
4541
+ this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
4058
4542
  previousClient.disconnect();
4543
+ this.renewActivityTimeout();
4059
4544
  this.logger.debug("Transport updated", { transport });
4060
4545
  }
4061
4546
  /**
@@ -4344,9 +4829,46 @@ var Sandbox = class Sandbox extends Container {
4344
4829
  if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
4345
4830
  }
4346
4831
  /**
4347
- * Cleanup and destroy the sandbox container
4832
+ * In-flight `destroy()` promise. While set, concurrent callers coalesce
4833
+ * onto the same teardown instead of triggering a second one. Cleared when
4834
+ * the underlying work settles, so a later call that genuinely needs to
4835
+ * recreate a destroyed sandbox still runs.
4836
+ *
4837
+ * If the underlying teardown hangs (e.g. `super.destroy()` never resolves
4838
+ * because the Containers control plane is unresponsive), every coalesced
4839
+ * caller hangs on the same promise until the Durable Object is evicted.
4840
+ * This is deliberate: a second concurrent teardown would not make a stuck
4841
+ * control plane unstuck, and spawning one would defeat the point of
4842
+ * coalescing. Callers that need bounded waits must apply their own
4843
+ * timeout around `destroy()`.
4844
+ */
4845
+ inflightDestroy = null;
4846
+ /**
4847
+ * Cleanup and destroy the sandbox container.
4848
+ *
4849
+ * Concurrent calls coalesce: if a previous `destroy()` is still in flight,
4850
+ * subsequent calls await the same underlying work instead of starting a
4851
+ * second teardown. A canonical `sandbox.destroy.coalesced` event is logged
4852
+ * per coalesced call so repeated destroy traffic is observable.
4348
4853
  */
4349
4854
  async destroy() {
4855
+ if (this.inflightDestroy) {
4856
+ logCanonicalEvent(this.logger, {
4857
+ event: "sandbox.destroy.coalesced",
4858
+ outcome: "success",
4859
+ durationMs: 0
4860
+ });
4861
+ return this.inflightDestroy;
4862
+ }
4863
+ const work = this.doDestroy();
4864
+ this.inflightDestroy = work;
4865
+ try {
4866
+ await work;
4867
+ } finally {
4868
+ if (this.inflightDestroy === work) this.inflightDestroy = null;
4869
+ }
4870
+ }
4871
+ async doDestroy() {
4350
4872
  const startTime = Date.now();
4351
4873
  let mountsProcessed = 0;
4352
4874
  let mountFailures = 0;
@@ -4356,7 +4878,6 @@ var Sandbox = class Sandbox extends Container {
4356
4878
  if (this.ctx.container?.running) try {
4357
4879
  await this.client.desktop.stop();
4358
4880
  } catch {}
4359
- this.client.disconnect();
4360
4881
  for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
4361
4882
  mountsProcessed++;
4362
4883
  if (mountInfo.mountType === "local-sync") try {
@@ -4381,6 +4902,7 @@ var Sandbox = class Sandbox extends Container {
4381
4902
  }
4382
4903
  }
4383
4904
  await this.ctx.storage.delete("portTokens");
4905
+ this.client.disconnect();
4384
4906
  outcome = "success";
4385
4907
  await super.destroy();
4386
4908
  } catch (error) {
@@ -4508,6 +5030,7 @@ var Sandbox = class Sandbox extends Container {
4508
5030
  this.containerGeneration++;
4509
5031
  this.defaultSession = null;
4510
5032
  this.defaultSessionInit = null;
5033
+ this.client.disconnect();
4511
5034
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
4512
5035
  this.activeMounts.clear();
4513
5036
  await this.ctx.storage.delete("defaultSession");
@@ -4786,22 +5309,43 @@ var Sandbox = class Sandbox extends Container {
4786
5309
  }
4787
5310
  }
4788
5311
  async initializeDefaultSession(sessionId, generation) {
5312
+ let placementId;
4789
5313
  try {
4790
- await this.client.utils.createSession({
5314
+ placementId = (await this.client.utils.createSession({
4791
5315
  id: sessionId,
4792
5316
  env: this.envVars || {},
4793
5317
  cwd: "/workspace"
4794
- });
5318
+ })).containerPlacementId;
4795
5319
  } catch (error) {
4796
5320
  if (!(error instanceof SessionAlreadyExistsError)) throw error;
5321
+ placementId = error.containerPlacementId;
4797
5322
  this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
4798
5323
  }
4799
5324
  if (generation !== this.containerGeneration) throw new Error("Default session initialization was invalidated by a container stop");
4800
5325
  await this.ctx.storage.put("defaultSession", sessionId);
5326
+ await this.capturePlacementId(placementId);
4801
5327
  this.defaultSession = sessionId;
4802
5328
  this.logger.debug("Default session initialized", { sessionId });
4803
5329
  return sessionId;
4804
5330
  }
5331
+ /**
5332
+ * Persist the container's placement ID in DO storage.
5333
+ *
5334
+ * Called from the session-create handshake so subsequent reads via
5335
+ * `getContainerPlacementId()` do not require a round-trip to the container. The value
5336
+ * is overwritten on every handshake so that container replacements (which
5337
+ * assign a new placement ID) are reflected on the next session-create.
5338
+ *
5339
+ * A value of `undefined` means the handshake response omitted the field
5340
+ * (older container, unexpected error shape) and the stored value is left
5341
+ * untouched. `null` means the env var is not set in the container and is
5342
+ * stored as-is so callers can distinguish "observed and absent" from "not
5343
+ * yet observed."
5344
+ */
5345
+ async capturePlacementId(containerPlacementId) {
5346
+ if (containerPlacementId === void 0) return;
5347
+ await this.ctx.storage.put("containerPlacementId", containerPlacementId);
5348
+ }
4805
5349
  async exec(command, options) {
4806
5350
  const session = await this.ensureDefaultSession();
4807
5351
  return this.execWithSession(command, session, options);
@@ -5306,6 +5850,7 @@ var Sandbox = class Sandbox extends Container {
5306
5850
  }
5307
5851
  async writeFile(path$1, content, options = {}) {
5308
5852
  const session = options.sessionId ?? await this.ensureDefaultSession();
5853
+ if (content instanceof ReadableStream) return this.client.writeFileStream(path$1, content, session);
5309
5854
  return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
5310
5855
  }
5311
5856
  async deleteFile(path$1, sessionId) {
@@ -5455,7 +6000,7 @@ var Sandbox = class Sandbox extends Container {
5455
6000
  let outcome = "error";
5456
6001
  let caughtError;
5457
6002
  try {
5458
- if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
6003
+ if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
5459
6004
  if (options.hostname.endsWith(".workers.dev")) throw new CustomDomainRequiredError({
5460
6005
  code: ErrorCode.CUSTOM_DOMAIN_REQUIRED,
5461
6006
  message: `Port exposure requires a custom domain. .workers.dev domains do not support wildcard subdomains required for port proxying.`,
@@ -5471,7 +6016,7 @@ var Sandbox = class Sandbox extends Container {
5471
6016
  } else token = this.generatePortToken();
5472
6017
  const tokens = await this.readPortTokens();
5473
6018
  const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
5474
- if (existingPort) throw new SecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
6019
+ if (existingPort) throw new SandboxSecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
5475
6020
  const sessionId = await this.ensureDefaultSession();
5476
6021
  await this.client.ports.exposePort(port, sessionId, options?.name);
5477
6022
  tokens[port.toString()] = {
@@ -5506,7 +6051,7 @@ var Sandbox = class Sandbox extends Container {
5506
6051
  let outcome = "error";
5507
6052
  let caughtError;
5508
6053
  try {
5509
- if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
6054
+ if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
5510
6055
  const tokens = await this.readPortTokens();
5511
6056
  if (tokens[port.toString()]) {
5512
6057
  delete tokens[port.toString()];
@@ -5572,9 +6117,9 @@ var Sandbox = class Sandbox extends Container {
5572
6117
  }
5573
6118
  }
5574
6119
  validateCustomToken(token) {
5575
- if (token.length === 0) throw new SecurityError(`Custom token cannot be empty.`);
5576
- if (token.length > 16) throw new SecurityError(`Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.`);
5577
- if (!/^[a-z0-9_]+$/.test(token)) throw new SecurityError(`Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.`);
6120
+ if (token.length === 0) throw new SandboxSecurityError(`Custom token cannot be empty.`);
6121
+ if (token.length > 16) throw new SandboxSecurityError(`Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.`);
6122
+ if (!/^[a-z0-9_]+$/.test(token)) throw new SandboxSecurityError(`Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.`);
5578
6123
  }
5579
6124
  generatePortToken() {
5580
6125
  const array = new Uint8Array(12);
@@ -5582,10 +6127,10 @@ var Sandbox = class Sandbox extends Container {
5582
6127
  return btoa(String.fromCharCode(...array)).replace(/\+/g, "_").replace(/\//g, "_").replace(/=/g, "").toLowerCase();
5583
6128
  }
5584
6129
  constructPreviewUrl(port, sandboxId, hostname, token) {
5585
- if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
6130
+ if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
5586
6131
  const effectiveId = this.sandboxName || sandboxId;
5587
6132
  const hasUppercase = /[A-Z]/.test(effectiveId);
5588
- if (!this.normalizeId && hasUppercase) throw new SecurityError(`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\nTo fix this:\n1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\nNote: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`);
6133
+ if (!this.normalizeId && hasUppercase) throw new SandboxSecurityError(`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\nTo fix this:\n1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\nNote: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`);
5589
6134
  const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
5590
6135
  if (isLocalhostPattern(hostname)) {
5591
6136
  const [host, portStr] = hostname.split(":");
@@ -5595,7 +6140,7 @@ var Sandbox = class Sandbox extends Container {
5595
6140
  baseUrl.hostname = `${port}-${sanitizedSandboxId}-${token}.${host}`;
5596
6141
  return baseUrl.toString();
5597
6142
  } catch (error) {
5598
- throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
6143
+ throw new SandboxSecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
5599
6144
  }
5600
6145
  }
5601
6146
  try {
@@ -5603,7 +6148,7 @@ var Sandbox = class Sandbox extends Container {
5603
6148
  baseUrl.hostname = `${port}-${sanitizedSandboxId}-${token}.${hostname}`;
5604
6149
  return baseUrl.toString();
5605
6150
  } catch (error) {
5606
- throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
6151
+ throw new SandboxSecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
5607
6152
  }
5608
6153
  }
5609
6154
  /**
@@ -5617,12 +6162,13 @@ var Sandbox = class Sandbox extends Container {
5617
6162
  ...options?.env ?? {}
5618
6163
  });
5619
6164
  const envPayload = Object.keys(filteredEnv).length > 0 ? filteredEnv : void 0;
5620
- await this.client.utils.createSession({
6165
+ const response = await this.client.utils.createSession({
5621
6166
  id: sessionId,
5622
6167
  ...envPayload && { env: envPayload },
5623
6168
  ...options?.cwd && { cwd: options.cwd },
5624
6169
  ...options?.commandTimeoutMs !== void 0 && { commandTimeoutMs: options.commandTimeoutMs }
5625
6170
  });
6171
+ await this.capturePlacementId(response.containerPlacementId);
5626
6172
  return this.getSessionWrapper(sessionId);
5627
6173
  }
5628
6174
  /**
@@ -5657,6 +6203,26 @@ var Sandbox = class Sandbox extends Container {
5657
6203
  timestamp: response.timestamp
5658
6204
  };
5659
6205
  }
6206
+ /**
6207
+ * Get the Cloudflare placement ID observed for the underlying container.
6208
+ *
6209
+ * The placement ID is captured during the first session-create handshake
6210
+ * after a container start and stored in Durable Object storage, so this
6211
+ * method returns the cached value without contacting the container. A new
6212
+ * placement ID is captured on each subsequent session-create handshake,
6213
+ * which occurs whenever the container has been replaced.
6214
+ *
6215
+ * Returns `null` when a handshake has completed but the container's
6216
+ * `CLOUDFLARE_PLACEMENT_ID` environment variable is not set (for example,
6217
+ * in local development).
6218
+ *
6219
+ * Returns `undefined` when no handshake has been observed yet on this
6220
+ * sandbox. Call any method that triggers session creation (such as
6221
+ * `exec()`) to populate the value.
6222
+ */
6223
+ async getContainerPlacementId() {
6224
+ return this.ctx.storage.get("containerPlacementId");
6225
+ }
5660
6226
  getSessionWrapper(sessionId) {
5661
6227
  return {
5662
6228
  id: sessionId,
@@ -6029,7 +6595,10 @@ var Sandbox = class Sandbox extends Container {
6029
6595
  backupSession = await this.ensureBackupSession();
6030
6596
  backupId = crypto.randomUUID();
6031
6597
  const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
6032
- const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, gitignore, excludes);
6598
+ const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
6599
+ gitignore,
6600
+ excludes
6601
+ });
6033
6602
  if (!createResult.success) throw new BackupCreateError({
6034
6603
  message: "Container failed to create backup archive",
6035
6604
  code: ErrorCode.BACKUP_CREATE_FAILED,
@@ -6147,7 +6716,10 @@ var Sandbox = class Sandbox extends Container {
6147
6716
  backupSession = await this.ensureBackupSession();
6148
6717
  backupId = crypto.randomUUID();
6149
6718
  const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
6150
- const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, gitignore, excludes);
6719
+ const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
6720
+ gitignore,
6721
+ excludes
6722
+ });
6151
6723
  if (!createResult.success) throw new BackupCreateError({
6152
6724
  message: "Container failed to create backup archive",
6153
6725
  code: ErrorCode.BACKUP_CREATE_FAILED,
@@ -6499,4 +7071,4 @@ var Sandbox = class Sandbox extends Container {
6499
7071
 
6500
7072
  //#endregion
6501
7073
  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, SessionTerminatedError 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 };
6502
- //# sourceMappingURL=sandbox-Cf_Wjrzq.js.map
7074
+ //# sourceMappingURL=sandbox-PAYx1CcU.js.map