@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/README.md +2 -1
- package/dist/{dist-2SF6oOaz.js → dist-Dgwt0coR.js} +35 -8
- package/dist/dist-Dgwt0coR.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +771 -186
- package/dist/index.js.map +1 -1
- package/dist/openai/index.d.ts +1 -1
- package/dist/openai/index.js +1 -1
- package/dist/opencode/index.d.ts +1 -1
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/opencode/index.js +1 -1
- package/dist/{sandbox-C9WRqWBO.d.ts → sandbox-uifih-hT.d.ts} +172 -33
- package/dist/sandbox-uifih-hT.d.ts.map +1 -0
- package/package.json +3 -2
- package/dist/dist-2SF6oOaz.js.map +0 -1
- package/dist/sandbox-C9WRqWBO.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
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-
|
|
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
|
|
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
|
|
579
|
-
|
|
580
|
-
options;
|
|
584
|
+
var BaseTransport = class {
|
|
585
|
+
config;
|
|
581
586
|
logger;
|
|
582
|
-
constructor(
|
|
583
|
-
this.
|
|
584
|
-
this.logger =
|
|
585
|
-
this.baseUrl = this.options.baseUrl;
|
|
587
|
+
constructor(config) {
|
|
588
|
+
this.config = config;
|
|
589
|
+
this.logger = config.logger ?? createNoOpLogger();
|
|
586
590
|
}
|
|
587
591
|
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
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
|
|
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.
|
|
596
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1318
|
-
* @param request - Port
|
|
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
|
|
1800
|
+
async watchPort(request) {
|
|
1321
1801
|
try {
|
|
1322
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
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.
|
|
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("
|
|
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
|
|
2466
|
-
*
|
|
2467
|
-
*
|
|
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
|
|
2705
|
-
|
|
2706
|
-
const
|
|
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
|
|
2711
|
-
statusMax
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
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
|
-
|
|
3292
|
+
await stream.cancel();
|
|
2724
3293
|
} catch {}
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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.
|
|
2992
|
-
|
|
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
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
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 =
|
|
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.
|
|
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;
|