@cloudflare/sandbox 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as shellEscape, c as TraceContext, d as GitLogger, f as getEnvString, i as isTerminalStatus, l as Execution, n as isProcess, o as createLogger, r as isProcessStatus, s as createNoOpLogger, t as isExecResult, u as ResultImpl } from "./dist-2SF6oOaz.js";
1
+ import { a as isExecResult, c as shellEscape, d as TraceContext, f as Execution, h as getEnvString, i as isWSStreamChunk, l as createLogger, m as GitLogger, n as isWSError, o as isProcess, p as ResultImpl, r as isWSResponse, s as isProcessStatus, t as generateRequestId, u as createNoOpLogger } from "./dist-Dgwt0coR.js";
2
2
  import { t as ErrorCode } from "./errors-BCXUmJUn.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
4
 
@@ -569,31 +569,37 @@ function createErrorFromResponse(errorResponse) {
569
569
  }
570
570
 
571
571
  //#endregion
572
- //#region src/clients/base-client.ts
572
+ //#region src/clients/transport/base-transport.ts
573
+ /**
574
+ * Container startup retry configuration
575
+ */
573
576
  const TIMEOUT_MS = 12e4;
574
577
  const MIN_TIME_FOR_RETRY_MS = 15e3;
575
578
  /**
576
- * Abstract base class providing common HTTP functionality for all domain clients
579
+ * Abstract base transport with shared retry logic
580
+ *
581
+ * Handles 503 retry for container startup - shared by all transports.
582
+ * Subclasses implement the transport-specific fetch and stream logic.
577
583
  */
