@cloudflare/sandbox 0.6.6 → 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 on 503 (Service Unavailable) which indicates container is starting
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 (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,35 +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
- *
710
- * The Sandbox DO returns proper HTTP status codes:
711
- * - 503 Service Unavailable: Transient errors (container starting, port not ready)
712
- * - 500 Internal Server Error: Permanent errors (bad config, missing image)
713
- *
714
- * We only retry on 503, which indicates the container is starting up.
715
- * The Retry-After header suggests how long to wait.
716
- *
717
- * @param response - HTTP response to check
718
- * @returns true if error is retryable (503), false otherwise
719
- */
720
- isRetryableContainerError(response) {
721
- return response.status === 503;
722
- }
723
- async executeFetch(path, options) {
724
- const url = this.options.stub ? `http://localhost:${this.options.port}${path}` : `${this.baseUrl}${path}`;
725
- try {
726
- if (this.options.stub) return await this.options.stub.containerFetch(url, options || {}, this.options.port);
727
- else return await fetch(url, options);
728
- } catch (error) {
729
- this.logger.error("HTTP request error", error instanceof Error ? error : new Error(String(error)), {
730
- method: options?.method || "GET",
731
- url
732
- });
733
- throw error;
734
- }
735
- }
736
1255
  };
737
1256
 
738
1257
  //#endregion
@@ -783,12 +1302,7 @@ var CommandClient = class extends BaseHttpClient {
783
1302
  ...options?.env !== void 0 && { env: options.env },
784
1303
  ...options?.cwd !== void 0 && { cwd: options.cwd }
785
1304
  };
786
- const response = await this.doFetch("/api/execute/stream", {
787
- method: "POST",
788
- headers: { "Content-Type": "application/json" },
789
- body: JSON.stringify(data)
790
- });
791
- const stream = await this.handleStreamResponse(response);
1305
+ const stream = await this.doStreamFetch("/api/execute/stream", data);
792
1306
  this.logSuccess("Command stream started", command);
793
1307
  return stream;