578
- var BaseHttpClient = class {
579
- baseUrl;
580
- options;
584
+ var BaseTransport = class {
585
+ config;
581
586
  logger;
582
- constructor(options = {}) {
583
- this.options = options;
584
- this.logger = options.logger ?? createNoOpLogger();
585
- this.baseUrl = this.options.baseUrl;
587
+ constructor(config) {
588
+ this.config = config;
589
+ this.logger = config.logger ?? createNoOpLogger();
586
590
  }
587
591
  /**
588
- * Core HTTP request method with automatic retry for container startup delays
589
- * Retries both 503 (provisioning) and 500 (startup failure) errors when they're container-related
592
+ * Fetch with automatic retry for 503 (container starting)
593
+ *
594
+ * This is the primary entry point for making requests. It wraps the
595
+ * transport-specific doFetch() with retry logic for container startup.
590
596
  */
591
- async doFetch(path, options) {
597
+ async fetch(path, options) {
592
598
  const startTime = Date.now();
593
599
  let attempt = 0;
594
600
  while (true) {
595
- const response = await this.executeFetch(path, options);
596
- if (await this.isRetryableContainerError(response)) {
601
+ const response = await this.doFetch(path, options);
602
+ if (response.status === 503) {
597
603
  const elapsed = Date.now() - startTime;
598
604
  const remaining = TIMEOUT_MS - elapsed;
599
605
  if (remaining > MIN_TIME_FOR_RETRY_MS) {
@@ -602,19 +608,537 @@ var BaseHttpClient = class {
602
608
  status: response.status,
603
609
  attempt: attempt + 1,
604
610
  delayMs: delay,
605
- remainingSec: Math.floor(remaining / 1e3)
611
+ remainingSec: Math.floor(remaining / 1e3),
612
+ mode: this.getMode()
606
613
  });
607
- await new Promise((resolve) => setTimeout(resolve, delay));
614
+ await this.sleep(delay);
608
615
  attempt++;
609
616
  continue;
610
617
  }
611
618
  this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1e3)}s`));
612
- return response;
613
619
  }
614
620
  return response;
615
621
  }
616
622
  }
617
623
  /**
624
+ * Sleep utility for retry delays
625
+ */
626
+ sleep(ms) {
627
+ return new Promise((resolve) => setTimeout(resolve, ms));
628
+ }
629
+ };
630
+
631
+ //#endregion
632
+ //#region src/clients/transport/http-transport.ts
633
+ /**
634
+ * HTTP transport implementation
635
+ *
636
+ * Uses standard fetch API for communication with the container.
637
+ * HTTP is stateless, so connect/disconnect are no-ops.
638
+ */
639
+ var HttpTransport = class extends BaseTransport {
640
+ baseUrl;
641
+ constructor(config) {
642
+ super(config);
643
+ this.baseUrl = config.baseUrl ?? "http://localhost:3000";
644
+ }
645
+ getMode() {
646
+ return "http";
647
+ }
648
+ async connect() {}
649
+ disconnect() {}
650
+ isConnected() {
651
+ return true;
652
+ }
653
+ async doFetch(path, options) {
654
+ const url = this.buildUrl(path);
655
+ if (this.config.stub) return this.config.stub.containerFetch(url, options || {}, this.config.port);
656
+ return globalThis.fetch(url, options);
657
+ }
658
+ async fetchStream(path, body, method = "POST") {
659
+ const url = this.buildUrl(path);
660
+ const options = this.buildStreamOptions(body, method);
661
+ let response;
662
+ if (this.config.stub) response = await this.config.stub.containerFetch(url, options, this.config.port);
663
+ else response = await globalThis.fetch(url, options);
664
+ if (!response.ok) {
665
+ const errorBody = await response.text();
666
+ throw new Error(`HTTP error! status: ${response.status} - ${errorBody}`);
667
+ }
668
+ if (!response.body) throw new Error("No response body for streaming");
669
+ return response.body;
670
+ }
671
+ buildUrl(path) {
672
+ if (this.config.stub) return `http://localhost:${this.config.port}${path}`;
673
+ return `${this.baseUrl}${path}`;
674
+ }
675
+ buildStreamOptions(body, method) {
676
+ return {
677
+ method,
678
+ headers: body && method === "POST" ? { "Content-Type": "application/json" } : void 0,
679
+ body: body && method === "POST" ? JSON.stringify(body) : void 0
680
+ };
681
+ }
682
+ };
683
+
684
+ //#endregion
685
+ //#region src/clients/transport/ws-transport.ts
686
+ /**
687
+ * WebSocket transport implementation
688
+ *
689
+ * Multiplexes HTTP-like requests over a single WebSocket connection.
690
+ * Useful when running inside Workers/DO where sub-request limits apply.
691
+ */
692
+ var WebSocketTransport = class extends BaseTransport {
693
+ ws = null;
694
+ state = "disconnected";
695
+ pendingRequests = /* @__PURE__ */ new Map();
696
+ connectPromise = null;
697
+ boundHandleMessage;
698
+ boundHandleClose;
699
+ constructor(config) {
700
+ super(config);
701
+ if (!config.wsUrl) throw new Error("wsUrl is required for WebSocket transport");
702
+ this.boundHandleMessage = this.handleMessage.bind(this);
703
+ this.boundHandleClose = this.handleClose.bind(this);
704
+ }
705
+ getMode() {
706
+ return "websocket";
707
+ }
708
+ /**
709
+ * Check if WebSocket is connected
710
+ */
711
+ isConnected() {
712
+ return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
713
+ }
714
+ /**
715
+ * Connect to the WebSocket server
716
+ *
717
+ * The connection promise is assigned synchronously so concurrent
718
+ * callers share the same connection attempt.
719
+ */
720
+ async connect() {
721
+ if (this.isConnected()) return;
722
+ if (this.connectPromise) return this.connectPromise;
723
+ this.connectPromise = this.doConnect();
724
+ try {
725
+ await this.connectPromise;
726
+ } catch (error) {
727
+ this.connectPromise = null;
728
+ throw error;
729
+ }
730
+ }
731
+ /**
732
+ * Disconnect from the WebSocket server
733
+ */
734
+ disconnect() {
735
+ this.cleanup();
736
+ }
737
+ /**
738
+ * Transport-specific fetch implementation
739
+ * Converts WebSocket response to standard Response object.
740
+ */
741
+ async doFetch(path, options) {
742
+ await this.connect();
743
+ const method = options?.method || "GET";
744
+ const body = this.parseBody(options?.body);
745
+ const result = await this.request(method, path, body);
746
+ return new Response(JSON.stringify(result.body), {
747
+ status: result.status,
748
+ headers: { "Content-Type": "application/json" }
749
+ });
750
+ }
751
+ /**
752
+ * Streaming fetch implementation
753
+ */
754
+ async fetchStream(path, body, method = "POST") {
755
+ return this.requestStream(method, path, body);
756
+ }
757
+ /**
758
+ * Parse request body from RequestInit
759
+ */
760
+ parseBody(body) {
761
+ if (!body) return;
762
+ if (typeof body === "string") try {
763
+ return JSON.parse(body);
764
+ } catch (error) {
765
+ throw new Error(`Request body must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
766
+ }
767
+ throw new Error(`WebSocket transport only supports string bodies. Got: ${typeof body}`);
768
+ }
769
+ /**
770
+ * Internal connection logic
771
+ */
772
+ async doConnect() {
773
+ this.state = "connecting";
774
+ if (this.config.stub) await this.connectViaFetch();
775
+ else await this.connectViaWebSocket();
776
+ }
777
+ /**
778
+ * Connect using fetch-based WebSocket (Cloudflare Workers style)
779
+ * This is required when running inside a Durable Object.
780
+ *
781
+ * Uses stub.fetch() which routes WebSocket upgrade requests through the
782
+ * parent Container class that supports the WebSocket protocol.
783
+ */
784
+ async connectViaFetch() {
785
+ const timeoutMs = this.config.connectTimeoutMs ?? 3e4;
786
+ const controller = new AbortController();
787
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
788
+ try {
789
+ const wsPath = new URL(this.config.wsUrl).pathname;
790
+ const httpUrl = `http://localhost:${this.config.port || 3e3}${wsPath}`;
791
+ const request = new Request(httpUrl, {
792
+ headers: {
793
+ Upgrade: "websocket",
794
+ Connection: "Upgrade"
795
+ },
796
+ signal: controller.signal
797
+ });
798
+ const response = await this.config.stub.fetch(request);
799
+ clearTimeout(timeout);
800
+ if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
801
+ const ws = response.webSocket;
802
+ if (!ws) throw new Error("No WebSocket in upgrade response");
803
+ ws.accept();
804
+ this.ws = ws;
805
+ this.state = "connected";
806
+ this.ws.addEventListener("close", this.boundHandleClose);
807
+ this.ws.addEventListener("message", this.boundHandleMessage);
808
+ this.logger.debug("WebSocket connected via fetch", { url: this.config.wsUrl });
809
+ } catch (error) {
810
+ clearTimeout(timeout);
811
+ this.state = "error";
812
+ this.logger.error("WebSocket fetch connection failed", error instanceof Error ? error : new Error(String(error)));
813
+ throw error;
814
+ }
815
+ }
816
+ /**
817
+ * Connect using standard WebSocket API (browser/Node style)
818
+ */
819
+ connectViaWebSocket() {
820
+ return new Promise((resolve, reject) => {
821
+ const timeoutMs = this.config.connectTimeoutMs ?? 3e4;
822
+ const timeout = setTimeout(() => {
823
+ this.cleanup();
824
+ reject(/* @__PURE__ */ new Error(`WebSocket connection timeout after ${timeoutMs}ms`));
825
+ }, timeoutMs);
826
+ try {
827
+ this.ws = new WebSocket(this.config.wsUrl);
828
+ const onOpen = () => {
829
+ clearTimeout(timeout);
830
+ this.ws?.removeEventListener("open", onOpen);
831
+ this.ws?.removeEventListener("error", onConnectError);
832
+ this.state = "connected";
833
+ this.logger.debug("WebSocket connected", { url: this.config.wsUrl });
834
+ resolve();
835
+ };
836
+ const onConnectError = () => {
837
+ clearTimeout(timeout);
838
+ this.ws?.removeEventListener("open", onOpen);
839
+ this.ws?.removeEventListener("error", onConnectError);
840
+ this.state = "error";
841
+ this.logger.error("WebSocket error", /* @__PURE__ */ new Error("WebSocket connection failed"));
842
+ reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
843
+ };
844
+ this.ws.addEventListener("open", onOpen);
845
+ this.ws.addEventListener("error", onConnectError);
846
+ this.ws.addEventListener("close", this.boundHandleClose);
847
+ this.ws.addEventListener("message", this.boundHandleMessage);
848
+ } catch (error) {
849
+ clearTimeout(timeout);
850
+ this.state = "error";
851
+ reject(error);
852
+ }
853
+ });
854
+ }
855
+ /**
856
+ * Send a request and wait for response
857
+ */
858
+ async request(method, path, body) {
859
+ await this.connect();
860
+ const id = generateRequestId();
861
+ const request = {
862
+ type: "request",
863
+ id,
864
+ method,
865
+ path,
866
+ body
867
+ };
868
+ return new Promise((resolve, reject) => {
869
+ const timeoutMs = this.config.requestTimeoutMs ?? 12e4;
870
+ const timeoutId = setTimeout(() => {
871
+ this.pendingRequests.delete(id);
872
+ reject(/* @__PURE__ */ new Error(`Request timeout after ${timeoutMs}ms: ${method} ${path}`));
873
+ }, timeoutMs);
874
+ this.pendingRequests.set(id, {
875
+ resolve: (response) => {
876
+ clearTimeout(timeoutId);
877
+ this.pendingRequests.delete(id);
878
+ resolve({
879
+ status: response.status,
880
+ body: response.body
881
+ });
882
+ },
883
+ reject: (error) => {
884
+ clearTimeout(timeoutId);
885
+ this.pendingRequests.delete(id);
886
+ reject(error);
887
+ },
888
+ isStreaming: false,
889
+ timeoutId
890
+ });
891
+ try {
892
+ this.send(request);
893
+ } catch (error) {
894
+ clearTimeout(timeoutId);
895
+ this.pendingRequests.delete(id);
896
+ reject(error instanceof Error ? error : new Error(String(error)));
897
+ }
898
+ });
899
+ }
900
+ /**
901
+ * Send a streaming request and return a ReadableStream
902
+ *
903
+ * The stream will receive data chunks as they arrive over the WebSocket.
904
+ * Format matches SSE for compatibility with existing streaming code.
905
+ */
906
+ async requestStream(method, path, body) {
907
+ await this.connect();
908
+ const id = generateRequestId();
909
+ const request = {
910
+ type: "request",
911
+ id,
912
+ method,
913
+ path,
914
+ body
915
+ };
916
+ return new ReadableStream({
917
+ start: (controller) => {
918
+ const timeoutMs = this.config.requestTimeoutMs ?? 12e4;
919
+ const timeoutId = setTimeout(() => {
920
+ this.pendingRequests.delete(id);
921
+ controller.error(/* @__PURE__ */ new Error(`Stream timeout after ${timeoutMs}ms: ${method} ${path}`));
922
+ }, timeoutMs);
923
+ this.pendingRequests.set(id, {
924
+ resolve: (response) => {
925
+ clearTimeout(timeoutId);
926
+ this.pendingRequests.delete(id);
927
+ if (response.status >= 400) controller.error(/* @__PURE__ */ new Error(`Stream error: ${response.status} - ${JSON.stringify(response.body)}`));
928
+ else controller.close();
929
+ },
930
+ reject: (error) => {
931
+ clearTimeout(timeoutId);
932
+ this.pendingRequests.delete(id);
933
+ controller.error(error);
934
+ },
935
+ streamController: controller,
936
+ isStreaming: true,
937
+ timeoutId
938
+ });
939
+ try {
940
+ this.send(request);
941
+ } catch (error) {
942
+ clearTimeout(timeoutId);
943
+ this.pendingRequests.delete(id);
944
+ controller.error(error instanceof Error ? error : new Error(String(error)));
945
+ }
946
+ },
947
+ cancel: () => {
948
+ const pending = this.pendingRequests.get(id);
949
+ if (pending?.timeoutId) clearTimeout(pending.timeoutId);
950
+ this.pendingRequests.delete(id);
951
+ }
952
+ });
953
+ }
954
+ /**
955
+ * Send a message over the WebSocket
956
+ */
957
+ send(message) {
958
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error("WebSocket not connected");
959
+ this.ws.send(JSON.stringify(message));
960
+ this.logger.debug("WebSocket sent", {
961
+ id: message.id,
962
+ method: message.method,
963
+ path: message.path
964
+ });
965
+ }
966
+ /**
967
+ * Handle incoming WebSocket messages
968
+ */
969
+ handleMessage(event) {
970
+ try {
971
+ const message = JSON.parse(event.data);
972
+ if (isWSResponse(message)) this.handleResponse(message);
973
+ else if (isWSStreamChunk(message)) this.handleStreamChunk(message);
974
+ else if (isWSError(message)) this.handleError(message);
975
+ else this.logger.warn("Unknown WebSocket message type", { message });
976
+ } catch (error) {
977
+ this.logger.error("Failed to parse WebSocket message", error instanceof Error ? error : new Error(String(error)));
978
+ }
979
+ }
980
+ /**
981
+ * Handle a response message
982
+ */
983
+ handleResponse(response) {
984
+ const pending = this.pendingRequests.get(response.id);
985
+ if (!pending) {
986
+ this.logger.warn("Received response for unknown request", { id: response.id });
987
+ return;
988
+ }
989
+ this.logger.debug("WebSocket response", {
990
+ id: response.id,
991
+ status: response.status,
992
+ done: response.done
993
+ });
994
+ if (response.done) pending.resolve(response);
995
+ }
996
+ /**
997
+ * Handle a stream chunk message
998
+ */
999
+ handleStreamChunk(chunk) {
1000
+ const pending = this.pendingRequests.get(chunk.id);
1001
+ if (!pending || !pending.streamController) {
1002
+ this.logger.warn("Received stream chunk for unknown request", { id: chunk.id });
1003
+ return;
1004
+ }
1005
+ const encoder = new TextEncoder();
1006
+ let sseData;
1007
+ if (chunk.event) sseData = `event: ${chunk.event}\ndata: ${chunk.data}\n\n`;
1008
+ else sseData = `data: ${chunk.data}\n\n`;
1009
+ try {
1010
+ pending.streamController.enqueue(encoder.encode(sseData));
1011
+ } catch (error) {
1012
+ this.logger.debug("Failed to enqueue stream chunk, cleaning up", {
1013
+ id: chunk.id,
1014
+ error: error instanceof Error ? error.message : String(error)
1015
+ });
1016
+ if (pending.timeoutId) clearTimeout(pending.timeoutId);
1017
+ this.pendingRequests.delete(chunk.id);
1018
+ }
1019
+ }
1020
+ /**
1021
+ * Handle an error message
1022
+ */
1023
+ handleError(error) {
1024
+ if (error.id) {
1025
+ const pending = this.pendingRequests.get(error.id);
1026
+ if (pending) {
1027
+ pending.reject(/* @__PURE__ */ new Error(`${error.code}: ${error.message}`));
1028
+ return;
1029
+ }
1030
+ }
1031
+ this.logger.error("WebSocket error message", new Error(error.message), {
1032
+ code: error.code,
1033
+ status: error.status
1034
+ });
1035
+ }
1036
+ /**
1037
+ * Handle WebSocket close
1038
+ */
1039
+ handleClose(event) {
1040
+ this.state = "disconnected";
1041
+ this.ws = null;
1042
+ const closeError = /* @__PURE__ */ new Error(`WebSocket closed: ${event.code} ${event.reason || "No reason"}`);
1043
+ for (const [, pending] of this.pendingRequests) {
1044
+ if (pending.timeoutId) clearTimeout(pending.timeoutId);
1045
+ if (pending.streamController) try {
1046
+ pending.streamController.error(closeError);
1047
+ } catch {}
1048
+ pending.reject(closeError);
1049
+ }
1050
+ this.pendingRequests.clear();
1051
+ }
1052
+ /**
1053
+ * Cleanup resources
1054
+ */
1055
+ cleanup() {
1056
+ if (this.ws) {
1057
+ this.ws.removeEventListener("close", this.boundHandleClose);
1058
+ this.ws.removeEventListener("message", this.boundHandleMessage);
1059
+ this.ws.close();
1060
+ this.ws = null;
1061
+ }
1062
+ this.state = "disconnected";
1063
+ this.connectPromise = null;
1064
+ for (const pending of this.pendingRequests.values()) if (pending.timeoutId) clearTimeout(pending.timeoutId);
1065
+ this.pendingRequests.clear();
1066
+ }
1067
+ };
1068
+
1069
+ //#endregion
1070
+ //#region src/clients/transport/factory.ts
1071
+ /**
1072
+ * Create a transport instance based on mode
1073
+ *
1074
+ * This is the primary API for creating transports. It handles
1075
+ * the selection of HTTP or WebSocket transport based on the mode.
1076
+ *
1077
+ * @example
1078
+ * ```typescript
1079
+ * // HTTP transport (default)
1080
+ * const http = createTransport({
1081
+ * mode: 'http',
1082
+ * baseUrl: 'http://localhost:3000'
1083
+ * });
1084
+ *
1085
+ * // WebSocket transport
1086
+ * const ws = createTransport({
1087
+ * mode: 'websocket',
1088
+ * wsUrl: 'ws://localhost:3000/ws'
1089
+ * });
1090
+ * ```
1091
+ */
1092
+ function createTransport(options) {
1093
+ switch (options.mode) {
1094
+ case "websocket": return new WebSocketTransport(options);
1095
+ default: return new HttpTransport(options);
1096
+ }
1097
+ }
1098
+
1099
+ //#endregion
1100
+ //#region src/clients/base-client.ts
1101
+ /**
1102
+ * Abstract base class providing common HTTP/WebSocket functionality for all domain clients
1103
+ *
1104
+ * All requests go through the Transport abstraction layer, which handles:
1105
+ * - HTTP and WebSocket modes transparently
1106
+ * - Automatic retry for 503 errors (container starting)
1107
+ * - Streaming responses
1108
+ *
1109
+ * WebSocket mode is useful when running inside Workers/Durable Objects
1110
+ * where sub-request limits apply.
1111
+ */
1112
+ var BaseHttpClient = class {
1113
+ options;
1114
+ logger;
1115
+ transport;
1116
+ constructor(options = {}) {
1117
+ this.options = options;
1118
+ this.logger = options.logger ?? createNoOpLogger();
1119
+ if (options.transport) this.transport = options.transport;
1120
+ else this.transport = createTransport({
1121
+ mode: options.transportMode ?? "http",
1122
+ baseUrl: options.baseUrl ?? "http://localhost:3000",
1123
+ wsUrl: options.wsUrl,
1124
+ logger: this.logger,
1125
+ stub: options.stub,
1126
+ port: options.port
1127
+ });
1128
+ }
1129
+ /**
1130
+ * Check if using WebSocket transport
1131
+ */
1132
+ isWebSocketMode() {
1133
+ return this.transport.getMode() === "websocket";
1134
+ }
1135
+ /**
1136
+ * Core fetch method - delegates to Transport which handles retry logic
1137
+ */
1138
+ async doFetch(path, options) {
1139
+ return this.transport.fetch(path, options);
1140
+ }
1141
+ /**
618
1142
  * Make a POST request with JSON body
619
1143
  */
620
1144
  async post(endpoint, data, responseHandler) {
@@ -686,6 +1210,30 @@ var BaseHttpClient = class {
686
1210
  return response.body;
687
1211
  }
688
1212
  /**
1213
+ * Stream request handler
1214
+ *
1215
+ * For HTTP mode, uses doFetch + handleStreamResponse to get proper error typing.
1216
+ * For WebSocket mode, uses Transport's streaming support.
1217
+ *
1218
+ * @param path - The API path to call
1219
+ * @param body - Optional request body (for POST requests)
1220
+ * @param method - HTTP method (default: POST, use GET for process logs)
1221
+ */
1222
+ async doStreamFetch(path, body, method = "POST") {
1223
+ if (this.transport.getMode() === "websocket") try {
1224
+ return await this.transport.fetchStream(path, body, method);
1225
+ } catch (error) {
1226
+ this.logError(`stream ${method} ${path}`, error);
1227
+ throw error;
1228
+ }
1229
+ const response = await this.doFetch(path, {
1230
+ method,
1231
+ headers: { "Content-Type": "application/json" },
1232
+ body: body && method === "POST" ? JSON.stringify(body) : void 0
1233
+ });
1234
+ return this.handleStreamResponse(response);
1235
+ }
1236
+ /**
689
1237
  * Utility method to log successful operations
690
1238
  */
691
1239
  logSuccess(operation, details) {
@@ -704,67 +1252,6 @@ var BaseHttpClient = class {
704
1252
  if (httpStatus >= 500) this.logger.error(`Unexpected error in ${operation}`, error instanceof Error ? error : new Error(String(error)), { httpStatus });
705
1253
  } else this.logger.error(`Error in ${operation}`, error instanceof Error ? error : new Error(String(error)));
706
1254
  }
707
- /**
708
- * Check if response indicates a retryable container error
709
- * Uses fail-safe strategy: only retry known transient errors
710
- *
711
- * TODO: This relies on string matching error messages, which is brittle.
712
- * Ideally, the container API should return structured errors with a
713
- * `retryable: boolean` field to avoid coupling to error message format.
714
- *
715
- * @param response - HTTP response to check
716
- * @returns true if error is retryable container error, false otherwise
717
- */
718
- async isRetryableContainerError(response) {
719
- if (response.status !== 500 && response.status !== 503) return false;
720
- try {
721
- const text = await response.clone().text();
722
- const textLower = text.toLowerCase();
723
- if ([
724
- "no such image",
725
- "container already exists",
726
- "malformed containerinspect"
727
- ].some((err) => textLower.includes(err))) {
728
- this.logger.debug("Detected permanent error, not retrying", { text });
729
- return false;
730
- }
731
- const shouldRetry = [
732
- "no container instance available",
733
- "currently provisioning",
734
- "container port not found",
735
- "connection refused: container port",
736
- "the container is not listening",
737
- "failed to verify port",
738
- "container did not start",
739
- "network connection lost",
740
- "container suddenly disconnected",
741
- "monitor failed to find container",
742
- "timed out",
743
- "timeout"
744
- ].some((err) => textLower.includes(err));
745
- if (!shouldRetry) this.logger.debug("Unknown error pattern, not retrying", {
746
- status: response.status,
747
- text: text.substring(0, 200)
748
- });
749
- return shouldRetry;
750
- } catch (error) {
751
- this.logger.error("Error checking if response is retryable", error instanceof Error ? error : new Error(String(error)));
752
- return false;
753
- }
754
- }
755
- async executeFetch(path, options) {
756
- const url = this.options.stub ? `http://localhost:${this.options.port}${path}` : `${this.baseUrl}${path}`;
757
- try {
758
- if (this.options.stub) return await this.options.stub.containerFetch(url, options || {}, this.options.port);
759
- else return await fetch(url, options);
760
- } catch (error) {
761
- this.logger.error("HTTP request error", error instanceof Error ? error : new Error(String(error)), {
762
- method: options?.method || "GET",
763
- url
764
- });
765
- throw error;
766
- }
767
- }
768
1255
  };
769
1256
 
770
1257
  //#endregion
@@ -815,12 +1302,7 @@ var CommandClient = class extends BaseHttpClient {
815
1302
  ...options?.env !== void 0 && { env: options.env },
816
1303
  ...options?.cwd !== void 0 && { cwd: options.cwd }
817
1304
  };
818
- const response = await this.doFetch("/api/execute/stream", {
819
- method: "POST",
820
- headers: { "Content-Type": "application/json" },
821
- body: JSON.stringify(data)
822
- });
823
- const stream = await this.handleStreamResponse(response);
1305
+ const stream = await this.doStreamFetch("/api/execute/stream", data);
824
1306
  this.logSuccess("Command stream started", command);
825
1307
  return stream;
826
1308
  } catch (error) {
@@ -914,12 +1396,7 @@ var FileClient = class extends BaseHttpClient {
914
1396
  path,
915
1397
  sessionId
916
1398
  };
917
- const response = await this.doFetch("/api/read/stream", {
918
- method: "POST",
919
- headers: { "Content-Type": "application/json" },
920
- body: JSON.stringify(data)
921
- });
922
- const stream = await this.handleStreamResponse(response);
1399
+ const stream = await this.doStreamFetch("/api/read/stream", data);
923
1400
  this.logSuccess("File stream started", path);
924
1401
  return stream;
925
1402
  } catch (error) {
@@ -1108,22 +1585,13 @@ var InterpreterClient = class extends BaseHttpClient {
1108
1585
  }
1109
1586
  async runCodeStream(contextId, code, language, callbacks, timeoutMs) {
1110
1587
  return this.executeWithRetry(async () => {
1111
- const response = await this.doFetch("/api/execute/code", {
1112
- method: "POST",
1113
- headers: {
1114
- "Content-Type": "application/json",
1115
- Accept: "text/event-stream"
1116
- },
1117
- body: JSON.stringify({
1118
- context_id: contextId,
1119
- code,
1120
- language,
1121
- ...timeoutMs !== void 0 && { timeout_ms: timeoutMs }
1122
- })
1588
+ const stream = await this.doStreamFetch("/api/execute/code", {
1589
+ context_id: contextId,
1590
+ code,
1591
+ language,
1592
+ ...timeoutMs !== void 0 && { timeout_ms: timeoutMs }
1123
1593
  });
1124
- if (!response.ok) throw await this.parseErrorResponse(response);
1125
- if (!response.body) throw new Error("No response body for streaming execution");
1126
- for await (const chunk of this.readLines(response.body)) await this.parseExecutionResult(chunk, callbacks);
1594
+ for await (const chunk of this.readLines(stream)) await this.parseExecutionResult(chunk, callbacks);
1127
1595
  });
1128
1596
  }
1129
1597
  async listCodeContexts() {
@@ -1154,6 +1622,17 @@ var InterpreterClient = class extends BaseHttpClient {
1154
1622
  });
1155
1623
  }
1156
1624
  /**
1625
+ * Get a raw stream for code execution.
1626
+ * Used by CodeInterpreter.runCodeStreaming() for direct stream access.
1627
+ */
1628
+ async streamCode(contextId, code, language) {
1629
+ return this.doStreamFetch("/api/execute/code", {
1630
+ context_id: contextId,
1631
+ code,
1632
+ language
1633
+ });
1634
+ }
1635
+ /**
1157
1636
  * Execute an operation with automatic retry for transient errors
1158
1637
  */
1159
1638
  async executeWithRetry(operation) {
@@ -1314,17 +1793,18 @@ var PortClient = class extends BaseHttpClient {
1314
1793
  }
1315
1794
  }
1316
1795
  /**
1317
- * Check if a port is ready to accept connections
1318
- * @param request - Port check configuration
1796
+ * Watch a port for readiness via SSE stream
1797
+ * @param request - Port watch configuration
1798
+ * @returns SSE stream that emits PortWatchEvent objects
1319
1799
  */
1320
- async checkPortReady(request) {
1800
+ async watchPort(request) {
1321
1801
  try {
1322
- return await this.post("/api/port-check", request);
1802
+ const stream = await this.doStreamFetch("/api/port-watch", request);
1803
+ this.logSuccess("Port watch started", `port ${request.port}`);
1804
+ return stream;
1323
1805
  } catch (error) {
1324
- return {
1325
- ready: false,
1326
- error: error instanceof Error ? error.message : "Port check failed"
1327
- };
1806
+ this.logError("watchPort", error);
1807
+ throw error;
1328
1808
  }
1329
1809
  }
1330
1810
  };
@@ -1439,8 +1919,7 @@ var ProcessClient = class extends BaseHttpClient {
1439
1919
  async streamProcessLogs(processId) {
1440
1920
  try {
1441
1921
  const url = `/api/process/${processId}/stream`;
1442
- const response = await this.doFetch(url, { method: "GET" });
1443
- const stream = await this.handleStreamResponse(response);
1922
+ const stream = await this.doStreamFetch(url, void 0, "GET");
1444
1923
  this.logSuccess("Process log stream started", `ID: ${processId}`);
1445
1924
  return stream;
1446
1925
  } catch (error) {
@@ -1531,6 +2010,12 @@ var UtilityClient = class extends BaseHttpClient {
1531
2010
  /**
1532
2011
  * Main sandbox client that composes all domain-specific clients
1533
2012
  * Provides organized access to all sandbox functionality
2013
+ *
2014
+ * Supports two transport modes:
2015
+ * - HTTP (default): Each request is a separate HTTP call
2016
+ * - WebSocket: All requests multiplexed over a single connection
2017
+ *
2018
+ * WebSocket mode reduces sub-request count when running inside Workers/Durable Objects.
1534
2019
  */
1535
2020
  var SandboxClient = class {
1536
2021
  commands;
@@ -1540,10 +2025,20 @@ var SandboxClient = class {
1540
2025
  git;
1541
2026
  interpreter;
1542
2027
  utils;
2028
+ transport = null;
1543
2029
  constructor(options) {
2030
+ if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
2031
+ mode: "websocket",
2032
+ wsUrl: options.wsUrl,
2033
+ baseUrl: options.baseUrl,
2034
+ logger: options.logger,
2035
+ stub: options.stub,
2036
+ port: options.port
2037
+ });
1544
2038
  const clientOptions = {
1545
2039
  baseUrl: "http://localhost:3000",
1546
- ...options
2040
+ ...options,
2041
+ transport: this.transport ?? options.transport
1547
2042
  };
1548
2043
  this.commands = new CommandClient(clientOptions);
1549
2044
  this.files = new FileClient(clientOptions);
@@ -1553,6 +2048,33 @@ var SandboxClient = class {
1553
2048
  this.interpreter = new InterpreterClient(clientOptions);
1554
2049
  this.utils = new UtilityClient(clientOptions);
1555
2050
  }
2051
+ /**
2052
+ * Get the current transport mode
2053
+ */
2054
+ getTransportMode() {
2055
+ return this.transport?.getMode() ?? "http";
2056
+ }
2057
+ /**
2058
+ * Check if WebSocket is connected (only relevant in WebSocket mode)
2059
+ */
2060
+ isWebSocketConnected() {
2061
+ return this.transport?.isConnected() ?? false;
2062
+ }
2063
+ /**
2064
+ * Connect WebSocket transport (no-op in HTTP mode)
2065
+ * Called automatically on first request, but can be called explicitly
2066
+ * to establish connection upfront.
2067
+ */
2068
+ async connect() {
2069
+ if (this.transport) await this.transport.connect();
2070
+ }
2071
+ /**
2072
+ * Disconnect WebSocket transport (no-op in HTTP mode)
2073
+ * Should be called when the sandbox is destroyed.
2074
+ */
2075
+ disconnect() {
2076
+ if (this.transport) this.transport.disconnect();
2077
+ }
1556
2078
  };
1557
2079
 
1558
2080
  //#endregion
@@ -1679,24 +2201,7 @@ var CodeInterpreter = class {
1679
2201
  const language = options.language || "python";
1680
2202
  context = await this.getOrCreateDefaultContext(language);
1681
2203
  }
1682
- const response = await this.interpreterClient.doFetch("/api/execute/code", {
1683
- method: "POST",
1684
- headers: {
1685
- "Content-Type": "application/json",
1686
- Accept: "text/event-stream"
1687
- },
1688
- body: JSON.stringify({
1689
- context_id: context.id,
1690
- code,
1691
- language: options.language
1692
- })
1693
- });
1694
- if (!response.ok) {
1695
- const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
1696
- throw new Error(errorData.error || `Failed to execute code: ${response.status}`);
1697
- }
1698
- if (!response.body) throw new Error("No response body for streaming execution");
1699
- return response.body;
2204
+ return this.interpreterClient.streamCode(context.id, code, options.language);
1700
2205
  }
1701
2206
  /**
1702
2207
  * List all code contexts
@@ -2019,7 +2524,7 @@ function resolveS3fsOptions(provider, userOptions) {
2019
2524
  * This file is auto-updated by .github/changeset-version.ts during releases
2020
2525
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
2021
2526
  */
2022
- const SDK_VERSION = "0.6.5";
2527
+ const SDK_VERSION = "0.6.7";
2023
2528
 
2024
2529
  //#endregion
2025
2530
  //#region src/sandbox.ts
@@ -2051,12 +2556,12 @@ var Sandbox = class extends Container {
2051
2556
  sandboxName = null;
2052
2557
  normalizeId = false;
2053
2558
  baseUrl = null;
2054
- portTokens = /* @__PURE__ */ new Map();
2055
2559
  defaultSession = null;
2056
2560
  envVars = {};
2057
2561
  logger;
2058
2562
  keepAliveEnabled = false;
2059
2563
  activeMounts = /* @__PURE__ */ new Map();
2564
+ transport = "http";
2060
2565
  /**
2061
2566
  * Default container startup timeouts (conservative for production)
2062
2567
  * Based on Cloudflare docs: "Containers take several minutes to provision"
@@ -2071,6 +2576,20 @@ var Sandbox = class extends Container {
2071
2576
  * Can be set via options, env vars, or defaults
2072
2577
  */
2073
2578
  containerTimeouts = { ...this.DEFAULT_CONTAINER_TIMEOUTS };
2579
+ /**
2580
+ * Create a SandboxClient with current transport settings
2581
+ */
2582
+ createSandboxClient() {
2583
+ return new SandboxClient({
2584
+ logger: this.logger,
2585
+ port: 3e3,
2586
+ stub: this,
2587
+ ...this.transport === "websocket" && {
2588
+ transportMode: "websocket",
2589
+ wsUrl: "ws://localhost:3000/ws"
2590
+ }
2591
+ });
2592
+ }
2074
2593
  constructor(ctx, env) {
2075
2594
  super(ctx, env);
2076
2595
  const envObj = env;
@@ -2082,19 +2601,15 @@ var Sandbox = class extends Container {
2082
2601
  component: "sandbox-do",
2083
2602
  sandboxId: this.ctx.id.toString()
2084
2603
  });
2085
- this.client = new SandboxClient({
2086
- logger: this.logger,
2087
- port: 3e3,
2088
- stub: this
2089
- });
2604
+ const transportEnv = envObj?.SANDBOX_TRANSPORT;
2605
+ if (transportEnv === "websocket") this.transport = "websocket";
2606
+ else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http" or "websocket". Defaulting to "http".`);
2607
+ this.client = this.createSandboxClient();
2090
2608
  this.codeInterpreter = new CodeInterpreter(this);
2091
2609
  this.ctx.blockConcurrencyWhile(async () => {
2092
2610
  this.sandboxName = await this.ctx.storage.get("sandboxName") || null;
2093
2611
  this.normalizeId = await this.ctx.storage.get("normalizeId") || false;
2094
2612
  this.defaultSession = await this.ctx.storage.get("defaultSession") || null;
2095
- const storedTokens = await this.ctx.storage.get("portTokens") || {};
2096
- this.portTokens = /* @__PURE__ */ new Map();
2097
- for (const [portStr, token] of Object.entries(storedTokens)) this.portTokens.set(parseInt(portStr, 10), token);
2098
2613
  const storedTimeouts = await this.ctx.storage.get("containerTimeouts");
2099
2614
  if (storedTimeouts) this.containerTimeouts = {
2100
2615
  ...this.containerTimeouts,
@@ -2302,6 +2817,7 @@ var Sandbox = class extends Container {
2302
2817
  */
2303
2818
  async destroy() {
2304
2819
  this.logger.info("Destroying sandbox container");
2820
+ this.client.disconnect();
2305
2821
  for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
2306
2822
  if (mountInfo.mounted) try {
2307
2823
  this.logger.info(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
@@ -2346,7 +2862,6 @@ var Sandbox = class extends Container {
2346
2862
  }
2347
2863
  async onStop() {
2348
2864
  this.logger.debug("Sandbox stopped");
2349
- this.portTokens.clear();
2350
2865
  this.defaultSession = null;
2351
2866
  this.activeMounts.clear();
2352
2867
  await Promise.all([this.ctx.storage.delete("portTokens"), this.ctx.storage.delete("defaultSession")]);
@@ -2379,18 +2894,57 @@ var Sandbox = class extends Container {
2379
2894
  status: 503,
2380
2895
  headers: { "Retry-After": "10" }
2381
2896
  });
2382
- this.logger.error("Container startup failed", e instanceof Error ? e : new Error(String(e)));
2897
+ if (this.isTransientStartupError(e)) {
2898
+ this.logger.debug("Transient container startup error, returning 503", { error: e instanceof Error ? e.message : String(e) });
2899
+ return new Response("Container is starting. Please retry in a moment.", {
2900
+ status: 503,
2901
+ headers: { "Retry-After": "3" }
2902
+ });
2903
+ }
2904
+ this.logger.error("Container startup failed with permanent error", e instanceof Error ? e : new Error(String(e)));
2383
2905
  return new Response(`Failed to start container: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
2384
2906
  }
2385
2907
  return await super.containerFetch(requestOrUrl, portOrInit, portParam);
2386
2908
  }
2387
2909
  /**
2388
2910
  * Helper: Check if error is "no container instance available"
2911
+ * This indicates the container VM is still being provisioned.
2389
2912
  */
2390
2913
  isNoInstanceError(error) {
2391
2914
  return error instanceof Error && error.message.toLowerCase().includes("no container instance");
2392
2915
  }
2393
2916
  /**
2917
+ * Helper: Check if error is a transient startup error that should trigger retry
2918
+ *
2919
+ * These errors occur during normal container startup and are recoverable:
2920
+ * - Port not yet mapped (container starting, app not listening yet)
2921
+ * - Connection refused (port mapped but app not ready)
2922
+ * - Timeouts during startup (recoverable with retry)
2923
+ * - Network transients (temporary connectivity issues)
2924
+ *
2925
+ * Errors NOT included (permanent failures):
2926
+ * - "no such image" - missing Docker image
2927
+ * - "container already exists" - name collision
2928
+ * - Configuration errors
2929
+ */
2930
+ isTransientStartupError(error) {
2931
+ if (!(error instanceof Error)) return false;
2932
+ const msg = error.message.toLowerCase();
2933
+ return [
2934
+ "container port not found",
2935
+ "connection refused: container port",
2936
+ "the container is not listening",
2937
+ "failed to verify port",
2938
+ "container did not start",
2939
+ "network connection lost",
2940
+ "container suddenly disconnected",
2941
+ "monitor failed to find container",
2942
+ "timed out",
2943
+ "timeout",
2944
+ "the operation was aborted"
2945
+ ].some((pattern) => msg.includes(pattern));
2946
+ }
2947
+ /**
2394
2948
  * Helper: Parse containerFetch arguments (supports multiple signatures)
2395
2949
  */
2396
2950
  parseContainerFetchArgs(requestOrUrl, portOrInit, portParam) {
@@ -2451,7 +3005,7 @@ var Sandbox = class extends Container {
2451
3005
  return await this.containerFetch(request, port);
2452
3006
  }
2453
3007
  wsConnect(request, port) {
2454
- throw new Error("Not implemented here to avoid RPC serialization issues");
3008
+ throw new Error("wsConnect must be called on the stub returned by getSandbox()");
2455
3009
  }
2456
3010
  determinePort(url) {
2457
3011
  const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/);
@@ -2462,9 +3016,9 @@ var Sandbox = class extends Container {
2462
3016
  * Ensure default session exists - lazy initialization
2463
3017
  * This is called automatically by all public methods that need a session
2464
3018
  *
2465
- * The session is persisted to Durable Object storage to survive hot reloads
2466
- * during development. If a session already exists in the container after reload,
2467
- * we reuse it instead of trying to create a new one.
3019
+ * The session ID is persisted to DO storage. On container restart, if the
3020
+ * container already has this session (from a previous instance), we sync
3021
+ * our state rather than failing on duplicate creation.
2468
3022
  */
2469
3023
  async ensureDefaultSession() {
2470
3024
  const sessionId = `sandbox-${this.sandboxName || "default"}`;
@@ -2606,6 +3160,9 @@ var Sandbox = class extends Container {
2606
3160
  },
2607
3161
  waitForPort: async (port, options) => {
2608
3162
  await this.waitForPortReady(data.id, data.command, port, options);
3163
+ },
3164
+ waitForExit: async (timeout) => {
3165
+ return this.waitForProcessExit(data.id, data.command, timeout);
2609
3166
  }
2610
3167
  };
2611
3168
  }
@@ -2699,35 +3256,65 @@ var Sandbox = class extends Container {
2699
3256
  min: 200,
2700
3257
  max: 399
2701
3258
  }, timeout, interval = 500 } = options ?? {};
2702
- const startTime = Date.now();
2703
3259
  const conditionStr = mode === "http" ? `port ${port} (HTTP ${path})` : `port ${port} (TCP)`;
2704
- const targetInterval = interval;
2705
- let checkCount = 0;
2706
- const checkRequest = {
3260
+ const statusMin = typeof status === "number" ? status : status.min;
3261
+ const statusMax = typeof status === "number" ? status : status.max;
3262
+ const stream = await this.client.ports.watchPort({
2707
3263
  port,
2708
3264
  mode,
2709
3265
  path,
2710
- statusMin: typeof status === "number" ? status : status.min,
2711
- statusMax: typeof status === "number" ? status : status.max
2712
- };
2713
- while (true) {
2714
- if (timeout !== void 0) {
2715
- if (timeout - (Date.now() - startTime) <= 0) throw this.createReadyTimeoutError(processId, command, conditionStr, timeout);
2716
- }
2717
- const iterationStart = Date.now();
2718
- if (checkCount % 3 === 0) {
2719
- const processInfo = await this.getProcess(processId);
2720
- if (!processInfo || isTerminalStatus(processInfo.status)) throw this.createExitedBeforeReadyError(processId, command, conditionStr, processInfo?.exitCode ?? 1);
2721
- }
3266
+ statusMin,
3267
+ statusMax,
3268
+ processId,
3269
+ interval
3270
+ });
3271
+ let timeoutId;
3272
+ let timeoutPromise;
3273
+ if (timeout !== void 0) timeoutPromise = new Promise((_, reject) => {
3274
+ timeoutId = setTimeout(() => {
3275
+ reject(this.createReadyTimeoutError(processId, command, conditionStr, timeout));
3276
+ }, timeout);
3277
+ });
3278
+ try {
3279
+ const streamProcessor = async () => {
3280
+ for await (const event of parseSSEStream(stream)) switch (event.type) {
3281
+ case "ready": return;
3282
+ case "process_exited": throw this.createExitedBeforeReadyError(processId, command, conditionStr, event.exitCode ?? 1);
3283
+ case "error": throw new Error(event.error || "Port watch failed");
3284
+ }
3285
+ throw new Error("Port watch stream ended unexpectedly");
3286
+ };
3287
+ if (timeoutPromise) await Promise.race([streamProcessor(), timeoutPromise]);
3288
+ else await streamProcessor();
3289
+ } finally {
3290
+ if (timeoutId) clearTimeout(timeoutId);
2722
3291
  try {
2723
- if ((await this.client.ports.checkPortReady(checkRequest)).ready) return;
3292
+ await stream.cancel();
2724
3293
  } catch {}
2725
- checkCount++;
2726
- const iterationDuration = Date.now() - iterationStart;
2727
- const sleepTime = Math.max(0, targetInterval - iterationDuration);
2728
- if (sleepTime > 0) {
2729
- if (timeout === void 0 || Date.now() - startTime + sleepTime < timeout) await new Promise((resolve) => setTimeout(resolve, sleepTime));
2730
- }
3294
+ }
3295
+ }
3296
+ /**
3297
+ * Wait for a process to exit
3298
+ * Returns the exit code
3299
+ */
3300
+ async waitForProcessExit(processId, command, timeout) {
3301
+ const stream = await this.streamProcessLogs(processId);
3302
+ let timeoutId;
3303
+ let timeoutPromise;
3304
+ if (timeout !== void 0) timeoutPromise = new Promise((_, reject) => {
3305
+ timeoutId = setTimeout(() => {
3306
+ reject(this.createReadyTimeoutError(processId, command, "process exit", timeout));
3307
+ }, timeout);
3308
+ });
3309
+ try {
3310
+ const streamProcessor = async () => {
3311
+ for await (const event of parseSSEStream(stream)) if (event.type === "exit") return { exitCode: event.exitCode ?? 1 };
3312
+ throw new Error(`Process ${processId} stream ended unexpectedly without exit event`);
3313
+ };
3314
+ if (timeoutPromise) return await Promise.race([streamProcessor(), timeoutPromise]);
3315
+ return await streamProcessor();
3316
+ } finally {
3317
+ if (timeoutId) clearTimeout(timeoutId);
2731
3318
  }
2732
3319
  }
2733
3320
  /**
@@ -2988,8 +3575,9 @@ var Sandbox = class extends Container {
2988
3575
  await this.client.ports.exposePort(port, sessionId, options?.name);
2989
3576
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
2990
3577
  const token = this.generatePortToken();
2991
- this.portTokens.set(port, token);
2992
- await this.persistPortTokens();
3578
+ const tokens = await this.ctx.storage.get("portTokens") || {};
3579
+ tokens[port.toString()] = token;
3580
+ await this.ctx.storage.put("portTokens", tokens);
2993
3581
  return {
2994
3582
  url: this.constructPreviewUrl(port, this.sandboxName, options.hostname, token),
2995
3583
  port,
@@ -3000,17 +3588,19 @@ var Sandbox = class extends Container {
3000
3588
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
3001
3589
  const sessionId = await this.ensureDefaultSession();
3002
3590
  await this.client.ports.unexposePort(port, sessionId);
3003
- if (this.portTokens.has(port)) {
3004
- this.portTokens.delete(port);
3005
- await this.persistPortTokens();
3591
+ const tokens = await this.ctx.storage.get("portTokens") || {};
3592
+ if (tokens[port.toString()]) {
3593
+ delete tokens[port.toString()];
3594
+ await this.ctx.storage.put("portTokens", tokens);
3006
3595
  }
3007
3596
  }
3008
3597
  async getExposedPorts(hostname) {
3009
3598
  const sessionId = await this.ensureDefaultSession();
3010
3599
  const response = await this.client.ports.getExposedPorts(sessionId);
3011
3600
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
3601
+ const tokens = await this.ctx.storage.get("portTokens") || {};
3012
3602
  return response.ports.map((port) => {
3013
- const token = this.portTokens.get(port.port);
3603
+ const token = tokens[port.port.toString()];
3014
3604
  if (!token) throw new Error(`Port ${port.port} is exposed but has no token. This should not happen.`);
3015
3605
  return {
3016
3606
  url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, token),
@@ -3030,7 +3620,7 @@ var Sandbox = class extends Container {
3030
3620
  }
3031
3621
  async validatePortToken(port, token) {
3032
3622
  if (!await this.isPortExposed(port)) return false;
3033
- const storedToken = this.portTokens.get(port);
3623
+ const storedToken = (await this.ctx.storage.get("portTokens") || {})[port.toString()];
3034
3624
  if (!storedToken) {
3035
3625
  this.logger.error("Port is exposed but has no token - bug detected", void 0, { port });
3036
3626
  return false;
@@ -3042,11 +3632,6 @@ var Sandbox = class extends Container {
3042
3632
  crypto.getRandomValues(array);
3043
3633
  return btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "").toLowerCase();
3044
3634
  }
3045
- async persistPortTokens() {
3046
- const tokensObj = {};
3047
- for (const [port, token] of this.portTokens.entries()) tokensObj[port.toString()] = token;
3048
- await this.ctx.storage.put("portTokens", tokensObj);
3049
- }
3050
3635
  constructPreviewUrl(port, sandboxId, hostname, token) {
3051
3636
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
3052
3637
  const effectiveId = this.sandboxName || sandboxId;