794
1308
  } catch (error) {
@@ -882,12 +1396,7 @@ var FileClient = class extends BaseHttpClient {
882
1396
  path,
883
1397
  sessionId
884
1398
  };
885
- const response = await this.doFetch("/api/read/stream", {
886
- method: "POST",
887
- headers: { "Content-Type": "application/json" },
888
- body: JSON.stringify(data)
889
- });
890
- const stream = await this.handleStreamResponse(response);
1399
+ const stream = await this.doStreamFetch("/api/read/stream", data);
891
1400
  this.logSuccess("File stream started", path);
892
1401
  return stream;
893
1402
  } catch (error) {
@@ -1076,22 +1585,13 @@ var InterpreterClient = class extends BaseHttpClient {
1076
1585
  }
1077
1586
  async runCodeStream(contextId, code, language, callbacks, timeoutMs) {
1078
1587
  return this.executeWithRetry(async () => {
1079
- const response = await this.doFetch("/api/execute/code", {
1080
- method: "POST",
1081
- headers: {
1082
- "Content-Type": "application/json",
1083
- Accept: "text/event-stream"
1084
- },
1085
- body: JSON.stringify({
1086
- context_id: contextId,
1087
- code,
1088
- language,
1089
- ...timeoutMs !== void 0 && { timeout_ms: timeoutMs }
1090
- })
1588
+ const stream = await this.doStreamFetch("/api/execute/code", {
1589
+ context_id: contextId,
1590
+ code,
1591
+ language,
1592
+ ...timeoutMs !== void 0 && { timeout_ms: timeoutMs }
1091
1593
  });
1092
- if (!response.ok) throw await this.parseErrorResponse(response);
1093
- if (!response.body) throw new Error("No response body for streaming execution");
1094
- 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);
1095
1595
  });
1096
1596
  }
1097
1597
  async listCodeContexts() {
@@ -1122,6 +1622,17 @@ var InterpreterClient = class extends BaseHttpClient {
1122
1622
  });
1123
1623
  }
1124
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
+ /**
1125
1636
  * Execute an operation with automatic retry for transient errors
1126
1637
  */
1127
1638
  async executeWithRetry(operation) {
@@ -1282,17 +1793,18 @@ var PortClient = class extends BaseHttpClient {
1282
1793
  }
1283
1794
  }
1284
1795
  /**
1285
- * Check if a port is ready to accept connections
1286
- * @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
1287
1799
  */
1288
- async checkPortReady(request) {
1800
+ async watchPort(request) {
1289
1801
  try {
1290
- 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;
1291
1805
  } catch (error) {
1292
- return {
1293
- ready: false,
1294
- error: error instanceof Error ? error.message : "Port check failed"
1295
- };
1806
+ this.logError("watchPort", error);
1807
+ throw error;
1296
1808
  }
1297
1809
  }
1298
1810
  };
@@ -1407,8 +1919,7 @@ var ProcessClient = class extends BaseHttpClient {
1407
1919
  async streamProcessLogs(processId) {
1408
1920
  try {
1409
1921
  const url = `/api/process/${processId}/stream`;
1410
- const response = await this.doFetch(url, { method: "GET" });
1411
- const stream = await this.handleStreamResponse(response);
1922
+ const stream = await this.doStreamFetch(url, void 0, "GET");
1412
1923
  this.logSuccess("Process log stream started", `ID: ${processId}`);
1413
1924
  return stream;
1414
1925
  } catch (error) {
@@ -1499,6 +2010,12 @@ var UtilityClient = class extends BaseHttpClient {
1499
2010
  /**
1500
2011
  * Main sandbox client that composes all domain-specific clients
1501
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.
1502
2019
  */
1503
2020
  var SandboxClient = class {
1504
2021
  commands;
@@ -1508,10 +2025,20 @@ var SandboxClient = class {
1508
2025
  git;
1509
2026
  interpreter;
1510
2027
  utils;
2028
+ transport = null;
1511
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
+ });
1512
2038
  const clientOptions = {
1513
2039
  baseUrl: "http://localhost:3000",
1514
- ...options
2040
+ ...options,
2041
+ transport: this.transport ?? options.transport
1515
2042
  };
1516
2043
  this.commands = new CommandClient(clientOptions);
1517
2044
  this.files = new FileClient(clientOptions);
@@ -1521,6 +2048,33 @@ var SandboxClient = class {
1521
2048
  this.interpreter = new InterpreterClient(clientOptions);
1522
2049
  this.utils = new UtilityClient(clientOptions);
1523
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
+ }
1524
2078
  };
1525
2079
 
1526
2080
  //#endregion
@@ -1647,24 +2201,7 @@ var CodeInterpreter = class {
1647
2201
  const language = options.language || "python";
1648
2202
  context = await this.getOrCreateDefaultContext(language);
1649
2203
  }
1650
- const response = await this.interpreterClient.doFetch("/api/execute/code", {
1651
- method: "POST",
1652
- headers: {
1653
- "Content-Type": "application/json",
1654
- Accept: "text/event-stream"
1655
- },
1656
- body: JSON.stringify({
1657
- context_id: context.id,
1658
- code,
1659
- language: options.language
1660
- })
1661
- });
1662
- if (!response.ok) {
1663
- const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
1664
- throw new Error(errorData.error || `Failed to execute code: ${response.status}`);
1665
- }
1666
- if (!response.body) throw new Error("No response body for streaming execution");
1667
- return response.body;
2204
+ return this.interpreterClient.streamCode(context.id, code, options.language);
1668
2205
  }
1669
2206
  /**
1670
2207
  * List all code contexts
@@ -1987,7 +2524,7 @@ function resolveS3fsOptions(provider, userOptions) {
1987
2524
  * This file is auto-updated by .github/changeset-version.ts during releases
1988
2525
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
1989
2526
  */
1990
- const SDK_VERSION = "0.6.6";
2527
+ const SDK_VERSION = "0.6.7";
1991
2528
 
1992
2529
  //#endregion
1993
2530
  //#region src/sandbox.ts
@@ -2019,12 +2556,12 @@ var Sandbox = class extends Container {
2019
2556
  sandboxName = null;
2020
2557
  normalizeId = false;
2021
2558
  baseUrl = null;
2022
- portTokens = /* @__PURE__ */ new Map();
2023
2559
  defaultSession = null;
2024
2560
  envVars = {};
2025
2561
  logger;
2026
2562
  keepAliveEnabled = false;
2027
2563
  activeMounts = /* @__PURE__ */ new Map();
2564
+ transport = "http";
2028
2565
  /**
2029
2566
  * Default container startup timeouts (conservative for production)
2030
2567
  * Based on Cloudflare docs: "Containers take several minutes to provision"
@@ -2039,6 +2576,20 @@ var Sandbox = class extends Container {
2039
2576
  * Can be set via options, env vars, or defaults
2040
2577
  */
2041
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
+ }
2042
2593
  constructor(ctx, env) {
2043
2594
  super(ctx, env);
2044
2595
  const envObj = env;
@@ -2050,19 +2601,15 @@ var Sandbox = class extends Container {
2050
2601
  component: "sandbox-do",
2051
2602
  sandboxId: this.ctx.id.toString()
2052
2603
  });
2053
- this.client = new SandboxClient({
2054
- logger: this.logger,
2055
- port: 3e3,
2056
- stub: this
2057
- });
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();
2058
2608
  this.codeInterpreter = new CodeInterpreter(this);
2059
2609
  this.ctx.blockConcurrencyWhile(async () => {
2060
2610
  this.sandboxName = await this.ctx.storage.get("sandboxName") || null;
2061
2611
  this.normalizeId = await this.ctx.storage.get("normalizeId") || false;
2062
2612
  this.defaultSession = await this.ctx.storage.get("defaultSession") || null;
2063
- const storedTokens = await this.ctx.storage.get("portTokens") || {};
2064
- this.portTokens = /* @__PURE__ */ new Map();
2065
- for (const [portStr, token] of Object.entries(storedTokens)) this.portTokens.set(parseInt(portStr, 10), token);
2066
2613
  const storedTimeouts = await this.ctx.storage.get("containerTimeouts");
2067
2614
  if (storedTimeouts) this.containerTimeouts = {
2068
2615
  ...this.containerTimeouts,
@@ -2270,6 +2817,7 @@ var Sandbox = class extends Container {
2270
2817
  */
2271
2818
  async destroy() {
2272
2819
  this.logger.info("Destroying sandbox container");
2820
+ this.client.disconnect();
2273
2821
  for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
2274
2822
  if (mountInfo.mounted) try {
2275
2823
  this.logger.info(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
@@ -2314,7 +2862,6 @@ var Sandbox = class extends Container {
2314
2862
  }
2315
2863
  async onStop() {
2316
2864
  this.logger.debug("Sandbox stopped");
2317
- this.portTokens.clear();
2318
2865
  this.defaultSession = null;
2319
2866
  this.activeMounts.clear();
2320
2867
  await Promise.all([this.ctx.storage.delete("portTokens"), this.ctx.storage.delete("defaultSession")]);
@@ -2458,7 +3005,7 @@ var Sandbox = class extends Container {
2458
3005
  return await this.containerFetch(request, port);
2459
3006
  }
2460
3007
  wsConnect(request, port) {
2461
- 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()");
2462
3009
  }
2463
3010
  determinePort(url) {
2464
3011
  const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/);
@@ -2469,9 +3016,9 @@ var Sandbox = class extends Container {
2469
3016
  * Ensure default session exists - lazy initialization
2470
3017
  * This is called automatically by all public methods that need a session
2471
3018
  *
2472
- * The session is persisted to Durable Object storage to survive hot reloads
2473
- * during development. If a session already exists in the container after reload,
2474
- * 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.
2475
3022
  */
2476
3023
  async ensureDefaultSession() {
2477
3024
  const sessionId = `sandbox-${this.sandboxName || "default"}`;
@@ -2709,35 +3256,41 @@ var Sandbox = class extends Container {
2709
3256
  min: 200,
2710
3257
  max: 399
2711
3258
  }, timeout, interval = 500 } = options ?? {};
2712
- const startTime = Date.now();
2713
3259
  const conditionStr = mode === "http" ? `port ${port} (HTTP ${path})` : `port ${port} (TCP)`;
2714
- const targetInterval = interval;
2715
- let checkCount = 0;
2716
- 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({
2717
3263
  port,
2718
3264
  mode,
2719
3265
  path,
2720
- statusMin: typeof status === "number" ? status : status.min,
2721
- statusMax: typeof status === "number" ? status : status.max
2722
- };
2723
- while (true) {
2724
- if (timeout !== void 0) {
2725
- if (timeout - (Date.now() - startTime) <= 0) throw this.createReadyTimeoutError(processId, command, conditionStr, timeout);
2726
- }
2727
- const iterationStart = Date.now();
2728
- if (checkCount % 3 === 0) {
2729
- const processInfo = await this.getProcess(processId);
2730
- if (!processInfo || isTerminalStatus(processInfo.status)) throw this.createExitedBeforeReadyError(processId, command, conditionStr, processInfo?.exitCode ?? 1);
2731
- }
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);
2732
3291
  try {
2733
- if ((await this.client.ports.checkPortReady(checkRequest)).ready) return;
3292
+ await stream.cancel();
2734
3293
  } catch {}
2735
- checkCount++;
2736
- const iterationDuration = Date.now() - iterationStart;
2737
- const sleepTime = Math.max(0, targetInterval - iterationDuration);
2738
- if (sleepTime > 0) {
2739
- if (timeout === void 0 || Date.now() - startTime + sleepTime < timeout) await new Promise((resolve) => setTimeout(resolve, sleepTime));
2740
- }
2741
3294
  }
2742
3295
  }
2743
3296
  /**
@@ -3022,8 +3575,9 @@ var Sandbox = class extends Container {
3022
3575
  await this.client.ports.exposePort(port, sessionId, options?.name);
3023
3576
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
3024
3577
  const token = this.generatePortToken();
3025
- this.portTokens.set(port, token);
3026
- 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);
3027
3581
  return {
3028
3582
  url: this.constructPreviewUrl(port, this.sandboxName, options.hostname, token),
3029
3583
  port,
@@ -3034,17 +3588,19 @@ var Sandbox = class extends Container {
3034
3588
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
3035
3589
  const sessionId = await this.ensureDefaultSession();
3036
3590
  await this.client.ports.unexposePort(port, sessionId);
3037
- if (this.portTokens.has(port)) {
3038
- this.portTokens.delete(port);
3039
- 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);
3040
3595
  }
3041
3596
  }
3042
3597
  async getExposedPorts(hostname) {
3043
3598
  const sessionId = await this.ensureDefaultSession();
3044
3599
  const response = await this.client.ports.getExposedPorts(sessionId);
3045
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") || {};
3046
3602
  return response.ports.map((port) => {
3047
- const token = this.portTokens.get(port.port);
3603
+ const token = tokens[port.port.toString()];
3048
3604
  if (!token) throw new Error(`Port ${port.port} is exposed but has no token. This should not happen.`);
3049
3605
  return {
3050
3606
  url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, token),
@@ -3064,7 +3620,7 @@ var Sandbox = class extends Container {
3064
3620
  }
3065
3621
  async validatePortToken(port, token) {
3066
3622
  if (!await this.isPortExposed(port)) return false;
3067
- const storedToken = this.portTokens.get(port);
3623
+ const storedToken = (await this.ctx.storage.get("portTokens") || {})[port.toString()];
3068
3624
  if (!storedToken) {
3069
3625
  this.logger.error("Port is exposed but has no token - bug detected", void 0, { port });
3070
3626
  return false;
@@ -3076,11 +3632,6 @@ var Sandbox = class extends Container {
3076
3632
  crypto.getRandomValues(array);
3077
3633
  return btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "").toLowerCase();
3078
3634
  }
3079
- async persistPortTokens() {
3080
- const tokensObj = {};
3081
- for (const [port, token] of this.portTokens.entries()) tokensObj[port.toString()] = token;
3082
- await this.ctx.storage.put("portTokens", tokensObj);
3083
- }
3084
3635
  constructPreviewUrl(port, sandboxId, hostname, token) {
3085
3636
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
3086
3637
  const effectiveId = this.sandboxName || sandboxId;