@cloudflare/sandbox 0.12.1 → 0.13.0-next.633.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/dist/bridge/index.js +3 -3
- package/dist/{contexts-B0_bcx9f.d.ts → contexts-DPFhc2nR.d.ts} +8 -2
- package/dist/contexts-DPFhc2nR.d.ts.map +1 -0
- package/dist/{dist-B_eXrP83.js → dist-mAH_7Ui7.js} +2 -122
- package/dist/dist-mAH_7Ui7.js.map +1 -0
- package/dist/{errors-aRUdk9K8.js → errors-CpoDEfUZ.js} +8 -1
- package/dist/errors-CpoDEfUZ.js.map +1 -0
- package/dist/index.d.ts +17 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/openai/index.d.ts +1 -1
- package/dist/openai/index.js +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.js +2 -2
- package/dist/{sandbox-C8l-pMlL.d.ts → sandbox-44kEJjAc.d.ts} +46 -825
- package/dist/sandbox-44kEJjAc.d.ts.map +1 -0
- package/dist/{sandbox-DKG3H156.js → sandbox-C9CHzLDx.js} +1027 -2745
- package/dist/sandbox-C9CHzLDx.js.map +1 -0
- package/package.json +1 -1
- package/dist/contexts-B0_bcx9f.d.ts.map +0 -1
- package/dist/dist-B_eXrP83.js.map +0 -1
- package/dist/errors-aRUdk9K8.js.map +0 -1
- package/dist/sandbox-C8l-pMlL.d.ts.map +0 -1
- package/dist/sandbox-DKG3H156.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-
|
|
1
|
+
import { a as shellEscape, c as TraceContext, d as ResultImpl, f as filterEnvVars, i as parseSSEFrames, l as logCanonicalEvent, m as partitionEnvVars, o as createLogger, p as getEnvString, s as createNoOpLogger, u as Execution } from "./dist-mAH_7Ui7.js";
|
|
2
|
+
import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-CpoDEfUZ.js";
|
|
3
3
|
import { Container, ContainerProxy, getContainer, switchPort } from "@cloudflare/containers";
|
|
4
4
|
import { AwsClient } from "aws4fetch";
|
|
5
5
|
import { RpcSession, RpcTarget } from "capnweb";
|
|
@@ -629,6 +629,12 @@ var BackupRestoreError = class extends SandboxError {
|
|
|
629
629
|
return this.context.backupId;
|
|
630
630
|
}
|
|
631
631
|
};
|
|
632
|
+
var ContainerUnavailableError = class extends SandboxError {
|
|
633
|
+
constructor(errorResponse) {
|
|
634
|
+
super(errorResponse);
|
|
635
|
+
this.name = "ContainerUnavailableError";
|
|
636
|
+
}
|
|
637
|
+
};
|
|
632
638
|
/**
|
|
633
639
|
* Raised when the capnweb WebSocket session itself fails on the SDK side.
|
|
634
640
|
* Unlike the rest of the SandboxError tree, the container never produces
|
|
@@ -710,6 +716,7 @@ function createErrorFromResponse(errorResponse, options) {
|
|
|
710
716
|
case ErrorCode.INTERPRETER_NOT_READY: return new InterpreterNotReadyError(errorResponse);
|
|
711
717
|
case ErrorCode.CONTEXT_NOT_FOUND: return new ContextNotFoundError(errorResponse);
|
|
712
718
|
case ErrorCode.CODE_EXECUTION_ERROR: return new CodeExecutionError(errorResponse);
|
|
719
|
+
case ErrorCode.CONTAINER_UNAVAILABLE: return new ContainerUnavailableError(errorResponse);
|
|
713
720
|
case ErrorCode.RPC_TRANSPORT_ERROR: return new RPCTransportError(errorResponse, options);
|
|
714
721
|
case ErrorCode.VALIDATION_FAILED: return new ValidationFailedError(errorResponse);
|
|
715
722
|
case ErrorCode.INVALID_JSON_RESPONSE:
|
|
@@ -720,2719 +727,1098 @@ function createErrorFromResponse(errorResponse, options) {
|
|
|
720
727
|
}
|
|
721
728
|
|
|
722
729
|
//#endregion
|
|
723
|
-
//#region src/
|
|
724
|
-
const DEFAULT_INITIAL_RETRY_DELAY_MS = 3e3;
|
|
725
|
-
const DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
|
|
726
|
-
const RETRYABLE_WEBSOCKET_UPGRADE_STATUSES = new Set([
|
|
727
|
-
500,
|
|
728
|
-
502,
|
|
729
|
-
503,
|
|
730
|
-
504
|
|
731
|
-
]);
|
|
732
|
-
function isRetryableWebSocketUpgradeResponse(response) {
|
|
733
|
-
return RETRYABLE_WEBSOCKET_UPGRADE_STATUSES.has(response.status);
|
|
734
|
-
}
|
|
730
|
+
//#region src/file-stream.ts
|
|
735
731
|
/**
|
|
736
|
-
*
|
|
737
|
-
* The retry budget covers the whole operation; each attempt owns any
|
|
738
|
-
* per-request timeout inside the caller-provided `fetchResponse` function.
|
|
732
|
+
* Parse SSE (Server-Sent Events) lines from a stream
|
|
739
733
|
*/
|
|
740
|
-
async function
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
734
|
+
async function* parseSSE(stream) {
|
|
735
|
+
const reader = stream.getReader();
|
|
736
|
+
const decoder = new TextDecoder();
|
|
737
|
+
let buffer = "";
|
|
738
|
+
let currentEvent = { data: [] };
|
|
739
|
+
try {
|
|
740
|
+
while (true) {
|
|
741
|
+
const { done, value } = await reader.read();
|
|
742
|
+
if (done) break;
|
|
743
|
+
buffer += decoder.decode(value, { stream: true });
|
|
744
|
+
const parsed = parseSSEFrames(buffer, currentEvent);
|
|
745
|
+
buffer = parsed.remaining;
|
|
746
|
+
currentEvent = parsed.currentEvent;
|
|
747
|
+
for (const frame of parsed.events) try {
|
|
748
|
+
yield JSON.parse(frame.data);
|
|
749
|
+
} catch {}
|
|
755
750
|
}
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
attempt++;
|
|
751
|
+
const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
|
|
752
|
+
for (const frame of finalParsed.events) try {
|
|
753
|
+
yield JSON.parse(frame.data);
|
|
754
|
+
} catch {}
|
|
755
|
+
} finally {
|
|
756
|
+
try {
|
|
757
|
+
await reader.cancel();
|
|
758
|
+
} catch {}
|
|
759
|
+
reader.releaseLock();
|
|
766
760
|
}
|
|
767
761
|
}
|
|
768
|
-
|
|
769
|
-
//#endregion
|
|
770
|
-
//#region src/clients/transport/base-transport.ts
|
|
771
762
|
/**
|
|
772
|
-
*
|
|
763
|
+
* Stream a file from the sandbox with automatic base64 decoding for binary files
|
|
764
|
+
*
|
|
765
|
+
* @param stream - The ReadableStream from readFileStream()
|
|
766
|
+
* @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary)
|
|
767
|
+
*
|
|
768
|
+
* @example
|
|
769
|
+
* ```ts
|
|
770
|
+
* const stream = await sandbox.readFileStream('/path/to/file.png');
|
|
771
|
+
* for await (const chunk of streamFile(stream)) {
|
|
772
|
+
* if (chunk instanceof Uint8Array) {
|
|
773
|
+
* // Binary chunk
|
|
774
|
+
* console.log('Binary chunk:', chunk.length, 'bytes');
|
|
775
|
+
* } else {
|
|
776
|
+
* // Text chunk
|
|
777
|
+
* console.log('Text chunk:', chunk);
|
|
778
|
+
* }
|
|
779
|
+
* }
|
|
780
|
+
* ```
|
|
773
781
|
*/
|
|
774
|
-
|
|
775
|
-
|
|
782
|
+
async function* streamFile(stream) {
|
|
783
|
+
let metadata = null;
|
|
784
|
+
for await (const event of parseSSE(stream)) switch (event.type) {
|
|
785
|
+
case "metadata":
|
|
786
|
+
metadata = {
|
|
787
|
+
mimeType: event.mimeType,
|
|
788
|
+
size: event.size,
|
|
789
|
+
isBinary: event.isBinary,
|
|
790
|
+
encoding: event.encoding
|
|
791
|
+
};
|
|
792
|
+
break;
|
|
793
|
+
case "chunk":
|
|
794
|
+
if (!metadata) throw new Error("Received chunk before metadata");
|
|
795
|
+
if (metadata.isBinary && metadata.encoding === "base64") {
|
|
796
|
+
const binaryString = atob(event.data);
|
|
797
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
798
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
799
|
+
yield bytes;
|
|
800
|
+
} else yield event.data;
|
|
801
|
+
break;
|
|
802
|
+
case "complete":
|
|
803
|
+
if (!metadata) throw new Error("Stream completed without metadata");
|
|
804
|
+
return metadata;
|
|
805
|
+
case "error": throw new Error(`File streaming error: ${event.error}`);
|
|
806
|
+
}
|
|
807
|
+
throw new Error("Stream ended unexpectedly");
|
|
808
|
+
}
|
|
776
809
|
/**
|
|
777
|
-
*
|
|
810
|
+
* Collect an entire file into memory from a stream
|
|
811
|
+
*
|
|
812
|
+
* @param stream - The ReadableStream from readFileStream()
|
|
813
|
+
* @returns Object containing the file content and metadata
|
|
778
814
|
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
815
|
+
* @example
|
|
816
|
+
* ```ts
|
|
817
|
+
* const stream = await sandbox.readFileStream('/path/to/file.txt');
|
|
818
|
+
* const { content, metadata } = await collectFile(stream);
|
|
819
|
+
* console.log('Content:', content);
|
|
820
|
+
* console.log('MIME type:', metadata.mimeType);
|
|
821
|
+
* ```
|
|
781
822
|
*/
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
this.retryTimeoutMs = config.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS$1;
|
|
790
|
-
}
|
|
791
|
-
setRetryTimeoutMs(ms) {
|
|
792
|
-
this.retryTimeoutMs = ms;
|
|
793
|
-
}
|
|
794
|
-
getRetryTimeoutMs() {
|
|
795
|
-
return this.retryTimeoutMs;
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Fetch with automatic retry for 503 (container starting)
|
|
799
|
-
*
|
|
800
|
-
* This is the primary entry point for making requests. It wraps the
|
|
801
|
-
* transport-specific doFetch() with retry logic for container startup.
|
|
802
|
-
*/
|
|
803
|
-
async fetch(path$1, options) {
|
|
804
|
-
return fetchWithResponseRetry(() => this.doFetch(path$1, options), {
|
|
805
|
-
retryTimeoutMs: this.retryTimeoutMs,
|
|
806
|
-
minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS$1,
|
|
807
|
-
logger: this.logger,
|
|
808
|
-
retryLogMessage: "Container not ready, retrying",
|
|
809
|
-
shouldRetry: (response) => response.status === 503,
|
|
810
|
-
getRetryLogContext: () => ({ mode: this.getMode() }),
|
|
811
|
-
onRetryExhausted: ({ attempts, elapsedMs }) => {
|
|
812
|
-
this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempts} attempts over ${Math.floor(elapsedMs / 1e3)}s`));
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* Build a URL targeting the container's HTTP server.
|
|
818
|
-
*/
|
|
819
|
-
buildContainerUrl(path$1) {
|
|
820
|
-
if (this.config.stub) return `http://localhost:${this.config.port || 3e3}${path$1}`;
|
|
821
|
-
return `${this.config.baseUrl ?? `http://localhost:${this.config.port || 3e3}`}${path$1}`;
|
|
822
|
-
}
|
|
823
|
-
/**
|
|
824
|
-
* Single HTTP request to the container — no WebSocket, no 503 retry.
|
|
825
|
-
*/
|
|
826
|
-
httpFetch(path$1, options) {
|
|
827
|
-
const url = this.buildContainerUrl(path$1);
|
|
828
|
-
if (this.config.stub) return this.config.stub.containerFetch(url, options || {}, this.config.port);
|
|
829
|
-
return globalThis.fetch(url, options);
|
|
823
|
+
async function collectFile(stream) {
|
|
824
|
+
const chunks = [];
|
|
825
|
+
const generator = streamFile(stream);
|
|
826
|
+
let result = await generator.next();
|
|
827
|
+
while (!result.done) {
|
|
828
|
+
chunks.push(result.value);
|
|
829
|
+
result = await generator.next();
|
|
830
830
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
"Content-Type": "application/json"
|
|
841
|
-
} : headers,
|
|
842
|
-
body: body && method === "POST" ? JSON.stringify(body) : void 0
|
|
843
|
-
};
|
|
844
|
-
let response;
|
|
845
|
-
if (this.config.stub) response = await this.config.stub.containerFetch(url, init, this.config.port);
|
|
846
|
-
else response = await globalThis.fetch(url, init);
|
|
847
|
-
if (!response.ok) {
|
|
848
|
-
const errorBody = await response.text();
|
|
849
|
-
throw new Error(`HTTP error! status: ${response.status} - ${errorBody}`);
|
|
831
|
+
const metadata = result.value;
|
|
832
|
+
if (!metadata) throw new Error("Failed to get file metadata");
|
|
833
|
+
if (metadata.isBinary) {
|
|
834
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), 0);
|
|
835
|
+
const combined = new Uint8Array(totalLength);
|
|
836
|
+
let offset = 0;
|
|
837
|
+
for (const chunk of chunks) if (chunk instanceof Uint8Array) {
|
|
838
|
+
combined.set(chunk, offset);
|
|
839
|
+
offset += chunk.length;
|
|
850
840
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
};
|
|
841
|
+
return {
|
|
842
|
+
content: combined,
|
|
843
|
+
metadata
|
|
844
|
+
};
|
|
845
|
+
} else return {
|
|
846
|
+
content: chunks.filter((c) => typeof c === "string").join(""),
|
|
847
|
+
metadata
|
|
848
|
+
};
|
|
849
|
+
}
|
|
855
850
|
|
|
856
851
|
//#endregion
|
|
857
|
-
//#region src/
|
|
852
|
+
//#region src/security.ts
|
|
858
853
|
/**
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
* Uses standard fetch API for communication with the container.
|
|
862
|
-
* HTTP is stateless, so connect/disconnect are no-ops.
|
|
854
|
+
* Security utilities for URL construction and input validation
|
|
863
855
|
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
856
|
+
* This module contains critical security functions to prevent:
|
|
857
|
+
* - URL injection attacks
|
|
858
|
+
* - SSRF (Server-Side Request Forgery) attacks
|
|
859
|
+
* - DNS rebinding attacks
|
|
860
|
+
* - Host header injection
|
|
861
|
+
* - Open redirect vulnerabilities
|
|
867
862
|
*/
|
|
868
|
-
var
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
disconnect() {}
|
|
874
|
-
isConnected() {
|
|
875
|
-
return true;
|
|
876
|
-
}
|
|
877
|
-
async doFetch(path$1, options) {
|
|
878
|
-
return this.httpFetch(path$1, options);
|
|
879
|
-
}
|
|
880
|
-
async fetchStream(path$1, body, method = "POST", headers) {
|
|
881
|
-
return this.httpFetchStream(path$1, body, method, headers);
|
|
863
|
+
var SandboxSecurityError = class extends Error {
|
|
864
|
+
constructor(message, code) {
|
|
865
|
+
super(message);
|
|
866
|
+
this.code = code;
|
|
867
|
+
this.name = "SandboxSecurityError";
|
|
882
868
|
}
|
|
883
869
|
};
|
|
884
|
-
|
|
885
|
-
//#endregion
|
|
886
|
-
//#region src/clients/transport/ws-transport.ts
|
|
887
870
|
/**
|
|
888
|
-
*
|
|
871
|
+
* Validates port numbers for sandbox services.
|
|
872
|
+
*
|
|
873
|
+
* Rules:
|
|
874
|
+
* - Range: 1024-65535 (privileged ports require root, which containers don't have)
|
|
875
|
+
* - Reserved: 3000 (sandbox control plane)
|
|
889
876
|
*/
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
877
|
+
function validatePort(port) {
|
|
878
|
+
if (!Number.isInteger(port)) return false;
|
|
879
|
+
if (port < 1024 || port > 65535) return false;
|
|
880
|
+
if ([3e3].includes(port)) return false;
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
895
883
|
/**
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
* Multiplexes HTTP-like requests over a single WebSocket connection.
|
|
899
|
-
* Useful when running inside Workers/DO where sub-request limits apply.
|
|
884
|
+
* Sanitizes and validates sandbox IDs for DNS compliance and security
|
|
885
|
+
* Only enforces critical requirements - allows maximum developer flexibility
|
|
900
886
|
*/
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
*/
|
|
970
|
-
async doFetch(path$1, options) {
|
|
971
|
-
if (this.isWebSocketConnecting()) return this.httpFetch(path$1, options);
|
|
972
|
-
await this.connect();
|
|
973
|
-
const method = options?.method || "GET";
|
|
974
|
-
const body = this.parseBody(options?.body);
|
|
975
|
-
const headers = this.normalizeHeaders(options?.headers);
|
|
976
|
-
const result = await this.request(method, path$1, body, headers, options?.requestTimeoutMs);
|
|
977
|
-
return new Response(JSON.stringify(result.body), {
|
|
978
|
-
status: result.status,
|
|
979
|
-
headers: { "Content-Type": "application/json" }
|
|
980
|
-
});
|
|
981
|
-
}
|
|
982
|
-
/**
|
|
983
|
-
* Streaming fetch implementation.
|
|
984
|
-
*
|
|
985
|
-
* Delegates to `requestStream()`, which applies the re-entrancy guard.
|
|
986
|
-
*/
|
|
987
|
-
async fetchStream(path$1, body, method = "POST", headers) {
|
|
988
|
-
return this.requestStream(method, path$1, body, headers);
|
|
989
|
-
}
|
|
990
|
-
/**
|
|
991
|
-
* Parse request body from RequestInit
|
|
992
|
-
*/
|
|
993
|
-
parseBody(body) {
|
|
994
|
-
if (!body) return;
|
|
995
|
-
if (typeof body === "string") try {
|
|
996
|
-
return JSON.parse(body);
|
|
997
|
-
} catch (error) {
|
|
998
|
-
throw new Error(`Request body must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
999
|
-
}
|
|
1000
|
-
throw new Error(`WebSocket transport only supports string bodies. Got: ${typeof body}`);
|
|
1001
|
-
}
|
|
1002
|
-
/**
|
|
1003
|
-
* Normalize RequestInit headers into a plain object for WSRequest.
|
|
1004
|
-
*/
|
|
1005
|
-
normalizeHeaders(headers) {
|
|
1006
|
-
if (!headers) return;
|
|
1007
|
-
const normalized = {};
|
|
1008
|
-
new Headers(headers).forEach((value, key) => {
|
|
1009
|
-
normalized[key] = value;
|
|
1010
|
-
});
|
|
1011
|
-
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
887
|
+
function sanitizeSandboxId(id) {
|
|
888
|
+
if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
|
|
889
|
+
if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
|
|
890
|
+
const reservedNames = [
|
|
891
|
+
"www",
|
|
892
|
+
"api",
|
|
893
|
+
"admin",
|
|
894
|
+
"root",
|
|
895
|
+
"system",
|
|
896
|
+
"cloudflare",
|
|
897
|
+
"workers"
|
|
898
|
+
];
|
|
899
|
+
const lowerCaseId = id.toLowerCase();
|
|
900
|
+
if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
|
|
901
|
+
return id;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Validates language for code interpreter
|
|
905
|
+
* Only allows supported languages
|
|
906
|
+
*/
|
|
907
|
+
function validateLanguage(language) {
|
|
908
|
+
if (!language) return;
|
|
909
|
+
const supportedLanguages = [
|
|
910
|
+
"python",
|
|
911
|
+
"python3",
|
|
912
|
+
"javascript",
|
|
913
|
+
"js",
|
|
914
|
+
"node",
|
|
915
|
+
"typescript",
|
|
916
|
+
"ts"
|
|
917
|
+
];
|
|
918
|
+
const normalized = language.toLowerCase();
|
|
919
|
+
if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Validates a single DNS label for use as a Cloudflare Tunnel hostname.
|
|
923
|
+
*
|
|
924
|
+
* Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
|
|
925
|
+
* input client-side before any network call. Whether the chosen label is
|
|
926
|
+
* actually available under the configured zone is left to the Cloudflare
|
|
927
|
+
* API (returned as a typed error).
|
|
928
|
+
*
|
|
929
|
+
* Rules:
|
|
930
|
+
* - 1–63 characters
|
|
931
|
+
* - Lowercase letters, digits, and internal hyphens only
|
|
932
|
+
* - No leading or trailing hyphen
|
|
933
|
+
* - No dots — multi-label hostnames need a delegated subdomain zone or
|
|
934
|
+
* Advanced Certificate Manager, which are out of scope for this
|
|
935
|
+
* feature. Universal SSL only covers `<label>.<zone>`.
|
|
936
|
+
*
|
|
937
|
+
* Throws `SandboxSecurityError` on any violation. Designed to be called
|
|
938
|
+
* before any other tunnel work so callers see a fast, deterministic
|
|
939
|
+
* failure.
|
|
940
|
+
*/
|
|
941
|
+
const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
942
|
+
function validateTunnelName(name) {
|
|
943
|
+
if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
|
|
944
|
+
if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
|
|
945
|
+
if (!TUNNEL_NAME_REGEX.test(name)) throw new SandboxSecurityError(`Tunnel name '${name}' is not a valid DNS label. Use lowercase letters, digits, and internal hyphens only (no dots, no leading/trailing hyphens).`, "INVALID_TUNNEL_NAME_FORMAT");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/interpreter.ts
|
|
950
|
+
var CodeInterpreter = class {
|
|
951
|
+
getInterpreterClient;
|
|
952
|
+
contexts = /* @__PURE__ */ new Map();
|
|
953
|
+
constructor(interpreterClient) {
|
|
954
|
+
this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
|
|
1012
955
|
}
|
|
1013
956
|
/**
|
|
1014
|
-
*
|
|
957
|
+
* Create a new code execution context
|
|
1015
958
|
*/
|
|
1016
|
-
async
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
async fetchUpgradeWithRetry(attemptUpgrade) {
|
|
1022
|
-
return fetchWithResponseRetry(attemptUpgrade, {
|
|
1023
|
-
retryTimeoutMs: this.getRetryTimeoutMs(),
|
|
1024
|
-
minTimeForRetryMs: MIN_TIME_FOR_CONNECT_RETRY_MS,
|
|
1025
|
-
logger: this.logger,
|
|
1026
|
-
retryLogMessage: "WebSocket upgrade returned retryable status, retrying",
|
|
1027
|
-
shouldRetry: isRetryableWebSocketUpgradeResponse
|
|
1028
|
-
});
|
|
959
|
+
async createCodeContext(options = {}) {
|
|
960
|
+
validateLanguage(options.language);
|
|
961
|
+
const context = await this.getInterpreterClient().createCodeContext(options);
|
|
962
|
+
this.contexts.set(context.id, context);
|
|
963
|
+
return context;
|
|
1029
964
|
}
|
|
1030
965
|
/**
|
|
1031
|
-
*
|
|
1032
|
-
* This is required when running inside a Durable Object.
|
|
1033
|
-
*
|
|
1034
|
-
* Uses stub.fetch() which routes WebSocket upgrade requests through the
|
|
1035
|
-
* parent Container class that supports the WebSocket protocol.
|
|
966
|
+
* Run code with optional context
|
|
1036
967
|
*/
|
|
1037
|
-
async
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
const response = await this.fetchUpgradeWithRetry(() => this.fetchUpgradeAttempt(httpUrl, timeoutMs));
|
|
1043
|
-
if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
|
|
1044
|
-
const ws = response.webSocket;
|
|
1045
|
-
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
1046
|
-
ws.accept();
|
|
1047
|
-
this.ws = ws;
|
|
1048
|
-
this.state = "connected";
|
|
1049
|
-
this.ws.addEventListener("close", this.boundHandleClose);
|
|
1050
|
-
this.ws.addEventListener("message", this.boundHandleMessage);
|
|
1051
|
-
this.logger.debug("WebSocket connected via fetch", { url: this.config.wsUrl });
|
|
1052
|
-
} catch (error) {
|
|
1053
|
-
this.state = "error";
|
|
1054
|
-
this.logger.error("WebSocket fetch connection failed", error instanceof Error ? error : new Error(String(error)));
|
|
1055
|
-
throw error;
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
async fetchUpgradeAttempt(httpUrl, timeoutMs) {
|
|
1059
|
-
const controller = new AbortController();
|
|
1060
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1061
|
-
try {
|
|
1062
|
-
const request = new Request(httpUrl, {
|
|
1063
|
-
headers: {
|
|
1064
|
-
Upgrade: "websocket",
|
|
1065
|
-
Connection: "Upgrade"
|
|
1066
|
-
},
|
|
1067
|
-
signal: controller.signal
|
|
1068
|
-
});
|
|
1069
|
-
return await this.config.stub.fetch(request);
|
|
1070
|
-
} finally {
|
|
1071
|
-
clearTimeout(timeout);
|
|
968
|
+
async runCode(code, options = {}) {
|
|
969
|
+
let context = options.context;
|
|
970
|
+
if (!context) {
|
|
971
|
+
const language = options.language || "python";
|
|
972
|
+
context = await this.getOrCreateDefaultContext(language);
|
|
1072
973
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
this.state = "connected";
|
|
1091
|
-
this.logger.debug("WebSocket connected", { url: this.config.wsUrl });
|
|
1092
|
-
resolve();
|
|
1093
|
-
};
|
|
1094
|
-
const onConnectError = () => {
|
|
1095
|
-
clearTimeout(timeout);
|
|
1096
|
-
this.ws?.removeEventListener("open", onOpen);
|
|
1097
|
-
this.ws?.removeEventListener("error", onConnectError);
|
|
1098
|
-
this.state = "error";
|
|
1099
|
-
this.logger.error("WebSocket error", /* @__PURE__ */ new Error("WebSocket connection failed"));
|
|
1100
|
-
reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
|
|
1101
|
-
};
|
|
1102
|
-
this.ws.addEventListener("open", onOpen);
|
|
1103
|
-
this.ws.addEventListener("error", onConnectError);
|
|
1104
|
-
this.ws.addEventListener("close", this.boundHandleClose);
|
|
1105
|
-
this.ws.addEventListener("message", this.boundHandleMessage);
|
|
1106
|
-
} catch (error) {
|
|
1107
|
-
clearTimeout(timeout);
|
|
1108
|
-
this.state = "error";
|
|
1109
|
-
reject(error);
|
|
1110
|
-
}
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
/**
|
|
1114
|
-
* Send a request and wait for response.
|
|
1115
|
-
*
|
|
1116
|
-
* Only reachable from `doFetch()`, which already applies the re-entrancy
|
|
1117
|
-
* guard via `isWebSocketConnecting()`. The `connect()` call here handles
|
|
1118
|
-
* the case where the WebSocket was closed between `doFetch` and `request`
|
|
1119
|
-
* (idle disconnect).
|
|
1120
|
-
*/
|
|
1121
|
-
async request(method, path$1, body, headers, requestTimeoutMs) {
|
|
1122
|
-
await this.connect();
|
|
1123
|
-
this.clearIdleDisconnectTimer();
|
|
1124
|
-
const id = generateRequestId();
|
|
1125
|
-
const request = {
|
|
1126
|
-
type: "request",
|
|
1127
|
-
id,
|
|
1128
|
-
method,
|
|
1129
|
-
path: path$1,
|
|
1130
|
-
body,
|
|
1131
|
-
headers
|
|
1132
|
-
};
|
|
1133
|
-
return new Promise((resolve, reject) => {
|
|
1134
|
-
const timeoutMs = requestTimeoutMs ?? this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1135
|
-
const timeoutId = setTimeout(() => {
|
|
1136
|
-
this.pendingRequests.delete(id);
|
|
1137
|
-
this.scheduleIdleDisconnect();
|
|
1138
|
-
reject(/* @__PURE__ */ new Error(`Request timeout after ${timeoutMs}ms: ${method} ${path$1}`));
|
|
1139
|
-
}, timeoutMs);
|
|
1140
|
-
this.pendingRequests.set(id, {
|
|
1141
|
-
resolve: (response) => {
|
|
1142
|
-
clearTimeout(timeoutId);
|
|
1143
|
-
this.pendingRequests.delete(id);
|
|
1144
|
-
this.scheduleIdleDisconnect();
|
|
1145
|
-
resolve({
|
|
1146
|
-
status: response.status,
|
|
1147
|
-
body: response.body
|
|
1148
|
-
});
|
|
1149
|
-
},
|
|
1150
|
-
reject: (error) => {
|
|
1151
|
-
clearTimeout(timeoutId);
|
|
1152
|
-
this.pendingRequests.delete(id);
|
|
1153
|
-
this.scheduleIdleDisconnect();
|
|
1154
|
-
reject(error);
|
|
1155
|
-
},
|
|
1156
|
-
isStreaming: false,
|
|
1157
|
-
timeoutId
|
|
1158
|
-
});
|
|
1159
|
-
try {
|
|
1160
|
-
this.send(request);
|
|
1161
|
-
} catch (error) {
|
|
1162
|
-
clearTimeout(timeoutId);
|
|
1163
|
-
this.pendingRequests.delete(id);
|
|
1164
|
-
this.scheduleIdleDisconnect();
|
|
1165
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1166
|
-
}
|
|
1167
|
-
});
|
|
1168
|
-
}
|
|
1169
|
-
/**
|
|
1170
|
-
* Send a streaming request and return a ReadableStream.
|
|
1171
|
-
*
|
|
1172
|
-
* The stream will receive data chunks as they arrive over the WebSocket.
|
|
1173
|
-
* Format matches SSE for compatibility with existing streaming code.
|
|
1174
|
-
*
|
|
1175
|
-
* This method waits for the first message before returning. If the server
|
|
1176
|
-
* responds with an error (non-streaming response), it throws immediately
|
|
1177
|
-
* rather than returning a stream that will error later.
|
|
1178
|
-
*
|
|
1179
|
-
* Uses an inactivity timeout instead of a total-duration timeout so that
|
|
1180
|
-
* long-running streams (e.g. execStream from an agent) stay alive as long
|
|
1181
|
-
* as data is flowing. The timer resets on every chunk or response message.
|
|
1182
|
-
*
|
|
1183
|
-
* Falls back to HTTP while a WebSocket connection is being established
|
|
1184
|
-
* to avoid the re-entrant deadlock described in `isWebSocketConnecting()`.
|
|
1185
|
-
*/
|
|
1186
|
-
async requestStream(method, path$1, body, headers) {
|
|
1187
|
-
if (this.isWebSocketConnecting()) return this.httpFetchStream(path$1, body, method, headers);
|
|
1188
|
-
await this.connect();
|
|
1189
|
-
this.clearIdleDisconnectTimer();
|
|
1190
|
-
const id = generateRequestId();
|
|
1191
|
-
const request = {
|
|
1192
|
-
type: "request",
|
|
1193
|
-
id,
|
|
1194
|
-
method,
|
|
1195
|
-
path: path$1,
|
|
1196
|
-
body,
|
|
1197
|
-
headers
|
|
1198
|
-
};
|
|
1199
|
-
const idleTimeoutMs = this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
1200
|
-
return new Promise((resolveStream, rejectStream) => {
|
|
1201
|
-
let streamController;
|
|
1202
|
-
let firstMessageReceived = false;
|
|
1203
|
-
const createIdleTimeout = () => {
|
|
1204
|
-
return setTimeout(() => {
|
|
1205
|
-
this.pendingRequests.delete(id);
|
|
1206
|
-
this.scheduleIdleDisconnect();
|
|
1207
|
-
const error = /* @__PURE__ */ new Error(`Stream idle timeout after ${idleTimeoutMs}ms: ${method} ${path$1}`);
|
|
1208
|
-
if (firstMessageReceived) try {
|
|
1209
|
-
streamController?.error(error);
|
|
1210
|
-
} catch {}
|
|
1211
|
-
else rejectStream(error);
|
|
1212
|
-
}, idleTimeoutMs);
|
|
1213
|
-
};
|
|
1214
|
-
const timeoutId = createIdleTimeout();
|
|
1215
|
-
const stream = new ReadableStream({
|
|
1216
|
-
start: (controller) => {
|
|
1217
|
-
streamController = controller;
|
|
1218
|
-
},
|
|
1219
|
-
cancel: () => {
|
|
1220
|
-
const pending = this.pendingRequests.get(id);
|
|
1221
|
-
if (pending?.timeoutId) clearTimeout(pending.timeoutId);
|
|
1222
|
-
try {
|
|
1223
|
-
this.send({
|
|
1224
|
-
type: "cancel",
|
|
1225
|
-
id
|
|
1226
|
-
});
|
|
1227
|
-
} catch (error) {
|
|
1228
|
-
this.logger.debug("Failed to send stream cancel message", {
|
|
1229
|
-
id,
|
|
1230
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1231
|
-
});
|
|
1232
|
-
}
|
|
1233
|
-
this.pendingRequests.delete(id);
|
|
1234
|
-
this.scheduleIdleDisconnect();
|
|
1235
|
-
}
|
|
1236
|
-
});
|
|
1237
|
-
this.pendingRequests.set(id, {
|
|
1238
|
-
resolve: (response) => {
|
|
1239
|
-
const pending = this.pendingRequests.get(id);
|
|
1240
|
-
if (pending?.timeoutId) clearTimeout(pending.timeoutId);
|
|
1241
|
-
this.pendingRequests.delete(id);
|
|
1242
|
-
this.scheduleIdleDisconnect();
|
|
1243
|
-
if (!firstMessageReceived) {
|
|
1244
|
-
firstMessageReceived = true;
|
|
1245
|
-
if (response.status >= 400) rejectStream(/* @__PURE__ */ new Error(`Stream error: ${response.status} - ${JSON.stringify(response.body)}`));
|
|
1246
|
-
else {
|
|
1247
|
-
streamController?.close();
|
|
1248
|
-
resolveStream(stream);
|
|
1249
|
-
}
|
|
1250
|
-
} else if (response.status >= 400) try {
|
|
1251
|
-
streamController?.error(/* @__PURE__ */ new Error(`Stream error: ${response.status} - ${JSON.stringify(response.body)}`));
|
|
1252
|
-
} catch {}
|
|
1253
|
-
else streamController?.close();
|
|
1254
|
-
},
|
|
1255
|
-
reject: (error) => {
|
|
1256
|
-
const pending = this.pendingRequests.get(id);
|
|
1257
|
-
if (pending?.timeoutId) clearTimeout(pending.timeoutId);
|
|
1258
|
-
this.pendingRequests.delete(id);
|
|
1259
|
-
this.scheduleIdleDisconnect();
|
|
1260
|
-
if (firstMessageReceived) try {
|
|
1261
|
-
streamController?.error(error);
|
|
1262
|
-
} catch {}
|
|
1263
|
-
else rejectStream(error);
|
|
1264
|
-
},
|
|
1265
|
-
streamController: void 0,
|
|
1266
|
-
isStreaming: true,
|
|
1267
|
-
timeoutId,
|
|
1268
|
-
onFirstChunk: () => {
|
|
1269
|
-
if (!firstMessageReceived) {
|
|
1270
|
-
firstMessageReceived = true;
|
|
1271
|
-
const pending = this.pendingRequests.get(id);
|
|
1272
|
-
if (pending) {
|
|
1273
|
-
pending.streamController = streamController;
|
|
1274
|
-
if (pending.bufferedChunks) {
|
|
1275
|
-
try {
|
|
1276
|
-
for (const buffered of pending.bufferedChunks) streamController.enqueue(buffered);
|
|
1277
|
-
} catch (error) {
|
|
1278
|
-
this.logger.debug("Failed to flush buffered chunks, cleaning up", {
|
|
1279
|
-
id,
|
|
1280
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1281
|
-
});
|
|
1282
|
-
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
1283
|
-
this.pendingRequests.delete(id);
|
|
1284
|
-
this.scheduleIdleDisconnect();
|
|
1285
|
-
}
|
|
1286
|
-
pending.bufferedChunks = void 0;
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
resolveStream(stream);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
});
|
|
1293
|
-
try {
|
|
1294
|
-
this.send(request);
|
|
1295
|
-
} catch (error) {
|
|
1296
|
-
clearTimeout(timeoutId);
|
|
1297
|
-
this.pendingRequests.delete(id);
|
|
1298
|
-
this.scheduleIdleDisconnect();
|
|
1299
|
-
rejectStream(error instanceof Error ? error : new Error(String(error)));
|
|
974
|
+
const execution = new Execution(code, context);
|
|
975
|
+
await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
|
|
976
|
+
onStdout: (output) => {
|
|
977
|
+
execution.logs.stdout.push(output.text);
|
|
978
|
+
if (options.onStdout) return options.onStdout(output);
|
|
979
|
+
},
|
|
980
|
+
onStderr: (output) => {
|
|
981
|
+
execution.logs.stderr.push(output.text);
|
|
982
|
+
if (options.onStderr) return options.onStderr(output);
|
|
983
|
+
},
|
|
984
|
+
onResult: async (result) => {
|
|
985
|
+
execution.results.push(new ResultImpl(result));
|
|
986
|
+
if (options.onResult) return options.onResult(result);
|
|
987
|
+
},
|
|
988
|
+
onError: (error) => {
|
|
989
|
+
execution.error = error;
|
|
990
|
+
if (options.onError) return options.onError(error);
|
|
1300
991
|
}
|
|
1301
992
|
});
|
|
993
|
+
return execution;
|
|
1302
994
|
}
|
|
1303
995
|
/**
|
|
1304
|
-
*
|
|
1305
|
-
*/
|
|
1306
|
-
send(message) {
|
|
1307
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error("WebSocket not connected");
|
|
1308
|
-
this.ws.send(JSON.stringify(message));
|
|
1309
|
-
this.logger.debug("WebSocket sent", {
|
|
1310
|
-
id: message.id,
|
|
1311
|
-
type: message.type,
|
|
1312
|
-
method: message.type === "request" ? message.method : void 0,
|
|
1313
|
-
path: message.type === "request" ? message.path : void 0
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1316
|
-
/**
|
|
1317
|
-
* Handle incoming WebSocket messages
|
|
1318
|
-
*/
|
|
1319
|
-
handleMessage(event) {
|
|
1320
|
-
try {
|
|
1321
|
-
const message = JSON.parse(event.data);
|
|
1322
|
-
if (isWSResponse(message)) this.handleResponse(message);
|
|
1323
|
-
else if (isWSStreamChunk(message)) this.handleStreamChunk(message);
|
|
1324
|
-
else if (isWSError(message)) this.handleError(message);
|
|
1325
|
-
else this.logger.warn("Unknown WebSocket message type", { message });
|
|
1326
|
-
} catch (error) {
|
|
1327
|
-
this.logger.error("Failed to parse WebSocket message", error instanceof Error ? error : new Error(String(error)));
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
/**
|
|
1331
|
-
* Handle a response message
|
|
1332
|
-
*/
|
|
1333
|
-
handleResponse(response) {
|
|
1334
|
-
const pending = this.pendingRequests.get(response.id);
|
|
1335
|
-
if (!pending) {
|
|
1336
|
-
this.logger.warn("Received response for unknown request", { id: response.id });
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
this.logger.debug("WebSocket response", {
|
|
1340
|
-
id: response.id,
|
|
1341
|
-
status: response.status,
|
|
1342
|
-
done: response.done
|
|
1343
|
-
});
|
|
1344
|
-
if (response.done) pending.resolve(response);
|
|
1345
|
-
}
|
|
1346
|
-
/**
|
|
1347
|
-
* Handle a stream chunk message
|
|
1348
|
-
*
|
|
1349
|
-
* Resets the idle timeout on every chunk so that long-running streams
|
|
1350
|
-
* with continuous output are not killed by the inactivity timer.
|
|
1351
|
-
*/
|
|
1352
|
-
handleStreamChunk(chunk) {
|
|
1353
|
-
const pending = this.pendingRequests.get(chunk.id);
|
|
1354
|
-
if (!pending) {
|
|
1355
|
-
this.logger.warn("Received stream chunk for unknown request", { id: chunk.id });
|
|
1356
|
-
return;
|
|
1357
|
-
}
|
|
1358
|
-
if (pending.onFirstChunk) {
|
|
1359
|
-
pending.onFirstChunk();
|
|
1360
|
-
pending.onFirstChunk = void 0;
|
|
1361
|
-
}
|
|
1362
|
-
if (pending.isStreaming) this.resetStreamIdleTimeout(chunk.id, pending);
|
|
1363
|
-
if (!pending.streamController) {
|
|
1364
|
-
if (!pending.bufferedChunks) pending.bufferedChunks = [];
|
|
1365
|
-
const encoder$1 = new TextEncoder();
|
|
1366
|
-
let sseData$1;
|
|
1367
|
-
if (chunk.event) sseData$1 = `event: ${chunk.event}\ndata: ${chunk.data}\n\n`;
|
|
1368
|
-
else sseData$1 = `data: ${chunk.data}\n\n`;
|
|
1369
|
-
pending.bufferedChunks.push(encoder$1.encode(sseData$1));
|
|
1370
|
-
return;
|
|
1371
|
-
}
|
|
1372
|
-
const encoder = new TextEncoder();
|
|
1373
|
-
let sseData;
|
|
1374
|
-
if (chunk.event) sseData = `event: ${chunk.event}\ndata: ${chunk.data}\n\n`;
|
|
1375
|
-
else sseData = `data: ${chunk.data}\n\n`;
|
|
1376
|
-
try {
|
|
1377
|
-
pending.streamController.enqueue(encoder.encode(sseData));
|
|
1378
|
-
} catch (error) {
|
|
1379
|
-
this.logger.debug("Failed to enqueue stream chunk, cleaning up", {
|
|
1380
|
-
id: chunk.id,
|
|
1381
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1382
|
-
});
|
|
1383
|
-
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
1384
|
-
this.pendingRequests.delete(chunk.id);
|
|
1385
|
-
this.scheduleIdleDisconnect();
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
/**
|
|
1389
|
-
* Reset the idle timeout for a streaming request.
|
|
1390
|
-
* Called on every incoming chunk to keep the stream alive while data flows.
|
|
1391
|
-
*/
|
|
1392
|
-
resetStreamIdleTimeout(id, pending) {
|
|
1393
|
-
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
1394
|
-
const idleTimeoutMs = this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
1395
|
-
pending.timeoutId = setTimeout(() => {
|
|
1396
|
-
this.pendingRequests.delete(id);
|
|
1397
|
-
this.scheduleIdleDisconnect();
|
|
1398
|
-
if (pending.streamController) try {
|
|
1399
|
-
pending.streamController.error(/* @__PURE__ */ new Error(`Stream idle timeout after ${idleTimeoutMs}ms`));
|
|
1400
|
-
} catch {}
|
|
1401
|
-
}, idleTimeoutMs);
|
|
1402
|
-
}
|
|
1403
|
-
/**
|
|
1404
|
-
* Handle an error message
|
|
996
|
+
* Run code and return a streaming response
|
|
1405
997
|
*/
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
998
|
+
async runCodeStream(code, options = {}) {
|
|
999
|
+
let context = options.context;
|
|
1000
|
+
if (!context) {
|
|
1001
|
+
const language = options.language || "python";
|
|
1002
|
+
context = await this.getOrCreateDefaultContext(language);
|
|
1413
1003
|
}
|
|
1414
|
-
this.
|
|
1415
|
-
code: error.code,
|
|
1416
|
-
status: error.status
|
|
1417
|
-
});
|
|
1004
|
+
return this.getInterpreterClient().streamCode(context.id, code, options.language);
|
|
1418
1005
|
}
|
|
1419
1006
|
/**
|
|
1420
|
-
*
|
|
1007
|
+
* List all code contexts
|
|
1421
1008
|
*/
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
this.
|
|
1425
|
-
|
|
1426
|
-
const closeError = /* @__PURE__ */ new Error(`WebSocket closed: ${event.code} ${event.reason || "No reason"}`);
|
|
1427
|
-
for (const [, pending] of this.pendingRequests) {
|
|
1428
|
-
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
1429
|
-
if (pending.streamController) try {
|
|
1430
|
-
pending.streamController.error(closeError);
|
|
1431
|
-
} catch {}
|
|
1432
|
-
pending.reject(closeError);
|
|
1433
|
-
}
|
|
1434
|
-
this.pendingRequests.clear();
|
|
1009
|
+
async listCodeContexts() {
|
|
1010
|
+
const contexts = await this.getInterpreterClient().listCodeContexts();
|
|
1011
|
+
for (const context of contexts) this.contexts.set(context.id, context);
|
|
1012
|
+
return contexts;
|
|
1435
1013
|
}
|
|
1436
1014
|
/**
|
|
1437
|
-
*
|
|
1015
|
+
* Delete a code context
|
|
1438
1016
|
*/
|
|
1439
|
-
|
|
1440
|
-
this.
|
|
1441
|
-
|
|
1442
|
-
this.ws.removeEventListener("close", this.boundHandleClose);
|
|
1443
|
-
this.ws.removeEventListener("message", this.boundHandleMessage);
|
|
1444
|
-
this.ws.close();
|
|
1445
|
-
this.ws = null;
|
|
1446
|
-
}
|
|
1447
|
-
this.state = "disconnected";
|
|
1448
|
-
this.connectPromise = null;
|
|
1449
|
-
for (const pending of this.pendingRequests.values()) if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
|
1450
|
-
this.pendingRequests.clear();
|
|
1451
|
-
}
|
|
1452
|
-
scheduleIdleDisconnect() {
|
|
1453
|
-
if (!this.isConnected() || this.pendingRequests.size > 0) return;
|
|
1454
|
-
this.clearIdleDisconnectTimer();
|
|
1455
|
-
this.idleDisconnectTimer = setTimeout(() => {
|
|
1456
|
-
this.idleDisconnectTimer = null;
|
|
1457
|
-
if (this.pendingRequests.size === 0 && this.isConnected()) {
|
|
1458
|
-
this.logger.debug("Disconnecting idle WebSocket transport");
|
|
1459
|
-
this.cleanup();
|
|
1460
|
-
}
|
|
1461
|
-
}, DEFAULT_IDLE_DISCONNECT_MS$1);
|
|
1017
|
+
async deleteCodeContext(contextId) {
|
|
1018
|
+
await this.getInterpreterClient().deleteCodeContext(contextId);
|
|
1019
|
+
this.contexts.delete(contextId);
|
|
1462
1020
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
this.idleDisconnectTimer = null;
|
|
1467
|
-
}
|
|
1021
|
+
async getOrCreateDefaultContext(language) {
|
|
1022
|
+
for (const context of this.contexts.values()) if (context.language === language) return context;
|
|
1023
|
+
return this.createCodeContext({ language });
|
|
1468
1024
|
}
|
|
1469
1025
|
};
|
|
1470
1026
|
|
|
1471
1027
|
//#endregion
|
|
1472
|
-
//#region src/
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1028
|
+
//#region src/pty/proxy.ts
|
|
1029
|
+
async function proxyTerminal(stub, sessionId, request, options) {
|
|
1030
|
+
if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
|
|
1031
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
|
|
1032
|
+
const params = new URLSearchParams({ sessionId });
|
|
1033
|
+
if (options?.cols) params.set("cols", String(options.cols));
|
|
1034
|
+
if (options?.rows) params.set("rows", String(options.rows));
|
|
1035
|
+
if (options?.shell) params.set("shell", options.shell);
|
|
1036
|
+
const ptyUrl = `http://localhost/ws/pty?${params}`;
|
|
1037
|
+
const ptyRequest = new Request(ptyUrl, request);
|
|
1038
|
+
return stub.fetch(switchPort(ptyRequest, 3e3));
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/preview-proxy-protocol.ts
|
|
1043
|
+
/** @internal */
|
|
1044
|
+
const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
|
|
1045
|
+
/** @internal */
|
|
1046
|
+
const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
|
|
1047
|
+
/** @internal */
|
|
1048
|
+
const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
|
|
1049
|
+
/** @internal */
|
|
1050
|
+
const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
|
|
1051
|
+
/** @internal */
|
|
1052
|
+
const PREVIEW_PROXY_HEADERS = [
|
|
1053
|
+
PREVIEW_PROXY_HEADER,
|
|
1054
|
+
PREVIEW_PROXY_PORT_HEADER,
|
|
1055
|
+
PREVIEW_PROXY_TOKEN_HEADER,
|
|
1056
|
+
PREVIEW_PROXY_SANDBOX_ID_HEADER
|
|
1057
|
+
];
|
|
1058
|
+
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region ../shared/src/backup.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* Absolute directory prefixes supported by backup and restore operations.
|
|
1063
|
+
*/
|
|
1064
|
+
const BACKUP_ALLOWED_PREFIXES = [
|
|
1065
|
+
"/workspace",
|
|
1066
|
+
"/home",
|
|
1067
|
+
"/tmp",
|
|
1068
|
+
"/var/tmp",
|
|
1069
|
+
"/app"
|
|
1070
|
+
];
|
|
1071
|
+
function normalizeBackupExcludePattern(pattern) {
|
|
1072
|
+
let normalized = pattern;
|
|
1073
|
+
while (normalized.startsWith("**/")) normalized = normalized.slice(3);
|
|
1074
|
+
while (normalized.includes("/**/")) normalized = normalized.replace(/\/\*\*\//g, "/");
|
|
1075
|
+
if (normalized.endsWith("/**")) normalized = normalized.slice(0, -3);
|
|
1076
|
+
if (!normalized || normalized === "**") return null;
|
|
1077
|
+
return normalized;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region ../shared/src/internal.ts
|
|
1082
|
+
const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
|
|
1083
|
+
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region src/response-retry.ts
|
|
1086
|
+
const DEFAULT_INITIAL_RETRY_DELAY_MS = 3e3;
|
|
1087
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
|
|
1088
|
+
const RETRYABLE_WEBSOCKET_UPGRADE_STATUSES = new Set([
|
|
1089
|
+
500,
|
|
1090
|
+
502,
|
|
1091
|
+
503,
|
|
1092
|
+
504
|
|
1093
|
+
]);
|
|
1094
|
+
function isRetryableWebSocketUpgradeResponse(response) {
|
|
1095
|
+
return RETRYABLE_WEBSOCKET_UPGRADE_STATUSES.has(response.status);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Retry Response-returning operations while their response remains retryable.
|
|
1099
|
+
* The retry budget covers the whole operation; each attempt owns any
|
|
1100
|
+
* per-request timeout inside the caller-provided `fetchResponse` function.
|
|
1493
1101
|
*/
|
|
1494
|
-
function
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1102
|
+
async function fetchWithResponseRetry(fetchResponse, options) {
|
|
1103
|
+
const startTime = Date.now();
|
|
1104
|
+
let attempt = 0;
|
|
1105
|
+
while (true) {
|
|
1106
|
+
const response = await fetchResponse();
|
|
1107
|
+
if (!options.shouldRetry(response)) return response;
|
|
1108
|
+
const elapsed = Date.now() - startTime;
|
|
1109
|
+
const remaining = options.retryTimeoutMs - elapsed;
|
|
1110
|
+
if (remaining <= options.minTimeForRetryMs) {
|
|
1111
|
+
options.onRetryExhausted?.({
|
|
1112
|
+
attempts: attempt + 1,
|
|
1113
|
+
elapsedMs: elapsed,
|
|
1114
|
+
response
|
|
1115
|
+
});
|
|
1116
|
+
return response;
|
|
1117
|
+
}
|
|
1118
|
+
const delay = Math.min(DEFAULT_INITIAL_RETRY_DELAY_MS * 2 ** attempt, DEFAULT_MAX_RETRY_DELAY_MS);
|
|
1119
|
+
options.logger.info(options.retryLogMessage, {
|
|
1120
|
+
status: response.status,
|
|
1121
|
+
attempt: attempt + 1,
|
|
1122
|
+
delayMs: delay,
|
|
1123
|
+
remainingSec: Math.floor(remaining / 1e3),
|
|
1124
|
+
...options.getRetryLogContext?.(response)
|
|
1125
|
+
});
|
|
1126
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1127
|
+
attempt++;
|
|
1498
1128
|
}
|
|
1499
1129
|
}
|
|
1500
1130
|
|
|
1501
1131
|
//#endregion
|
|
1502
|
-
//#region src/
|
|
1132
|
+
//#region src/container-control/connection.ts
|
|
1133
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
1134
|
+
const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
|
|
1135
|
+
const MIN_TIME_FOR_RETRY_MS = 15e3;
|
|
1136
|
+
function isErrorResponse(value) {
|
|
1137
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1138
|
+
const candidate = value;
|
|
1139
|
+
return typeof candidate.code === "string" && Object.values(ErrorCode).includes(candidate.code) && typeof candidate.message === "string" && typeof candidate.httpStatus === "number" && typeof candidate.timestamp === "string" && typeof candidate.context === "object" && candidate.context !== null;
|
|
1140
|
+
}
|
|
1503
1141
|
/**
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
* Requests go through the Transport abstraction layer, which handles:
|
|
1507
|
-
* - HTTP and WebSocket route-based modes transparently
|
|
1508
|
-
* - Automatic retry for 503 errors while the container is starting
|
|
1509
|
-
* - Streaming responses for the existing route API
|
|
1142
|
+
* Manages a capnweb WebSocket RPC session to the container.
|
|
1510
1143
|
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1144
|
+
* The RPC stub is created eagerly in the constructor using a deferred
|
|
1145
|
+
* transport. Calls made before `connect()` completes are queued in the
|
|
1146
|
+
* transport and flushed once the WebSocket is established.
|
|
1513
1147
|
*/
|
|
1514
|
-
var
|
|
1515
|
-
|
|
1516
|
-
|
|
1148
|
+
var ContainerControlConnection = class {
|
|
1149
|
+
stub;
|
|
1150
|
+
session;
|
|
1517
1151
|
transport;
|
|
1518
|
-
|
|
1519
|
-
|
|
1152
|
+
ws = null;
|
|
1153
|
+
connected = false;
|
|
1154
|
+
connectPromise = null;
|
|
1155
|
+
containerStub;
|
|
1156
|
+
port;
|
|
1157
|
+
logger;
|
|
1158
|
+
retryTimeoutMs;
|
|
1159
|
+
onClose;
|
|
1160
|
+
constructor(options) {
|
|
1161
|
+
this.containerStub = options.stub;
|
|
1162
|
+
this.port = options.port ?? 3e3;
|
|
1520
1163
|
this.logger = options.logger ?? createNoOpLogger();
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
logger: this.logger,
|
|
1527
|
-
stub: options.stub,
|
|
1528
|
-
port: options.port,
|
|
1529
|
-
retryTimeoutMs: options.retryTimeoutMs
|
|
1530
|
-
});
|
|
1164
|
+
this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
|
|
1165
|
+
this.onClose = options.onClose;
|
|
1166
|
+
this.transport = new DeferredTransport();
|
|
1167
|
+
this.session = new RpcSession(this.transport, options.localMain);
|
|
1168
|
+
this.stub = this.session.getRemoteMain();
|
|
1531
1169
|
}
|
|
1532
1170
|
/**
|
|
1533
|
-
*
|
|
1171
|
+
* Get the typed RPC stub.
|
|
1172
|
+
*
|
|
1173
|
+
* The stub is available immediately — calls made before connect()
|
|
1174
|
+
* completes are queued in the deferred transport and flushed once
|
|
1175
|
+
* the WebSocket is established.
|
|
1534
1176
|
*/
|
|
1535
|
-
|
|
1536
|
-
this.
|
|
1177
|
+
rpc() {
|
|
1178
|
+
if (!this.connected && !this.connectPromise) this.connect().catch(() => {});
|
|
1179
|
+
return this.stub;
|
|
1537
1180
|
}
|
|
1538
1181
|
/**
|
|
1539
|
-
*
|
|
1182
|
+
* Return capnweb session statistics. The `imports` and `exports` counts
|
|
1183
|
+
* reflect all in-flight RPC calls, streams, and peer-held references.
|
|
1184
|
+
* An idle session has imports <= 1 && exports <= 1 (the bootstrap stubs).
|
|
1540
1185
|
*/
|
|
1541
|
-
|
|
1542
|
-
return this.
|
|
1186
|
+
getStats() {
|
|
1187
|
+
return this.session.getStats();
|
|
1543
1188
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
async
|
|
1548
|
-
|
|
1549
|
-
if (
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1189
|
+
isConnected() {
|
|
1190
|
+
return this.connected;
|
|
1191
|
+
}
|
|
1192
|
+
async connect() {
|
|
1193
|
+
if (this.connected) return;
|
|
1194
|
+
if (this.connectPromise) return this.connectPromise;
|
|
1195
|
+
this.connectPromise = this.doConnect();
|
|
1196
|
+
try {
|
|
1197
|
+
await this.connectPromise;
|
|
1198
|
+
} finally {
|
|
1199
|
+
this.connectPromise = null;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
disconnect() {
|
|
1203
|
+
try {
|
|
1204
|
+
this.stub[Symbol.dispose]?.();
|
|
1205
|
+
} catch {}
|
|
1206
|
+
if (this.ws) {
|
|
1207
|
+
this.ws.removeEventListener("close", this.onWebSocketClose);
|
|
1208
|
+
this.ws.removeEventListener("error", this.onWebSocketError);
|
|
1209
|
+
try {
|
|
1210
|
+
this.ws.close();
|
|
1211
|
+
} catch {}
|
|
1212
|
+
this.ws = null;
|
|
1213
|
+
}
|
|
1214
|
+
this.connected = false;
|
|
1215
|
+
this.connectPromise = null;
|
|
1557
1216
|
}
|
|
1558
1217
|
/**
|
|
1559
|
-
*
|
|
1218
|
+
* Update the upgrade retry budget without recreating the connection. Takes
|
|
1219
|
+
* effect on the next `connect()`; an in-flight connect uses the value
|
|
1220
|
+
* captured at start.
|
|
1560
1221
|
*/
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
method: "POST",
|
|
1564
|
-
headers: { "Content-Type": "application/json" },
|
|
1565
|
-
body: JSON.stringify(data),
|
|
1566
|
-
...requestOptions
|
|
1567
|
-
});
|
|
1568
|
-
return await this.handleResponse(response, responseHandler);
|
|
1222
|
+
setRetryTimeoutMs(ms) {
|
|
1223
|
+
this.retryTimeoutMs = ms;
|
|
1569
1224
|
}
|
|
1570
1225
|
/**
|
|
1571
|
-
*
|
|
1226
|
+
* Run the owner-provided `onClose` callback exactly once per call,
|
|
1227
|
+
* swallowing any errors so a buggy listener can't keep the connection
|
|
1228
|
+
* object in a half-torn-down state.
|
|
1572
1229
|
*/
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1230
|
+
fireOnClose() {
|
|
1231
|
+
if (!this.onClose) return;
|
|
1232
|
+
try {
|
|
1233
|
+
this.onClose();
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
|
|
1236
|
+
}
|
|
1576
1237
|
}
|
|
1577
1238
|
/**
|
|
1578
|
-
*
|
|
1239
|
+
* WebSocket `close` listener. Defined as a bound arrow field so the
|
|
1240
|
+
* same reference can be passed to both `addEventListener` and
|
|
1241
|
+
* `removeEventListener` — a fresh anonymous lambda would silently
|
|
1242
|
+
* fail to unbind.
|
|
1579
1243
|
*/
|
|
1580
|
-
|
|
1581
|
-
const
|
|
1582
|
-
|
|
1583
|
-
|
|
1244
|
+
onWebSocketClose = () => {
|
|
1245
|
+
const wasConnected = this.connected;
|
|
1246
|
+
this.connected = false;
|
|
1247
|
+
this.ws = null;
|
|
1248
|
+
this.logger.debug("ContainerControlConnection WebSocket closed");
|
|
1249
|
+
if (wasConnected) this.fireOnClose();
|
|
1250
|
+
};
|
|
1584
1251
|
/**
|
|
1585
|
-
*
|
|
1252
|
+
* WebSocket `error` listener. Same field-form rationale as
|
|
1253
|
+
* {@link onWebSocketClose}.
|
|
1586
1254
|
*/
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1255
|
+
onWebSocketError = () => {
|
|
1256
|
+
const wasConnected = this.connected;
|
|
1257
|
+
this.connected = false;
|
|
1258
|
+
this.ws = null;
|
|
1259
|
+
if (wasConnected) this.fireOnClose();
|
|
1260
|
+
};
|
|
1261
|
+
async doConnect() {
|
|
1590
1262
|
try {
|
|
1591
|
-
|
|
1263
|
+
const response = await this.fetchUpgradeWithRetry();
|
|
1264
|
+
if (response.status !== 101) {
|
|
1265
|
+
const structuredError = await this.parseStructuredUpgradeError(response);
|
|
1266
|
+
if (structuredError) throw createErrorFromResponse(structuredError);
|
|
1267
|
+
if (isRetryableWebSocketUpgradeResponse(response)) throw createErrorFromResponse({
|
|
1268
|
+
code: ErrorCode.CONTAINER_UNAVAILABLE,
|
|
1269
|
+
message: "Container is starting. Please retry in a moment.",
|
|
1270
|
+
context: { reason: "startup" },
|
|
1271
|
+
httpStatus: 503,
|
|
1272
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1273
|
+
suggestion: "The container is not ready yet. Retry the operation in a moment."
|
|
1274
|
+
});
|
|
1275
|
+
throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
|
|
1276
|
+
}
|
|
1277
|
+
const ws = response.webSocket;
|
|
1278
|
+
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
1279
|
+
ws.accept();
|
|
1280
|
+
ws.addEventListener("close", this.onWebSocketClose);
|
|
1281
|
+
ws.addEventListener("error", this.onWebSocketError);
|
|
1282
|
+
this.ws = ws;
|
|
1283
|
+
this.transport.activate(ws);
|
|
1284
|
+
this.connected = true;
|
|
1285
|
+
this.logger.debug("ContainerControlConnection established", { port: this.port });
|
|
1592
1286
|
} catch (error) {
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1599
|
-
});
|
|
1287
|
+
this.connected = false;
|
|
1288
|
+
this.transport.abort(error);
|
|
1289
|
+
this.logger.error("ContainerControlConnection failed", error instanceof Error ? error : new Error(String(error)));
|
|
1290
|
+
this.fireOnClose();
|
|
1291
|
+
throw error;
|
|
1600
1292
|
}
|
|
1601
1293
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
async handleErrorResponse(response) {
|
|
1606
|
-
let errorData;
|
|
1294
|
+
async parseStructuredUpgradeError(response) {
|
|
1295
|
+
if (!(response.headers.get("Content-Type") ?? "").includes("application/json")) return null;
|
|
1296
|
+
let body;
|
|
1607
1297
|
try {
|
|
1608
|
-
|
|
1298
|
+
body = await response.clone().json();
|
|
1609
1299
|
} catch {
|
|
1610
|
-
|
|
1611
|
-
code: ErrorCode.INTERNAL_ERROR,
|
|
1612
|
-
message: `HTTP error! status: ${response.status}`,
|
|
1613
|
-
context: { statusText: response.statusText },
|
|
1614
|
-
httpStatus: response.status,
|
|
1615
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1616
|
-
};
|
|
1300
|
+
return null;
|
|
1617
1301
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
throw error;
|
|
1302
|
+
if (!isErrorResponse(body)) return null;
|
|
1303
|
+
return body;
|
|
1621
1304
|
}
|
|
1622
1305
|
/**
|
|
1623
|
-
*
|
|
1306
|
+
* Issue WebSocket upgrade fetches, retrying transient control-plane
|
|
1307
|
+
* unavailability responses until either the upgrade succeeds, a
|
|
1308
|
+
* non-retryable status is returned, or the retry budget runs out.
|
|
1624
1309
|
*/
|
|
1625
|
-
async
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1310
|
+
async fetchUpgradeWithRetry() {
|
|
1311
|
+
return fetchWithResponseRetry(() => this.fetchUpgradeAttempt(), {
|
|
1312
|
+
retryTimeoutMs: this.retryTimeoutMs,
|
|
1313
|
+
minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS,
|
|
1314
|
+
logger: this.logger,
|
|
1315
|
+
retryLogMessage: "ContainerControlConnection upgrade returned retryable status, retrying",
|
|
1316
|
+
shouldRetry: isRetryableWebSocketUpgradeResponse
|
|
1317
|
+
});
|
|
1629
1318
|
}
|
|
1630
1319
|
/**
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
*
|
|
1634
|
-
* For WebSocket mode, uses Transport's streaming support.
|
|
1635
|
-
*
|
|
1636
|
-
* @param path - The API path to call
|
|
1637
|
-
* @param body - Optional request body (for POST requests)
|
|
1638
|
-
* @param method - HTTP method (default: POST, use GET for process logs)
|
|
1320
|
+
* Single WebSocket-upgrade fetch attempt. Owns its own AbortController so
|
|
1321
|
+
* each retry gets a fresh per-attempt connect timeout independent of the
|
|
1322
|
+
* total retry budget.
|
|
1639
1323
|
*/
|
|
1640
|
-
async
|
|
1641
|
-
const
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1324
|
+
async fetchUpgradeAttempt() {
|
|
1325
|
+
const controller = new AbortController();
|
|
1326
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
|
|
1327
|
+
try {
|
|
1328
|
+
const url = `http://localhost:${this.port}/rpc`;
|
|
1329
|
+
const request = new Request(url, {
|
|
1330
|
+
headers: {
|
|
1331
|
+
Upgrade: "websocket",
|
|
1332
|
+
Connection: "Upgrade"
|
|
1333
|
+
},
|
|
1334
|
+
signal: controller.signal
|
|
1335
|
+
});
|
|
1336
|
+
return await this.containerStub.fetch(request);
|
|
1337
|
+
} finally {
|
|
1338
|
+
clearTimeout(timeout);
|
|
1339
|
+
}
|
|
1652
1340
|
}
|
|
1653
1341
|
};
|
|
1654
|
-
|
|
1655
|
-
//#endregion
|
|
1656
|
-
//#region src/clients/backup-client.ts
|
|
1657
1342
|
/**
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
* The container creates/extracts squashfs archives locally.
|
|
1662
|
-
* R2 upload/download is handled by the Sandbox DO, not by this client.
|
|
1343
|
+
* RPC transport that queues sends and blocks receives until a WebSocket
|
|
1344
|
+
* is provided via `activate()`. Allows the RPC stub to be created before
|
|
1345
|
+
* the connection is established — queued calls flush automatically.
|
|
1663
1346
|
*/
|
|
1664
|
-
var
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1347
|
+
var DeferredTransport = class {
|
|
1348
|
+
#ws = null;
|
|
1349
|
+
#sendQueue = [];
|
|
1350
|
+
#receiveQueue = [];
|
|
1351
|
+
#receiveResolver;
|
|
1352
|
+
#receiveRejecter;
|
|
1353
|
+
#error;
|
|
1354
|
+
activate(ws) {
|
|
1355
|
+
this.#ws = ws;
|
|
1356
|
+
ws.addEventListener("message", (event) => {
|
|
1357
|
+
if (this.#error) return;
|
|
1358
|
+
if (typeof event.data === "string") if (this.#receiveResolver) {
|
|
1359
|
+
this.#receiveResolver(event.data);
|
|
1360
|
+
this.#receiveResolver = void 0;
|
|
1361
|
+
this.#receiveRejecter = void 0;
|
|
1362
|
+
} else this.#receiveQueue.push(event.data);
|
|
1363
|
+
else this.#fail(/* @__PURE__ */ new TypeError("Received non-string message from WebSocket."));
|
|
1364
|
+
});
|
|
1365
|
+
ws.addEventListener("close", (event) => {
|
|
1366
|
+
this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
|
|
1367
|
+
});
|
|
1368
|
+
ws.addEventListener("error", () => {
|
|
1369
|
+
this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed."));
|
|
1370
|
+
});
|
|
1371
|
+
for (const msg of this.#sendQueue) ws.send(msg);
|
|
1372
|
+
this.#sendQueue = [];
|
|
1681
1373
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
* @param archivePath - Path to the archive file in the container
|
|
1686
|
-
* @param sessionId - Session context
|
|
1687
|
-
*/
|
|
1688
|
-
async restoreArchive(dir, archivePath, sessionId) {
|
|
1689
|
-
const data = {
|
|
1690
|
-
dir,
|
|
1691
|
-
archivePath,
|
|
1692
|
-
sessionId
|
|
1693
|
-
};
|
|
1694
|
-
return await this.post("/api/backup/restore", data);
|
|
1374
|
+
async send(message) {
|
|
1375
|
+
if (this.#ws) this.#ws.send(message);
|
|
1376
|
+
else this.#sendQueue.push(message);
|
|
1695
1377
|
}
|
|
1696
|
-
async
|
|
1697
|
-
return this.
|
|
1698
|
-
|
|
1699
|
-
|
|
1378
|
+
async receive() {
|
|
1379
|
+
if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
|
|
1380
|
+
if (this.#error) throw this.#error;
|
|
1381
|
+
return new Promise((resolve, reject) => {
|
|
1382
|
+
this.#receiveResolver = resolve;
|
|
1383
|
+
this.#receiveRejecter = reject;
|
|
1700
1384
|
});
|
|
1701
1385
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
* Client for command execution operations
|
|
1708
|
-
*/
|
|
1709
|
-
var CommandClient = class extends BaseHttpClient {
|
|
1710
|
-
/**
|
|
1711
|
-
* Execute a command and return the complete result
|
|
1712
|
-
* @param command - The command to execute
|
|
1713
|
-
* @param sessionId - The session ID for this command execution
|
|
1714
|
-
* @param timeoutMs - Optional timeout in milliseconds (unlimited by default)
|
|
1715
|
-
* @param env - Optional environment variables for this command
|
|
1716
|
-
* @param cwd - Optional working directory for this command
|
|
1717
|
-
*/
|
|
1718
|
-
async execute(command, sessionId, options) {
|
|
1719
|
-
try {
|
|
1720
|
-
const data = {
|
|
1721
|
-
command,
|
|
1722
|
-
sessionId,
|
|
1723
|
-
...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
|
|
1724
|
-
...options?.env !== void 0 && { env: options.env },
|
|
1725
|
-
...options?.cwd !== void 0 && { cwd: options.cwd },
|
|
1726
|
-
...options?.origin !== void 0 && { origin: options.origin }
|
|
1727
|
-
};
|
|
1728
|
-
const response = await this.post("/api/execute", data);
|
|
1729
|
-
this.options.onCommandComplete?.(response.success, response.exitCode, response.stdout, response.stderr, response.command);
|
|
1730
|
-
return response;
|
|
1731
|
-
} catch (error) {
|
|
1732
|
-
this.options.onError?.(error instanceof Error ? error.message : String(error), command);
|
|
1733
|
-
throw error;
|
|
1386
|
+
abort(reason) {
|
|
1387
|
+
this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
|
|
1388
|
+
if (this.#ws) {
|
|
1389
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
1390
|
+
this.#ws.close(3e3, message);
|
|
1734
1391
|
}
|
|
1735
1392
|
}
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
async executeStream(command, sessionId, options) {
|
|
1743
|
-
try {
|
|
1744
|
-
const data = {
|
|
1745
|
-
command,
|
|
1746
|
-
sessionId,
|
|
1747
|
-
...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
|
|
1748
|
-
...options?.env !== void 0 && { env: options.env },
|
|
1749
|
-
...options?.cwd !== void 0 && { cwd: options.cwd },
|
|
1750
|
-
...options?.origin !== void 0 && { origin: options.origin }
|
|
1751
|
-
};
|
|
1752
|
-
return await this.doStreamFetch("/api/execute/stream", data);
|
|
1753
|
-
} catch (error) {
|
|
1754
|
-
this.options.onError?.(error instanceof Error ? error.message : String(error), command);
|
|
1755
|
-
throw error;
|
|
1756
|
-
}
|
|
1393
|
+
#fail(err) {
|
|
1394
|
+
if (this.#error) return;
|
|
1395
|
+
this.#error = err;
|
|
1396
|
+
this.#receiveRejecter?.(err);
|
|
1397
|
+
this.#receiveResolver = void 0;
|
|
1398
|
+
this.#receiveRejecter = void 0;
|
|
1757
1399
|
}
|
|
1758
1400
|
};
|
|
1759
1401
|
|
|
1760
1402
|
//#endregion
|
|
1761
|
-
//#region src/
|
|
1403
|
+
//#region src/container-control/client.ts
|
|
1404
|
+
/** Close the idle capnweb WebSocket promptly so the DO can sleep. */
|
|
1405
|
+
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
1762
1406
|
/**
|
|
1763
|
-
*
|
|
1407
|
+
* How often the busy/idle poller samples `getStats()`.
|
|
1408
|
+
*
|
|
1409
|
+
* Sets two worst-case bounds:
|
|
1410
|
+
*
|
|
1411
|
+
* 1. **Idle-detection lag.** Time between the session going idle on
|
|
1412
|
+
* the wire and the DO observing it (and arming the disconnect).
|
|
1413
|
+
* Bounded by `pollInterval`.
|
|
1414
|
+
* 2. **Activity-renewal lag while busy.** While a stream is active we
|
|
1415
|
+
* renew the DO's activity timeout once per tick. The alarm could
|
|
1416
|
+
* fire as late as `sleepAfter` after the last renew, so the
|
|
1417
|
+
* effective margin against a mid-stream sleep is
|
|
1418
|
+
* `sleepAfter - pollInterval`.
|
|
1419
|
+
*
|
|
1420
|
+
* **Invariant: `pollInterval` must be comfortably less than the
|
|
1421
|
+
* smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
|
|
1422
|
+
* The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
|
|
1423
|
+
* 3× margin and at least two renewals during a 3s window. If a smaller
|
|
1424
|
+
* `sleepAfter` is ever supported, drop this proportionally.
|
|
1764
1425
|
*/
|
|
1765
|
-
|
|
1766
|
-
/**
|
|
1767
|
-
* Create a directory
|
|
1768
|
-
* @param path - Directory path to create
|
|
1769
|
-
* @param sessionId - The session ID for this operation
|
|
1770
|
-
* @param options - Optional settings (recursive)
|
|
1771
|
-
*/
|
|
1772
|
-
async mkdir(path$1, sessionId, options) {
|
|
1773
|
-
const data = {
|
|
1774
|
-
path: path$1,
|
|
1775
|
-
sessionId,
|
|
1776
|
-
recursive: options?.recursive ?? false
|
|
1777
|
-
};
|
|
1778
|
-
return await this.post("/api/mkdir", data);
|
|
1779
|
-
}
|
|
1780
|
-
/**
|
|
1781
|
-
* Write content to a file
|
|
1782
|
-
* @param path - File path to write to
|
|
1783
|
-
* @param content - Content to write
|
|
1784
|
-
* @param sessionId - The session ID for this operation
|
|
1785
|
-
* @param options - Optional settings (encoding)
|
|
1786
|
-
*/
|
|
1787
|
-
async writeFile(path$1, content, sessionId, options) {
|
|
1788
|
-
const data = {
|
|
1789
|
-
path: path$1,
|
|
1790
|
-
content,
|
|
1791
|
-
sessionId,
|
|
1792
|
-
encoding: options?.encoding
|
|
1793
|
-
};
|
|
1794
|
-
return await this.post("/api/write", data);
|
|
1795
|
-
}
|
|
1796
|
-
async readFile(path$1, sessionId, options) {
|
|
1797
|
-
if (options?.encoding === "none") throw new Error("readFile with encoding: 'none' requires the rpc transport. Set SANDBOX_TRANSPORT=rpc.");
|
|
1798
|
-
const data = {
|
|
1799
|
-
path: path$1,
|
|
1800
|
-
sessionId,
|
|
1801
|
-
encoding: options?.encoding
|
|
1802
|
-
};
|
|
1803
|
-
return await this.post("/api/read", data);
|
|
1804
|
-
}
|
|
1805
|
-
/**
|
|
1806
|
-
* Stream a file using Server-Sent Events
|
|
1807
|
-
* Returns a ReadableStream of SSE events containing metadata, chunks, and completion
|
|
1808
|
-
* @param path - File path to stream
|
|
1809
|
-
* @param sessionId - The session ID for this operation
|
|
1810
|
-
*/
|
|
1811
|
-
async readFileStream(path$1, sessionId) {
|
|
1812
|
-
const data = {
|
|
1813
|
-
path: path$1,
|
|
1814
|
-
sessionId
|
|
1815
|
-
};
|
|
1816
|
-
return await this.doStreamFetch("/api/read/stream", data);
|
|
1817
|
-
}
|
|
1818
|
-
/**
|
|
1819
|
-
* Delete a file
|
|
1820
|
-
* @param path - File path to delete
|
|
1821
|
-
* @param sessionId - The session ID for this operation
|
|
1822
|
-
*/
|
|
1823
|
-
async deleteFile(path$1, sessionId) {
|
|
1824
|
-
const data = {
|
|
1825
|
-
path: path$1,
|
|
1826
|
-
sessionId
|
|
1827
|
-
};
|
|
1828
|
-
return await this.post("/api/delete", data);
|
|
1829
|
-
}
|
|
1830
|
-
/**
|
|
1831
|
-
* Rename a file
|
|
1832
|
-
* @param path - Current file path
|
|
1833
|
-
* @param newPath - New file path
|
|
1834
|
-
* @param sessionId - The session ID for this operation
|
|
1835
|
-
*/
|
|
1836
|
-
async renameFile(path$1, newPath, sessionId) {
|
|
1837
|
-
const data = {
|
|
1838
|
-
oldPath: path$1,
|
|
1839
|
-
newPath,
|
|
1840
|
-
sessionId
|
|
1841
|
-
};
|
|
1842
|
-
return await this.post("/api/rename", data);
|
|
1843
|
-
}
|
|
1844
|
-
/**
|
|
1845
|
-
* Move a file
|
|
1846
|
-
* @param path - Current file path
|
|
1847
|
-
* @param newPath - Destination file path
|
|
1848
|
-
* @param sessionId - The session ID for this operation
|
|
1849
|
-
*/
|
|
1850
|
-
async moveFile(path$1, newPath, sessionId) {
|
|
1851
|
-
const data = {
|
|
1852
|
-
sourcePath: path$1,
|
|
1853
|
-
destinationPath: newPath,
|
|
1854
|
-
sessionId
|
|
1855
|
-
};
|
|
1856
|
-
return await this.post("/api/move", data);
|
|
1857
|
-
}
|
|
1858
|
-
/**
|
|
1859
|
-
* List files in a directory
|
|
1860
|
-
* @param path - Directory path to list
|
|
1861
|
-
* @param sessionId - The session ID for this operation
|
|
1862
|
-
* @param options - Optional settings (recursive, includeHidden)
|
|
1863
|
-
*/
|
|
1864
|
-
async listFiles(path$1, sessionId, options) {
|
|
1865
|
-
const data = {
|
|
1866
|
-
path: path$1,
|
|
1867
|
-
sessionId,
|
|
1868
|
-
options: options || {}
|
|
1869
|
-
};
|
|
1870
|
-
return await this.post("/api/list-files", data);
|
|
1871
|
-
}
|
|
1872
|
-
/**
|
|
1873
|
-
* Check if a file or directory exists
|
|
1874
|
-
* @param path - Path to check
|
|
1875
|
-
* @param sessionId - The session ID for this operation
|
|
1876
|
-
*/
|
|
1877
|
-
async exists(path$1, sessionId) {
|
|
1878
|
-
const data = {
|
|
1879
|
-
path: path$1,
|
|
1880
|
-
sessionId
|
|
1881
|
-
};
|
|
1882
|
-
return await this.post("/api/exists", data);
|
|
1883
|
-
}
|
|
1884
|
-
/**
|
|
1885
|
-
* Write a file via a raw binary stream over the RPC transport.
|
|
1886
|
-
* Throws on HTTP and WebSocket transports — use writeFile() with a string instead.
|
|
1887
|
-
*/
|
|
1888
|
-
writeFileStream(_path, _content, _sessionId) {
|
|
1889
|
-
throw new Error("writeFileStream requires the rpc transport. Set SANDBOX_TRANSPORT=rpc.");
|
|
1890
|
-
}
|
|
1891
|
-
};
|
|
1892
|
-
|
|
1893
|
-
//#endregion
|
|
1894
|
-
//#region src/clients/git-client.ts
|
|
1426
|
+
const BUSY_POLL_INTERVAL_MS = 1e3;
|
|
1895
1427
|
/**
|
|
1896
|
-
*
|
|
1428
|
+
* Baseline getStats() values for an idle session. The bootstrap stub on each
|
|
1429
|
+
* side accounts for 1 import and 1 export.
|
|
1897
1430
|
*/
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
//#endregion
|
|
1931
|
-
//#region src/clients/interpreter-client.ts
|
|
1932
|
-
var InterpreterClient = class extends BaseHttpClient {
|
|
1933
|
-
maxRetries = 3;
|
|
1934
|
-
retryDelayMs = 1e3;
|
|
1935
|
-
async createCodeContext(options = {}) {
|
|
1936
|
-
return this.executeWithRetry(async () => {
|
|
1937
|
-
const response = await this.doFetch("/api/contexts", {
|
|
1938
|
-
method: "POST",
|
|
1939
|
-
headers: { "Content-Type": "application/json" },
|
|
1940
|
-
body: JSON.stringify({
|
|
1941
|
-
language: options.language || "python",
|
|
1942
|
-
cwd: options.cwd || "/workspace",
|
|
1943
|
-
env_vars: options.envVars
|
|
1944
|
-
})
|
|
1945
|
-
});
|
|
1946
|
-
if (!response.ok) throw await this.parseErrorResponse(response);
|
|
1947
|
-
const data = await response.json();
|
|
1948
|
-
if (!data.success) throw new Error(`Failed to create context: ${JSON.stringify(data)}`);
|
|
1949
|
-
return {
|
|
1950
|
-
id: data.contextId,
|
|
1951
|
-
language: data.language,
|
|
1952
|
-
cwd: data.cwd || "/workspace",
|
|
1953
|
-
createdAt: new Date(data.timestamp),
|
|
1954
|
-
lastUsed: new Date(data.timestamp)
|
|
1955
|
-
};
|
|
1956
|
-
});
|
|
1957
|
-
}
|
|
1958
|
-
async runCodeStream(contextId, code, language, callbacks, timeoutMs) {
|
|
1959
|
-
return this.executeWithRetry(async () => {
|
|
1960
|
-
const stream = await this.doStreamFetch("/api/execute/code", {
|
|
1961
|
-
context_id: contextId,
|
|
1431
|
+
const IDLE_IMPORT_THRESHOLD = 1;
|
|
1432
|
+
const IDLE_EXPORT_THRESHOLD = 1;
|
|
1433
|
+
function isLocalSandboxError(error) {
|
|
1434
|
+
if (!("errorResponse" in error)) return false;
|
|
1435
|
+
const response = error.errorResponse;
|
|
1436
|
+
return typeof response?.code === "string" && Object.hasOwn(ErrorCode, response.code) && typeof response.message === "string" && typeof response.httpStatus === "number" && typeof response.timestamp === "string" && typeof response.context === "object" && response.context !== null;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Translate a capnweb-propagated error into a typed SandboxError.
|
|
1440
|
+
*
|
|
1441
|
+
* Two wire formats are supported for backward compatibility with older
|
|
1442
|
+
* container images:
|
|
1443
|
+
*
|
|
1444
|
+
* 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
|
|
1445
|
+
* `ServiceError`-shaped object with own enumerable `code` and `details`
|
|
1446
|
+
* properties. capnweb walks `Object.keys()` and reconstructs those fields
|
|
1447
|
+
* on the SDK side.
|
|
1448
|
+
* 2. Legacy JSON-encoded message. Older containers encoded the structured
|
|
1449
|
+
* payload as a JSON string in `error.message`.
|
|
1450
|
+
*
|
|
1451
|
+
* The JSON-fallback branch can be removed once all older container images are
|
|
1452
|
+
* no longer in service.
|
|
1453
|
+
*/
|
|
1454
|
+
function translateRPCError(error) {
|
|
1455
|
+
if (error instanceof Error) {
|
|
1456
|
+
if (isLocalSandboxError(error)) throw error;
|
|
1457
|
+
const propagated = error;
|
|
1458
|
+
if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
|
|
1459
|
+
const code = propagated.code;
|
|
1460
|
+
const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
|
|
1461
|
+
throw createErrorFromResponse({
|
|
1962
1462
|
code,
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
async listCodeContexts() {
|
|
1970
|
-
return this.executeWithRetry(async () => {
|
|
1971
|
-
const response = await this.doFetch("/api/contexts", {
|
|
1972
|
-
method: "GET",
|
|
1973
|
-
headers: { "Content-Type": "application/json" }
|
|
1974
|
-
});
|
|
1975
|
-
if (!response.ok) throw await this.parseErrorResponse(response);
|
|
1976
|
-
const data = await response.json();
|
|
1977
|
-
if (!data.success) throw new Error(`Failed to list contexts: ${JSON.stringify(data)}`);
|
|
1978
|
-
return data.contexts.map((ctx) => ({
|
|
1979
|
-
id: ctx.id,
|
|
1980
|
-
language: ctx.language,
|
|
1981
|
-
cwd: ctx.cwd || "/workspace",
|
|
1982
|
-
createdAt: new Date(data.timestamp),
|
|
1983
|
-
lastUsed: new Date(data.timestamp)
|
|
1984
|
-
}));
|
|
1985
|
-
});
|
|
1986
|
-
}
|
|
1987
|
-
async deleteCodeContext(contextId) {
|
|
1988
|
-
return this.executeWithRetry(async () => {
|
|
1989
|
-
const response = await this.doFetch(`/api/contexts/${contextId}`, {
|
|
1990
|
-
method: "DELETE",
|
|
1991
|
-
headers: { "Content-Type": "application/json" }
|
|
1463
|
+
message: error.message,
|
|
1464
|
+
context,
|
|
1465
|
+
httpStatus: getHttpStatus(code),
|
|
1466
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1992
1467
|
});
|
|
1993
|
-
|
|
1468
|
+
}
|
|
1469
|
+
let payload;
|
|
1470
|
+
try {
|
|
1471
|
+
payload = JSON.parse(error.message);
|
|
1472
|
+
} catch {}
|
|
1473
|
+
if (payload && typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
|
|
1474
|
+
code: payload.code,
|
|
1475
|
+
message: payload.message,
|
|
1476
|
+
context: payload.context ?? {},
|
|
1477
|
+
httpStatus: getHttpStatus(payload.code),
|
|
1478
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1994
1479
|
});
|
|
1480
|
+
throw createErrorFromResponse(buildTransportErrorResponse(error), { cause: error });
|
|
1995
1481
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
* Used by CodeInterpreter.runCodeStreaming() for direct stream access.
|
|
1999
|
-
*/
|
|
2000
|
-
async streamCode(contextId, code, language) {
|
|
2001
|
-
return this.doStreamFetch("/api/execute/code", {
|
|
2002
|
-
context_id: contextId,
|
|
2003
|
-
code,
|
|
2004
|
-
language
|
|
2005
|
-
});
|
|
2006
|
-
}
|
|
2007
|
-
/**
|
|
2008
|
-
* Execute an operation with automatic retry for transient errors
|
|
2009
|
-
*/
|
|
2010
|
-
async executeWithRetry(operation) {
|
|
2011
|
-
let lastError;
|
|
2012
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt++) try {
|
|
2013
|
-
return await operation();
|
|
2014
|
-
} catch (error) {
|
|
2015
|
-
lastError = error;
|
|
2016
|
-
if (this.isRetryableError(error)) {
|
|
2017
|
-
if (attempt < this.maxRetries - 1) {
|
|
2018
|
-
const delay = this.retryDelayMs * 2 ** attempt + Math.random() * 1e3;
|
|
2019
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2020
|
-
continue;
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
throw error;
|
|
2024
|
-
}
|
|
2025
|
-
throw lastError || /* @__PURE__ */ new Error("Execution failed after retries");
|
|
2026
|
-
}
|
|
2027
|
-
isRetryableError(error) {
|
|
2028
|
-
if (error instanceof InterpreterNotReadyError) return true;
|
|
2029
|
-
if (error instanceof Error) return error.message.includes("not ready") || error.message.includes("initializing");
|
|
2030
|
-
return false;
|
|
2031
|
-
}
|
|
2032
|
-
async parseErrorResponse(response) {
|
|
2033
|
-
try {
|
|
2034
|
-
return createErrorFromResponse(await response.json());
|
|
2035
|
-
} catch {
|
|
2036
|
-
return createErrorFromResponse({
|
|
2037
|
-
code: ErrorCode.INTERNAL_ERROR,
|
|
2038
|
-
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
2039
|
-
context: {},
|
|
2040
|
-
httpStatus: response.status,
|
|
2041
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2042
|
-
});
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
async *readLines(stream) {
|
|
2046
|
-
const reader = stream.getReader();
|
|
2047
|
-
let buffer = "";
|
|
2048
|
-
try {
|
|
2049
|
-
while (true) {
|
|
2050
|
-
const { done, value } = await reader.read();
|
|
2051
|
-
if (value) buffer += new TextDecoder().decode(value);
|
|
2052
|
-
if (done) break;
|
|
2053
|
-
let newlineIdx = buffer.indexOf("\n");
|
|
2054
|
-
while (newlineIdx !== -1) {
|
|
2055
|
-
yield buffer.slice(0, newlineIdx);
|
|
2056
|
-
buffer = buffer.slice(newlineIdx + 1);
|
|
2057
|
-
newlineIdx = buffer.indexOf("\n");
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
if (buffer.length > 0) yield buffer;
|
|
2061
|
-
} finally {
|
|
2062
|
-
try {
|
|
2063
|
-
await reader.cancel();
|
|
2064
|
-
} catch {}
|
|
2065
|
-
reader.releaseLock();
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
async parseExecutionResult(line, callbacks) {
|
|
2069
|
-
if (!line.trim()) return;
|
|
2070
|
-
if (!line.startsWith("data: ")) return;
|
|
2071
|
-
try {
|
|
2072
|
-
const jsonData = line.substring(6);
|
|
2073
|
-
const data = JSON.parse(jsonData);
|
|
2074
|
-
switch (data.type) {
|
|
2075
|
-
case "stdout":
|
|
2076
|
-
if (callbacks.onStdout && data.text) await callbacks.onStdout({
|
|
2077
|
-
text: data.text,
|
|
2078
|
-
timestamp: data.timestamp || Date.now()
|
|
2079
|
-
});
|
|
2080
|
-
break;
|
|
2081
|
-
case "stderr":
|
|
2082
|
-
if (callbacks.onStderr && data.text) await callbacks.onStderr({
|
|
2083
|
-
text: data.text,
|
|
2084
|
-
timestamp: data.timestamp || Date.now()
|
|
2085
|
-
});
|
|
2086
|
-
break;
|
|
2087
|
-
case "result":
|
|
2088
|
-
if (callbacks.onResult) {
|
|
2089
|
-
const result = new ResultImpl(data);
|
|
2090
|
-
await callbacks.onResult(result);
|
|
2091
|
-
}
|
|
2092
|
-
break;
|
|
2093
|
-
case "error":
|
|
2094
|
-
if (callbacks.onError) await callbacks.onError({
|
|
2095
|
-
name: data.ename || "Error",
|
|
2096
|
-
message: data.evalue || "Unknown error",
|
|
2097
|
-
traceback: data.traceback || []
|
|
2098
|
-
});
|
|
2099
|
-
break;
|
|
2100
|
-
case "execution_complete": break;
|
|
2101
|
-
}
|
|
2102
|
-
} catch {}
|
|
2103
|
-
}
|
|
2104
|
-
};
|
|
2105
|
-
|
|
2106
|
-
//#endregion
|
|
2107
|
-
//#region src/clients/port-client.ts
|
|
2108
|
-
/**
|
|
2109
|
-
* Client for port readiness operations.
|
|
2110
|
-
*/
|
|
2111
|
-
var PortClient = class extends BaseHttpClient {
|
|
2112
|
-
/**
|
|
2113
|
-
* Watch a port for readiness via SSE stream
|
|
2114
|
-
* @param request - Port watch configuration
|
|
2115
|
-
* @returns SSE stream that emits PortWatchEvent objects
|
|
2116
|
-
*/
|
|
2117
|
-
async watchPort(request) {
|
|
2118
|
-
return await this.doStreamFetch("/api/port-watch", request);
|
|
2119
|
-
}
|
|
2120
|
-
};
|
|
2121
|
-
|
|
2122
|
-
//#endregion
|
|
2123
|
-
//#region src/clients/process-client.ts
|
|
2124
|
-
/**
|
|
2125
|
-
* Client for background process management
|
|
2126
|
-
*/
|
|
2127
|
-
var ProcessClient = class extends BaseHttpClient {
|
|
2128
|
-
/**
|
|
2129
|
-
* Start a background process
|
|
2130
|
-
* @param command - Command to execute as a background process
|
|
2131
|
-
* @param sessionId - The session ID for this operation
|
|
2132
|
-
* @param options - Optional settings (processId)
|
|
2133
|
-
*/
|
|
2134
|
-
async startProcess(command, sessionId, options) {
|
|
2135
|
-
const data = {
|
|
2136
|
-
command,
|
|
2137
|
-
sessionId,
|
|
2138
|
-
...options?.origin !== void 0 && { origin: options.origin },
|
|
2139
|
-
...options?.processId !== void 0 && { processId: options.processId },
|
|
2140
|
-
...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
|
|
2141
|
-
...options?.env !== void 0 && { env: options.env },
|
|
2142
|
-
...options?.cwd !== void 0 && { cwd: options.cwd },
|
|
2143
|
-
...options?.encoding !== void 0 && { encoding: options.encoding },
|
|
2144
|
-
...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
|
|
2145
|
-
};
|
|
2146
|
-
return await this.post("/api/process/start", data);
|
|
2147
|
-
}
|
|
2148
|
-
/**
|
|
2149
|
-
* List all processes (sandbox-scoped, not session-scoped)
|
|
2150
|
-
*/
|
|
2151
|
-
async listProcesses() {
|
|
2152
|
-
return await this.get(`/api/process/list`);
|
|
2153
|
-
}
|
|
2154
|
-
/**
|
|
2155
|
-
* Get information about a specific process (sandbox-scoped, not session-scoped)
|
|
2156
|
-
* @param processId - ID of the process to retrieve
|
|
2157
|
-
*/
|
|
2158
|
-
async getProcess(processId) {
|
|
2159
|
-
const url = `/api/process/${processId}`;
|
|
2160
|
-
return await this.get(url);
|
|
2161
|
-
}
|
|
2162
|
-
/**
|
|
2163
|
-
* Kill a specific process (sandbox-scoped, not session-scoped)
|
|
2164
|
-
* @param processId - ID of the process to kill
|
|
2165
|
-
*/
|
|
2166
|
-
async killProcess(processId) {
|
|
2167
|
-
const url = `/api/process/${processId}`;
|
|
2168
|
-
return await this.delete(url);
|
|
2169
|
-
}
|
|
2170
|
-
/**
|
|
2171
|
-
* Kill all running processes (sandbox-scoped, not session-scoped)
|
|
2172
|
-
*/
|
|
2173
|
-
async killAllProcesses() {
|
|
2174
|
-
return await this.delete(`/api/process/kill-all`);
|
|
2175
|
-
}
|
|
2176
|
-
/**
|
|
2177
|
-
* Get logs from a specific process (sandbox-scoped, not session-scoped)
|
|
2178
|
-
* @param processId - ID of the process to get logs from
|
|
2179
|
-
*/
|
|
2180
|
-
async getProcessLogs(processId) {
|
|
2181
|
-
const url = `/api/process/${processId}/logs`;
|
|
2182
|
-
return await this.get(url);
|
|
2183
|
-
}
|
|
2184
|
-
/**
|
|
2185
|
-
* Stream logs from a specific process (sandbox-scoped, not session-scoped)
|
|
2186
|
-
* @param processId - ID of the process to stream logs from
|
|
2187
|
-
*/
|
|
2188
|
-
async streamProcessLogs(processId) {
|
|
2189
|
-
const url = `/api/process/${processId}/stream`;
|
|
2190
|
-
return await this.doStreamFetch(url, void 0, "GET");
|
|
2191
|
-
}
|
|
2192
|
-
};
|
|
2193
|
-
|
|
2194
|
-
//#endregion
|
|
2195
|
-
//#region src/clients/utility-client.ts
|
|
1482
|
+
throw createErrorFromResponse(buildTransportErrorResponse(new Error(String(error))), { cause: error });
|
|
1483
|
+
}
|
|
2196
1484
|
/**
|
|
2197
|
-
*
|
|
1485
|
+
* Inspect a transport-level Error's message and produce the ErrorResponse
|
|
1486
|
+
* that becomes an RPCTransportError. Pattern strings are pinned to the exact
|
|
1487
|
+
* messages emitted by capnweb's WebSocket transport (see capnweb's
|
|
1488
|
+
* src/websocket.ts) and our DeferredTransport in container-control/connection.ts —
|
|
1489
|
+
* notably the trailing period in `WebSocket connection failed.` matches
|
|
1490
|
+
* capnweb verbatim. The DeferredTransport tests in
|
|
1491
|
+
* tests/container-connection.test.ts pin the literal strings.
|
|
2198
1492
|
*/
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
return await this.post("/api/session/create", options);
|
|
2218
|
-
}
|
|
2219
|
-
/**
|
|
2220
|
-
* Delete an execution session
|
|
2221
|
-
* @param sessionId - Session ID to delete
|
|
2222
|
-
*/
|
|
2223
|
-
async deleteSession(sessionId) {
|
|
2224
|
-
return await this.post("/api/session/delete", { sessionId });
|
|
2225
|
-
}
|
|
2226
|
-
/**
|
|
2227
|
-
* Get the container version
|
|
2228
|
-
* Returns the version embedded in the Docker image during build
|
|
2229
|
-
*/
|
|
2230
|
-
async getVersion() {
|
|
2231
|
-
try {
|
|
2232
|
-
return (await this.get("/api/version")).version;
|
|
2233
|
-
} catch (error) {
|
|
2234
|
-
this.logger.debug("Failed to get container version (may be old container)", { error });
|
|
2235
|
-
return "unknown";
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
listSessions() {
|
|
2239
|
-
throw new Error("listSessions requires the RPC transport. Set SANDBOX_TRANSPORT=rpc.");
|
|
1493
|
+
function buildTransportErrorResponse(error) {
|
|
1494
|
+
const message = error.message;
|
|
1495
|
+
const errorName = error.name;
|
|
1496
|
+
let kind = "unknown";
|
|
1497
|
+
let closeCode;
|
|
1498
|
+
let closeReason;
|
|
1499
|
+
if (errorName === "TypeError") kind = "invalid_frame";
|
|
1500
|
+
else if (errorName === "SyntaxError") kind = "protocol_error";
|
|
1501
|
+
else {
|
|
1502
|
+
const peerCloseMatch = message.match(/^Peer closed WebSocket: (\d+) ?(.*)$/);
|
|
1503
|
+
if (peerCloseMatch) {
|
|
1504
|
+
kind = "peer_closed";
|
|
1505
|
+
closeCode = Number(peerCloseMatch[1]);
|
|
1506
|
+
closeReason = peerCloseMatch[2] || void 0;
|
|
1507
|
+
} else if (message === "WebSocket connection failed.") kind = "connection_failed";
|
|
1508
|
+
else if (message.startsWith("WebSocket upgrade failed")) kind = "upgrade_failed";
|
|
1509
|
+
else if (message === "No WebSocket in upgrade response") kind = "upgrade_failed";
|
|
1510
|
+
else if (message === "RPC session was shut down by disposing the main stub" || message === "RPC was canceled because the RpcPromise was disposed.") kind = "session_disposed";
|
|
2240
1511
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
1512
|
+
const context = {
|
|
1513
|
+
kind,
|
|
1514
|
+
originalMessage: message,
|
|
1515
|
+
errorName,
|
|
1516
|
+
...closeCode !== void 0 ? { closeCode } : {},
|
|
1517
|
+
...closeReason !== void 0 ? { closeReason } : {}
|
|
1518
|
+
};
|
|
1519
|
+
return {
|
|
1520
|
+
code: ErrorCode.RPC_TRANSPORT_ERROR,
|
|
1521
|
+
message,
|
|
1522
|
+
context,
|
|
1523
|
+
httpStatus: getHttpStatus(ErrorCode.RPC_TRANSPORT_ERROR),
|
|
1524
|
+
suggestion: getSuggestion(ErrorCode.RPC_TRANSPORT_ERROR, context),
|
|
1525
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
2245
1528
|
/**
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
1529
|
+
* Wrap a capnweb RPC stub so that every method call translates errors
|
|
1530
|
+
* from the JSON wire format into typed SandboxError instances and signals
|
|
1531
|
+
* activity at call start.
|
|
1532
|
+
*
|
|
1533
|
+
* `onCallStarted` fires synchronously when an RPC method is invoked. The
|
|
1534
|
+
* ContainerControlClient uses this to renew the DO's activity timeout
|
|
1535
|
+
* immediately, so even a call that completes entirely between two
|
|
1536
|
+
* busy-poll ticks still pushes the sleepAfter deadline forward.
|
|
2248
1537
|
*
|
|
2249
|
-
*
|
|
2250
|
-
*
|
|
1538
|
+
* Note: there is no `onCallSettled` hook. A method whose returned promise
|
|
1539
|
+
* resolves with a `ReadableStream` is *not* finished when the promise
|
|
1540
|
+
* settles — capnweb keeps the export alive until the stream ends. The
|
|
1541
|
+
* busy/idle poll on `getStats()` is the source of truth for that.
|
|
2251
1542
|
*/
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
}
|
|
2259
|
-
/**
|
|
2260
|
-
* Start watching a directory for changes.
|
|
2261
|
-
* The returned promise resolves only after the watcher is established
|
|
2262
|
-
* on the filesystem (i.e. the `watching` SSE event has been received).
|
|
2263
|
-
* The returned stream still contains the `watching` event so consumers
|
|
2264
|
-
* using `parseSSEStream` will see the full event sequence.
|
|
2265
|
-
*
|
|
2266
|
-
* @param request - Watch request with path and options
|
|
2267
|
-
*/
|
|
2268
|
-
async watch(request) {
|
|
2269
|
-
const stream = await this.doStreamFetch("/api/watch", request);
|
|
2270
|
-
return await this.waitForReadiness(stream);
|
|
2271
|
-
}
|
|
2272
|
-
/**
|
|
2273
|
-
* Read SSE chunks until the `watching` event appears, then return a
|
|
2274
|
-
* wrapper stream that replays the buffered chunks followed by the
|
|
2275
|
-
* remaining original stream data.
|
|
2276
|
-
*/
|
|
2277
|
-
async waitForReadiness(stream) {
|
|
2278
|
-
const reader = stream.getReader();
|
|
2279
|
-
const bufferedChunks = [];
|
|
2280
|
-
const decoder = new TextDecoder();
|
|
2281
|
-
let buffer = "";
|
|
2282
|
-
let currentEvent = { data: [] };
|
|
2283
|
-
let watcherReady = false;
|
|
2284
|
-
const processEventData = (eventData) => {
|
|
2285
|
-
let event;
|
|
1543
|
+
function wrapStub(stub, onCallStarted) {
|
|
1544
|
+
return new Proxy(stub, { get(target, prop, receiver) {
|
|
1545
|
+
const value = Reflect.get(target, prop, receiver);
|
|
1546
|
+
if (typeof value !== "function") return value;
|
|
1547
|
+
return (...args) => {
|
|
1548
|
+
onCallStarted();
|
|
2286
1549
|
try {
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
return;
|
|
1550
|
+
const result = Reflect.apply(value, target, args);
|
|
1551
|
+
if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
|
|
1552
|
+
return result;
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
translateRPCError(err);
|
|
2290
1555
|
}
|
|
2291
|
-
if (event.type === "watching") watcherReady = true;
|
|
2292
|
-
if (event.type === "error") throw new Error(event.error || "Watch failed to establish");
|
|
2293
1556
|
};
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
const { done, value } = await reader.read();
|
|
2297
|
-
if (done) {
|
|
2298
|
-
const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
|
|
2299
|
-
for (const frame of finalParsed.events) {
|
|
2300
|
-
processEventData(frame.data);
|
|
2301
|
-
if (watcherReady) break;
|
|
2302
|
-
}
|
|
2303
|
-
if (watcherReady) break;
|
|
2304
|
-
throw new Error("Watch stream ended before watcher was established");
|
|
2305
|
-
}
|
|
2306
|
-
bufferedChunks.push(value);
|
|
2307
|
-
buffer += decoder.decode(value, { stream: true });
|
|
2308
|
-
const parsed = parseSSEFrames(buffer, currentEvent);
|
|
2309
|
-
buffer = parsed.remaining;
|
|
2310
|
-
currentEvent = parsed.currentEvent;
|
|
2311
|
-
for (const frame of parsed.events) {
|
|
2312
|
-
processEventData(frame.data);
|
|
2313
|
-
if (watcherReady) break;
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
} catch (error) {
|
|
2317
|
-
reader.cancel().catch(() => {});
|
|
2318
|
-
throw error;
|
|
2319
|
-
}
|
|
2320
|
-
let replayIndex = 0;
|
|
2321
|
-
return new ReadableStream({
|
|
2322
|
-
pull(controller) {
|
|
2323
|
-
if (replayIndex < bufferedChunks.length) {
|
|
2324
|
-
controller.enqueue(bufferedChunks[replayIndex++]);
|
|
2325
|
-
return;
|
|
2326
|
-
}
|
|
2327
|
-
return reader.read().then(({ done: d, value: v }) => {
|
|
2328
|
-
if (d) {
|
|
2329
|
-
controller.close();
|
|
2330
|
-
return;
|
|
2331
|
-
}
|
|
2332
|
-
controller.enqueue(v);
|
|
2333
|
-
});
|
|
2334
|
-
},
|
|
2335
|
-
cancel() {
|
|
2336
|
-
return reader.cancel();
|
|
2337
|
-
}
|
|
2338
|
-
});
|
|
2339
|
-
}
|
|
2340
|
-
};
|
|
2341
|
-
|
|
2342
|
-
//#endregion
|
|
2343
|
-
//#region src/clients/sandbox-client.ts
|
|
1557
|
+
} });
|
|
1558
|
+
}
|
|
2344
1559
|
/**
|
|
2345
|
-
*
|
|
2346
|
-
*
|
|
1560
|
+
* Sandbox control facade backed by direct capnweb RPC.
|
|
1561
|
+
*
|
|
1562
|
+
* All operations call the container's SandboxAPI control interface directly
|
|
1563
|
+
* over capnweb, bypassing the HTTP handler/router layer entirely.
|
|
2347
1564
|
*
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
2350
|
-
*
|
|
2351
|
-
*
|
|
1565
|
+
* Manages its own WebSocket lifecycle: a fresh `ContainerControlConnection` is
|
|
1566
|
+
* created on demand and torn down after `idleDisconnectMs` of inactivity.
|
|
1567
|
+
* Busy/idle detection relies on `RpcSession.getStats()` which tracks all
|
|
1568
|
+
* in-flight RPC calls and stream exports — including long-lived streaming
|
|
1569
|
+
* RPCs that would be invisible to a simple per-call request counter (see
|
|
1570
|
+
* the file-level comment for the full rationale).
|
|
2352
1571
|
*/
|
|
2353
|
-
var
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
* PublicKeys<SandboxAPI>` compile-time check holds. Calling any method on
|
|
2367
|
-
* the returned proxy throws a clear `RPC transport required` error.
|
|
2368
|
-
*/
|
|
2369
|
-
tunnels = createTunnelsNotImplemented();
|
|
2370
|
-
transport = null;
|
|
1572
|
+
var ContainerControlClient = class {
|
|
1573
|
+
connOptions;
|
|
1574
|
+
idleDisconnectMs;
|
|
1575
|
+
busyPollIntervalMs;
|
|
1576
|
+
logger;
|
|
1577
|
+
onActivity;
|
|
1578
|
+
onSessionBusy;
|
|
1579
|
+
onSessionIdle;
|
|
1580
|
+
conn = null;
|
|
1581
|
+
idleTimer = null;
|
|
1582
|
+
busyPollTimer = null;
|
|
1583
|
+
/** Tracks whether we currently believe the session is busy. */
|
|
1584
|
+
busy = false;
|
|
2371
1585
|
constructor(options) {
|
|
2372
|
-
|
|
2373
|
-
mode: options.transportMode,
|
|
2374
|
-
wsUrl: options.wsUrl,
|
|
2375
|
-
baseUrl: options.baseUrl,
|
|
2376
|
-
logger: options.logger,
|
|
1586
|
+
this.connOptions = {
|
|
2377
1587
|
stub: options.stub,
|
|
2378
1588
|
port: options.port,
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
1589
|
+
localMain: options.localMain,
|
|
1590
|
+
logger: options.logger,
|
|
1591
|
+
retryTimeoutMs: options.retryTimeoutMs,
|
|
1592
|
+
onClose: () => {
|
|
1593
|
+
if (this.conn) this.destroyConnection();
|
|
1594
|
+
}
|
|
2385
1595
|
};
|
|
2386
|
-
this.
|
|
2387
|
-
this.
|
|
2388
|
-
this.
|
|
2389
|
-
this.
|
|
2390
|
-
this.
|
|
2391
|
-
this.
|
|
2392
|
-
this.interpreter = new InterpreterClient(clientOptions);
|
|
2393
|
-
this.utils = new UtilityClient(clientOptions);
|
|
2394
|
-
this.watch = new WatchClient(clientOptions);
|
|
1596
|
+
this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
|
|
1597
|
+
this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
|
|
1598
|
+
this.logger = options.logger ?? createNoOpLogger();
|
|
1599
|
+
this.onActivity = options.onActivity;
|
|
1600
|
+
this.onSessionBusy = options.onSessionBusy;
|
|
1601
|
+
this.onSessionIdle = options.onSessionIdle;
|
|
2395
1602
|
}
|
|
2396
1603
|
/**
|
|
2397
|
-
*
|
|
2398
|
-
*
|
|
2399
|
-
* In WebSocket mode a single shared transport is used, so one update covers
|
|
2400
|
-
* every sub-client. In HTTP mode each sub-client owns its own transport, so
|
|
2401
|
-
* all of them are updated individually.
|
|
1604
|
+
* Return the current connection, creating one when the client is disconnected.
|
|
1605
|
+
* Starts the busy-poll timer the first time a connection is materialized.
|
|
2402
1606
|
*/
|
|
2403
|
-
|
|
2404
|
-
if (this.
|
|
2405
|
-
|
|
2406
|
-
this.
|
|
2407
|
-
this.commands.setRetryTimeoutMs(ms);
|
|
2408
|
-
this.files.setRetryTimeoutMs(ms);
|
|
2409
|
-
this.processes.setRetryTimeoutMs(ms);
|
|
2410
|
-
this.ports.setRetryTimeoutMs(ms);
|
|
2411
|
-
this.git.setRetryTimeoutMs(ms);
|
|
2412
|
-
this.interpreter.setRetryTimeoutMs(ms);
|
|
2413
|
-
this.utils.setRetryTimeoutMs(ms);
|
|
2414
|
-
this.watch.setRetryTimeoutMs(ms);
|
|
1607
|
+
getConnection() {
|
|
1608
|
+
if (!this.conn) {
|
|
1609
|
+
this.conn = new ContainerControlConnection(this.connOptions);
|
|
1610
|
+
this.startBusyPoll();
|
|
2415
1611
|
}
|
|
1612
|
+
return this.conn;
|
|
2416
1613
|
}
|
|
2417
1614
|
/**
|
|
2418
|
-
*
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
return this.transport?.getMode() ?? "http";
|
|
2422
|
-
}
|
|
2423
|
-
/**
|
|
2424
|
-
* Check if WebSocket is connected (only relevant in WebSocket mode)
|
|
1615
|
+
* Called synchronously at the start of each RPC method invocation.
|
|
1616
|
+
* Renews the DO activity timeout so the sleepAfter alarm is pushed
|
|
1617
|
+
* forward before the container processes the call.
|
|
2425
1618
|
*/
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
}
|
|
2429
|
-
/**
|
|
2430
|
-
* Connect WebSocket transport (no-op in HTTP mode)
|
|
2431
|
-
* Called automatically on first request, but can be called explicitly
|
|
2432
|
-
* to establish connection upfront.
|
|
2433
|
-
*/
|
|
2434
|
-
async connect() {
|
|
2435
|
-
if (this.transport) await this.transport.connect();
|
|
2436
|
-
}
|
|
2437
|
-
/**
|
|
2438
|
-
* Disconnect WebSocket transport (no-op in HTTP mode)
|
|
2439
|
-
* Should be called when the sandbox is destroyed.
|
|
2440
|
-
*/
|
|
2441
|
-
disconnect() {
|
|
2442
|
-
if (this.transport) this.transport.disconnect();
|
|
2443
|
-
}
|
|
2444
|
-
};
|
|
2445
|
-
function createTunnelsNotImplemented() {
|
|
2446
|
-
const message = "sandbox.tunnels.* requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.";
|
|
2447
|
-
return new Proxy({}, { get() {
|
|
2448
|
-
return () => {
|
|
2449
|
-
throw new Error(message);
|
|
2450
|
-
};
|
|
2451
|
-
} });
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
//#endregion
|
|
2455
|
-
//#region ../shared/src/backup.ts
|
|
2456
|
-
/**
|
|
2457
|
-
* Absolute directory prefixes supported by backup and restore operations.
|
|
2458
|
-
*/
|
|
2459
|
-
const BACKUP_ALLOWED_PREFIXES = [
|
|
2460
|
-
"/workspace",
|
|
2461
|
-
"/home",
|
|
2462
|
-
"/tmp",
|
|
2463
|
-
"/var/tmp",
|
|
2464
|
-
"/app"
|
|
2465
|
-
];
|
|
2466
|
-
function normalizeBackupExcludePattern(pattern) {
|
|
2467
|
-
let normalized = pattern;
|
|
2468
|
-
while (normalized.startsWith("**/")) normalized = normalized.slice(3);
|
|
2469
|
-
while (normalized.includes("/**/")) normalized = normalized.replace(/\/\*\*\//g, "/");
|
|
2470
|
-
if (normalized.endsWith("/**")) normalized = normalized.slice(0, -3);
|
|
2471
|
-
if (!normalized || normalized === "**") return null;
|
|
2472
|
-
return normalized;
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
//#endregion
|
|
2476
|
-
//#region ../shared/src/internal.ts
|
|
2477
|
-
const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
|
|
2478
|
-
|
|
2479
|
-
//#endregion
|
|
2480
|
-
//#region src/container-control/connection.ts
|
|
2481
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
2482
|
-
const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
|
|
2483
|
-
const MIN_TIME_FOR_RETRY_MS = 15e3;
|
|
2484
|
-
/**
|
|
2485
|
-
* Manages a capnweb WebSocket RPC session to the container.
|
|
2486
|
-
*
|
|
2487
|
-
* The RPC stub is created eagerly in the constructor using a deferred
|
|
2488
|
-
* transport. Calls made before `connect()` completes are queued in the
|
|
2489
|
-
* transport and flushed once the WebSocket is established.
|
|
2490
|
-
*/
|
|
2491
|
-
var ContainerControlConnection = class {
|
|
2492
|
-
stub;
|
|
2493
|
-
session;
|
|
2494
|
-
transport;
|
|
2495
|
-
ws = null;
|
|
2496
|
-
connected = false;
|
|
2497
|
-
connectPromise = null;
|
|
2498
|
-
containerStub;
|
|
2499
|
-
port;
|
|
2500
|
-
logger;
|
|
2501
|
-
retryTimeoutMs;
|
|
2502
|
-
onClose;
|
|
2503
|
-
constructor(options) {
|
|
2504
|
-
this.containerStub = options.stub;
|
|
2505
|
-
this.port = options.port ?? 3e3;
|
|
2506
|
-
this.logger = options.logger ?? createNoOpLogger();
|
|
2507
|
-
this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
|
|
2508
|
-
this.onClose = options.onClose;
|
|
2509
|
-
this.transport = new DeferredTransport();
|
|
2510
|
-
this.session = new RpcSession(this.transport, options.localMain);
|
|
2511
|
-
this.stub = this.session.getRemoteMain();
|
|
2512
|
-
}
|
|
1619
|
+
renewActivity = () => {
|
|
1620
|
+
this.onActivity?.();
|
|
1621
|
+
};
|
|
2513
1622
|
/**
|
|
2514
|
-
*
|
|
1623
|
+
* Sample `getStats()` and update busy/idle state. While busy, renews the
|
|
1624
|
+
* activity timeout each tick so an in-flight stream keeps pushing the
|
|
1625
|
+
* sleepAfter deadline forward. On the busy → idle edge, fires
|
|
1626
|
+
* `onSessionIdle` and schedules the WebSocket disconnect.
|
|
2515
1627
|
*
|
|
2516
|
-
*
|
|
2517
|
-
*
|
|
2518
|
-
* the
|
|
1628
|
+
* If the WebSocket has dropped underneath us (container crash, network
|
|
1629
|
+
* blip) we tear the connection down here. `destroyConnection()` fires
|
|
1630
|
+
* `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
|
|
1631
|
+
* stay pinned forever waiting for a peer that's never going to reply.
|
|
2519
1632
|
*/
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
return
|
|
1633
|
+
pollBusyState = () => {
|
|
1634
|
+
const conn = this.conn;
|
|
1635
|
+
if (!conn) return;
|
|
1636
|
+
if (!conn.isConnected()) return;
|
|
1637
|
+
const { imports, exports } = conn.getStats();
|
|
1638
|
+
if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
|
|
1639
|
+
if (!this.busy) {
|
|
1640
|
+
this.busy = true;
|
|
1641
|
+
this.onSessionBusy?.();
|
|
1642
|
+
}
|
|
1643
|
+
this.onActivity?.();
|
|
1644
|
+
this.clearIdleTimer();
|
|
1645
|
+
} else if (this.busy) {
|
|
1646
|
+
this.busy = false;
|
|
1647
|
+
this.onSessionIdle?.();
|
|
1648
|
+
this.scheduleIdleDisconnect();
|
|
1649
|
+
} else if (!this.idleTimer) this.scheduleIdleDisconnect();
|
|
1650
|
+
};
|
|
1651
|
+
startBusyPoll() {
|
|
1652
|
+
if (this.busyPollTimer) return;
|
|
1653
|
+
this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
|
|
2523
1654
|
}
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
getStats() {
|
|
2530
|
-
return this.session.getStats();
|
|
1655
|
+
stopBusyPoll() {
|
|
1656
|
+
if (this.busyPollTimer) {
|
|
1657
|
+
clearInterval(this.busyPollTimer);
|
|
1658
|
+
this.busyPollTimer = null;
|
|
1659
|
+
}
|
|
2531
1660
|
}
|
|
2532
|
-
|
|
2533
|
-
|
|
1661
|
+
scheduleIdleDisconnect() {
|
|
1662
|
+
this.clearIdleTimer();
|
|
1663
|
+
this.idleTimer = setTimeout(() => {
|
|
1664
|
+
this.idleTimer = null;
|
|
1665
|
+
const conn = this.conn;
|
|
1666
|
+
if (!conn || !conn.isConnected()) return;
|
|
1667
|
+
const { imports, exports } = conn.getStats();
|
|
1668
|
+
if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
|
|
1669
|
+
this.logger.debug("Disconnecting idle RPC connection");
|
|
1670
|
+
this.destroyConnection();
|
|
1671
|
+
}
|
|
1672
|
+
}, this.idleDisconnectMs);
|
|
2534
1673
|
}
|
|
2535
|
-
|
|
2536
|
-
if (this.
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
try {
|
|
2540
|
-
await this.connectPromise;
|
|
2541
|
-
} finally {
|
|
2542
|
-
this.connectPromise = null;
|
|
1674
|
+
clearIdleTimer() {
|
|
1675
|
+
if (this.idleTimer) {
|
|
1676
|
+
clearTimeout(this.idleTimer);
|
|
1677
|
+
this.idleTimer = null;
|
|
2543
1678
|
}
|
|
2544
1679
|
}
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
this.
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
this.ws = null;
|
|
1680
|
+
destroyConnection() {
|
|
1681
|
+
this.stopBusyPoll();
|
|
1682
|
+
this.clearIdleTimer();
|
|
1683
|
+
if (this.busy) {
|
|
1684
|
+
this.busy = false;
|
|
1685
|
+
this.onSessionIdle?.();
|
|
1686
|
+
}
|
|
1687
|
+
if (this.conn) {
|
|
1688
|
+
this.conn.disconnect();
|
|
1689
|
+
this.conn = null;
|
|
2556
1690
|
}
|
|
2557
|
-
this.connected = false;
|
|
2558
|
-
this.connectPromise = null;
|
|
2559
1691
|
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
* effect on the next `connect()`; an in-flight connect uses the value
|
|
2563
|
-
* captured at start. Mirrors `WebSocketTransport.setRetryTimeoutMs`.
|
|
2564
|
-
*/
|
|
2565
|
-
setRetryTimeoutMs(ms) {
|
|
2566
|
-
this.retryTimeoutMs = ms;
|
|
1692
|
+
get commands() {
|
|
1693
|
+
return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
|
|
2567
1694
|
}
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
* swallowing any errors so a buggy listener can't keep the connection
|
|
2571
|
-
* object in a half-torn-down state.
|
|
2572
|
-
*/
|
|
2573
|
-
fireOnClose() {
|
|
2574
|
-
if (!this.onClose) return;
|
|
2575
|
-
try {
|
|
2576
|
-
this.onClose();
|
|
2577
|
-
} catch (err) {
|
|
2578
|
-
this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
|
|
2579
|
-
}
|
|
1695
|
+
get files() {
|
|
1696
|
+
return wrapStub(this.getConnection().rpc().files, this.renewActivity);
|
|
2580
1697
|
}
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
* same reference can be passed to both `addEventListener` and
|
|
2584
|
-
* `removeEventListener` — a fresh anonymous lambda would silently
|
|
2585
|
-
* fail to unbind.
|
|
2586
|
-
*/
|
|
2587
|
-
onWebSocketClose = () => {
|
|
2588
|
-
const wasConnected = this.connected;
|
|
2589
|
-
this.connected = false;
|
|
2590
|
-
this.ws = null;
|
|
2591
|
-
this.logger.debug("ContainerControlConnection WebSocket closed");
|
|
2592
|
-
if (wasConnected) this.fireOnClose();
|
|
2593
|
-
};
|
|
2594
|
-
/**
|
|
2595
|
-
* WebSocket `error` listener. Same field-form rationale as
|
|
2596
|
-
* {@link onWebSocketClose}.
|
|
2597
|
-
*/
|
|
2598
|
-
onWebSocketError = () => {
|
|
2599
|
-
const wasConnected = this.connected;
|
|
2600
|
-
this.connected = false;
|
|
2601
|
-
this.ws = null;
|
|
2602
|
-
if (wasConnected) this.fireOnClose();
|
|
2603
|
-
};
|
|
2604
|
-
async doConnect() {
|
|
2605
|
-
try {
|
|
2606
|
-
const response = await this.fetchUpgradeWithRetry();
|
|
2607
|
-
if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
|
|
2608
|
-
const ws = response.webSocket;
|
|
2609
|
-
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
2610
|
-
ws.accept();
|
|
2611
|
-
ws.addEventListener("close", this.onWebSocketClose);
|
|
2612
|
-
ws.addEventListener("error", this.onWebSocketError);
|
|
2613
|
-
this.ws = ws;
|
|
2614
|
-
this.transport.activate(ws);
|
|
2615
|
-
this.connected = true;
|
|
2616
|
-
this.logger.debug("ContainerControlConnection established", { port: this.port });
|
|
2617
|
-
} catch (error) {
|
|
2618
|
-
this.connected = false;
|
|
2619
|
-
this.transport.abort(error);
|
|
2620
|
-
this.logger.error("ContainerControlConnection failed", error instanceof Error ? error : new Error(String(error)));
|
|
2621
|
-
throw error;
|
|
2622
|
-
}
|
|
1698
|
+
get processes() {
|
|
1699
|
+
return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
|
|
2623
1700
|
}
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
* unavailability responses until either the upgrade succeeds, a
|
|
2627
|
-
* non-retryable status is returned, or the retry budget runs out.
|
|
2628
|
-
*/
|
|
2629
|
-
async fetchUpgradeWithRetry() {
|
|
2630
|
-
return fetchWithResponseRetry(() => this.fetchUpgradeAttempt(), {
|
|
2631
|
-
retryTimeoutMs: this.retryTimeoutMs,
|
|
2632
|
-
minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS,
|
|
2633
|
-
logger: this.logger,
|
|
2634
|
-
retryLogMessage: "ContainerControlConnection upgrade returned retryable status, retrying",
|
|
2635
|
-
shouldRetry: isRetryableWebSocketUpgradeResponse
|
|
2636
|
-
});
|
|
1701
|
+
get ports() {
|
|
1702
|
+
return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
|
|
2637
1703
|
}
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
* each retry gets a fresh per-attempt connect timeout independent of the
|
|
2641
|
-
* total retry budget.
|
|
2642
|
-
*/
|
|
2643
|
-
async fetchUpgradeAttempt() {
|
|
2644
|
-
const controller = new AbortController();
|
|
2645
|
-
const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
|
|
2646
|
-
try {
|
|
2647
|
-
const url = `http://localhost:${this.port}/rpc`;
|
|
2648
|
-
const request = new Request(url, {
|
|
2649
|
-
headers: {
|
|
2650
|
-
Upgrade: "websocket",
|
|
2651
|
-
Connection: "Upgrade"
|
|
2652
|
-
},
|
|
2653
|
-
signal: controller.signal
|
|
2654
|
-
});
|
|
2655
|
-
return await this.containerStub.fetch(request);
|
|
2656
|
-
} finally {
|
|
2657
|
-
clearTimeout(timeout);
|
|
2658
|
-
}
|
|
2659
|
-
}
|
|
2660
|
-
};
|
|
2661
|
-
/**
|
|
2662
|
-
* RPC transport that queues sends and blocks receives until a WebSocket
|
|
2663
|
-
* is provided via `activate()`. Allows the RPC stub to be created before
|
|
2664
|
-
* the connection is established — queued calls flush automatically.
|
|
2665
|
-
*/
|
|
2666
|
-
var DeferredTransport = class {
|
|
2667
|
-
#ws = null;
|
|
2668
|
-
#sendQueue = [];
|
|
2669
|
-
#receiveQueue = [];
|
|
2670
|
-
#receiveResolver;
|
|
2671
|
-
#receiveRejecter;
|
|
2672
|
-
#error;
|
|
2673
|
-
activate(ws) {
|
|
2674
|
-
this.#ws = ws;
|
|
2675
|
-
ws.addEventListener("message", (event) => {
|
|
2676
|
-
if (this.#error) return;
|
|
2677
|
-
if (typeof event.data === "string") if (this.#receiveResolver) {
|
|
2678
|
-
this.#receiveResolver(event.data);
|
|
2679
|
-
this.#receiveResolver = void 0;
|
|
2680
|
-
this.#receiveRejecter = void 0;
|
|
2681
|
-
} else this.#receiveQueue.push(event.data);
|
|
2682
|
-
else this.#fail(/* @__PURE__ */ new TypeError("Received non-string message from WebSocket."));
|
|
2683
|
-
});
|
|
2684
|
-
ws.addEventListener("close", (event) => {
|
|
2685
|
-
this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
|
|
2686
|
-
});
|
|
2687
|
-
ws.addEventListener("error", () => {
|
|
2688
|
-
this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed."));
|
|
2689
|
-
});
|
|
2690
|
-
for (const msg of this.#sendQueue) ws.send(msg);
|
|
2691
|
-
this.#sendQueue = [];
|
|
2692
|
-
}
|
|
2693
|
-
async send(message) {
|
|
2694
|
-
if (this.#ws) this.#ws.send(message);
|
|
2695
|
-
else this.#sendQueue.push(message);
|
|
2696
|
-
}
|
|
2697
|
-
async receive() {
|
|
2698
|
-
if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
|
|
2699
|
-
if (this.#error) throw this.#error;
|
|
2700
|
-
return new Promise((resolve, reject) => {
|
|
2701
|
-
this.#receiveResolver = resolve;
|
|
2702
|
-
this.#receiveRejecter = reject;
|
|
2703
|
-
});
|
|
2704
|
-
}
|
|
2705
|
-
abort(reason) {
|
|
2706
|
-
this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
|
|
2707
|
-
if (this.#ws) {
|
|
2708
|
-
const message = reason instanceof Error ? reason.message : String(reason);
|
|
2709
|
-
this.#ws.close(3e3, message);
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
#fail(err) {
|
|
2713
|
-
if (this.#error) return;
|
|
2714
|
-
this.#error = err;
|
|
2715
|
-
this.#receiveRejecter?.(err);
|
|
2716
|
-
this.#receiveResolver = void 0;
|
|
2717
|
-
this.#receiveRejecter = void 0;
|
|
2718
|
-
}
|
|
2719
|
-
};
|
|
2720
|
-
|
|
2721
|
-
//#endregion
|
|
2722
|
-
//#region src/container-control/client.ts
|
|
2723
|
-
/** Close the idle capnweb WebSocket promptly so the DO can sleep. */
|
|
2724
|
-
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
2725
|
-
/**
|
|
2726
|
-
* How often the busy/idle poller samples `getStats()`.
|
|
2727
|
-
*
|
|
2728
|
-
* Sets two worst-case bounds:
|
|
2729
|
-
*
|
|
2730
|
-
* 1. **Idle-detection lag.** Time between the session going idle on
|
|
2731
|
-
* the wire and the DO observing it (and arming the disconnect).
|
|
2732
|
-
* Bounded by `pollInterval`.
|
|
2733
|
-
* 2. **Activity-renewal lag while busy.** While a stream is active we
|
|
2734
|
-
* renew the DO's activity timeout once per tick. The alarm could
|
|
2735
|
-
* fire as late as `sleepAfter` after the last renew, so the
|
|
2736
|
-
* effective margin against a mid-stream sleep is
|
|
2737
|
-
* `sleepAfter - pollInterval`.
|
|
2738
|
-
*
|
|
2739
|
-
* **Invariant: `pollInterval` must be comfortably less than the
|
|
2740
|
-
* smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
|
|
2741
|
-
* The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
|
|
2742
|
-
* 3× margin and at least two renewals during a 3s window. If a smaller
|
|
2743
|
-
* `sleepAfter` is ever supported, drop this proportionally.
|
|
2744
|
-
*/
|
|
2745
|
-
const BUSY_POLL_INTERVAL_MS = 1e3;
|
|
2746
|
-
/**
|
|
2747
|
-
* Baseline getStats() values for an idle session. The bootstrap stub on each
|
|
2748
|
-
* side accounts for 1 import and 1 export.
|
|
2749
|
-
*/
|
|
2750
|
-
const IDLE_IMPORT_THRESHOLD = 1;
|
|
2751
|
-
const IDLE_EXPORT_THRESHOLD = 1;
|
|
2752
|
-
/**
|
|
2753
|
-
* Translate a capnweb-propagated error into a typed SandboxError.
|
|
2754
|
-
*
|
|
2755
|
-
* Two wire formats are supported for backward compatibility with older
|
|
2756
|
-
* container images:
|
|
2757
|
-
*
|
|
2758
|
-
* 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
|
|
2759
|
-
* `ServiceError`-shaped object with own enumerable `code` and `details`
|
|
2760
|
-
* properties. capnweb walks `Object.keys()` and reconstructs those fields
|
|
2761
|
-
* on the SDK side.
|
|
2762
|
-
* 2. Legacy JSON-encoded message. Older containers encoded the structured
|
|
2763
|
-
* payload as a JSON string in `error.message`.
|
|
2764
|
-
*
|
|
2765
|
-
* The JSON-fallback branch can be removed once all older container images are
|
|
2766
|
-
* no longer in service.
|
|
2767
|
-
*/
|
|
2768
|
-
function translateRPCError(error) {
|
|
2769
|
-
if (error instanceof Error) {
|
|
2770
|
-
const propagated = error;
|
|
2771
|
-
if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
|
|
2772
|
-
const code = propagated.code;
|
|
2773
|
-
const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
|
|
2774
|
-
throw createErrorFromResponse({
|
|
2775
|
-
code,
|
|
2776
|
-
message: error.message,
|
|
2777
|
-
context,
|
|
2778
|
-
httpStatus: getHttpStatus(code),
|
|
2779
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2780
|
-
});
|
|
2781
|
-
}
|
|
2782
|
-
let payload;
|
|
2783
|
-
try {
|
|
2784
|
-
payload = JSON.parse(error.message);
|
|
2785
|
-
} catch {}
|
|
2786
|
-
if (payload && typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
|
|
2787
|
-
code: payload.code,
|
|
2788
|
-
message: payload.message,
|
|
2789
|
-
context: payload.context ?? {},
|
|
2790
|
-
httpStatus: getHttpStatus(payload.code),
|
|
2791
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2792
|
-
});
|
|
2793
|
-
throw createErrorFromResponse(buildTransportErrorResponse(error), { cause: error });
|
|
2794
|
-
}
|
|
2795
|
-
throw createErrorFromResponse(buildTransportErrorResponse(new Error(String(error))), { cause: error });
|
|
2796
|
-
}
|
|
2797
|
-
/**
|
|
2798
|
-
* Inspect a transport-level Error's message and produce the ErrorResponse
|
|
2799
|
-
* that becomes an RPCTransportError. Pattern strings are pinned to the exact
|
|
2800
|
-
* messages emitted by capnweb's WebSocketTransport (see capnweb's
|
|
2801
|
-
* src/websocket.ts) and our DeferredTransport in container-control/connection.ts —
|
|
2802
|
-
* notably the trailing period in `WebSocket connection failed.` matches
|
|
2803
|
-
* capnweb verbatim. The DeferredTransport tests in
|
|
2804
|
-
* tests/container-connection.test.ts pin the literal strings.
|
|
2805
|
-
*/
|
|
2806
|
-
function buildTransportErrorResponse(error) {
|
|
2807
|
-
const message = error.message;
|
|
2808
|
-
const errorName = error.name;
|
|
2809
|
-
let kind = "unknown";
|
|
2810
|
-
let closeCode;
|
|
2811
|
-
let closeReason;
|
|
2812
|
-
if (errorName === "TypeError") kind = "invalid_frame";
|
|
2813
|
-
else if (errorName === "SyntaxError") kind = "protocol_error";
|
|
2814
|
-
else {
|
|
2815
|
-
const peerCloseMatch = message.match(/^Peer closed WebSocket: (\d+) ?(.*)$/);
|
|
2816
|
-
if (peerCloseMatch) {
|
|
2817
|
-
kind = "peer_closed";
|
|
2818
|
-
closeCode = Number(peerCloseMatch[1]);
|
|
2819
|
-
closeReason = peerCloseMatch[2] || void 0;
|
|
2820
|
-
} else if (message === "WebSocket connection failed.") kind = "connection_failed";
|
|
2821
|
-
else if (message.startsWith("WebSocket upgrade failed")) kind = "upgrade_failed";
|
|
2822
|
-
else if (message === "No WebSocket in upgrade response") kind = "upgrade_failed";
|
|
2823
|
-
else if (message === "RPC session was shut down by disposing the main stub" || message === "RPC was canceled because the RpcPromise was disposed.") kind = "session_disposed";
|
|
2824
|
-
}
|
|
2825
|
-
const context = {
|
|
2826
|
-
kind,
|
|
2827
|
-
originalMessage: message,
|
|
2828
|
-
errorName,
|
|
2829
|
-
...closeCode !== void 0 ? { closeCode } : {},
|
|
2830
|
-
...closeReason !== void 0 ? { closeReason } : {}
|
|
2831
|
-
};
|
|
2832
|
-
return {
|
|
2833
|
-
code: ErrorCode.RPC_TRANSPORT_ERROR,
|
|
2834
|
-
message,
|
|
2835
|
-
context,
|
|
2836
|
-
httpStatus: getHttpStatus(ErrorCode.RPC_TRANSPORT_ERROR),
|
|
2837
|
-
suggestion: getSuggestion(ErrorCode.RPC_TRANSPORT_ERROR, context),
|
|
2838
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2839
|
-
};
|
|
2840
|
-
}
|
|
2841
|
-
/**
|
|
2842
|
-
* Wrap a capnweb RPC stub so that every method call translates errors
|
|
2843
|
-
* from the JSON wire format into typed SandboxError instances and signals
|
|
2844
|
-
* activity at call start.
|
|
2845
|
-
*
|
|
2846
|
-
* `onCallStarted` fires synchronously when an RPC method is invoked. The
|
|
2847
|
-
* ContainerControlClient uses this to renew the DO's activity timeout
|
|
2848
|
-
* immediately, so even a call that completes entirely between two
|
|
2849
|
-
* busy-poll ticks still pushes the sleepAfter deadline forward.
|
|
2850
|
-
*
|
|
2851
|
-
* Note: there is no `onCallSettled` hook. A method whose returned promise
|
|
2852
|
-
* resolves with a `ReadableStream` is *not* finished when the promise
|
|
2853
|
-
* settles — capnweb keeps the export alive until the stream ends. The
|
|
2854
|
-
* busy/idle poll on `getStats()` is the source of truth for that.
|
|
2855
|
-
*/
|
|
2856
|
-
function wrapStub(stub, onCallStarted) {
|
|
2857
|
-
return new Proxy(stub, { get(target, prop, receiver) {
|
|
2858
|
-
const value = Reflect.get(target, prop, receiver);
|
|
2859
|
-
if (typeof value !== "function") return value;
|
|
2860
|
-
return (...args) => {
|
|
2861
|
-
onCallStarted();
|
|
2862
|
-
try {
|
|
2863
|
-
const result = Reflect.apply(value, target, args);
|
|
2864
|
-
if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
|
|
2865
|
-
return result;
|
|
2866
|
-
} catch (err) {
|
|
2867
|
-
translateRPCError(err);
|
|
2868
|
-
}
|
|
2869
|
-
};
|
|
2870
|
-
} });
|
|
2871
|
-
}
|
|
2872
|
-
/**
|
|
2873
|
-
* SandboxClient-compatible facade backed by direct capnweb RPC.
|
|
2874
|
-
*
|
|
2875
|
-
* All operations call the container's SandboxAPI control interface directly
|
|
2876
|
-
* over capnweb, bypassing the HTTP handler/router layer entirely.
|
|
2877
|
-
*
|
|
2878
|
-
* Manages its own WebSocket lifecycle: a fresh `ContainerControlConnection` is
|
|
2879
|
-
* created on demand and torn down after `idleDisconnectMs` of inactivity.
|
|
2880
|
-
* Busy/idle detection relies on `RpcSession.getStats()` which tracks all
|
|
2881
|
-
* in-flight RPC calls and stream exports — including long-lived streaming
|
|
2882
|
-
* RPCs that would be invisible to a simple per-call request counter (see
|
|
2883
|
-
* the file-level comment for the full rationale).
|
|
2884
|
-
*/
|
|
2885
|
-
var ContainerControlClient = class {
|
|
2886
|
-
connOptions;
|
|
2887
|
-
idleDisconnectMs;
|
|
2888
|
-
busyPollIntervalMs;
|
|
2889
|
-
logger;
|
|
2890
|
-
onActivity;
|
|
2891
|
-
onSessionBusy;
|
|
2892
|
-
onSessionIdle;
|
|
2893
|
-
conn = null;
|
|
2894
|
-
idleTimer = null;
|
|
2895
|
-
busyPollTimer = null;
|
|
2896
|
-
/** Tracks whether we currently believe the session is busy. */
|
|
2897
|
-
busy = false;
|
|
2898
|
-
constructor(options) {
|
|
2899
|
-
this.connOptions = {
|
|
2900
|
-
stub: options.stub,
|
|
2901
|
-
port: options.port,
|
|
2902
|
-
localMain: options.localMain,
|
|
2903
|
-
logger: options.logger,
|
|
2904
|
-
retryTimeoutMs: options.retryTimeoutMs,
|
|
2905
|
-
onClose: () => {
|
|
2906
|
-
if (this.conn) this.destroyConnection();
|
|
2907
|
-
}
|
|
2908
|
-
};
|
|
2909
|
-
this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
|
|
2910
|
-
this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
|
|
2911
|
-
this.logger = options.logger ?? createNoOpLogger();
|
|
2912
|
-
this.onActivity = options.onActivity;
|
|
2913
|
-
this.onSessionBusy = options.onSessionBusy;
|
|
2914
|
-
this.onSessionIdle = options.onSessionIdle;
|
|
2915
|
-
}
|
|
2916
|
-
/**
|
|
2917
|
-
* Return the current connection, creating one when the client is disconnected.
|
|
2918
|
-
* Starts the busy-poll timer the first time a connection is materialized.
|
|
2919
|
-
*/
|
|
2920
|
-
getConnection() {
|
|
2921
|
-
if (!this.conn) {
|
|
2922
|
-
this.conn = new ContainerControlConnection(this.connOptions);
|
|
2923
|
-
this.startBusyPoll();
|
|
2924
|
-
}
|
|
2925
|
-
return this.conn;
|
|
2926
|
-
}
|
|
2927
|
-
/**
|
|
2928
|
-
* Called synchronously at the start of each RPC method invocation.
|
|
2929
|
-
* Renews the DO activity timeout so the sleepAfter alarm is pushed
|
|
2930
|
-
* forward before the container processes the call.
|
|
2931
|
-
*/
|
|
2932
|
-
renewActivity = () => {
|
|
2933
|
-
this.onActivity?.();
|
|
2934
|
-
};
|
|
2935
|
-
/**
|
|
2936
|
-
* Sample `getStats()` and update busy/idle state. While busy, renews the
|
|
2937
|
-
* activity timeout each tick so an in-flight stream keeps pushing the
|
|
2938
|
-
* sleepAfter deadline forward. On the busy → idle edge, fires
|
|
2939
|
-
* `onSessionIdle` and schedules the WebSocket disconnect.
|
|
2940
|
-
*
|
|
2941
|
-
* If the WebSocket has dropped underneath us (container crash, network
|
|
2942
|
-
* blip) we tear the connection down here. `destroyConnection()` fires
|
|
2943
|
-
* `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
|
|
2944
|
-
* stay pinned forever waiting for a peer that's never going to reply.
|
|
2945
|
-
*/
|
|
2946
|
-
pollBusyState = () => {
|
|
2947
|
-
const conn = this.conn;
|
|
2948
|
-
if (!conn) return;
|
|
2949
|
-
if (!conn.isConnected()) return;
|
|
2950
|
-
const { imports, exports } = conn.getStats();
|
|
2951
|
-
if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
|
|
2952
|
-
if (!this.busy) {
|
|
2953
|
-
this.busy = true;
|
|
2954
|
-
this.onSessionBusy?.();
|
|
2955
|
-
}
|
|
2956
|
-
this.onActivity?.();
|
|
2957
|
-
this.clearIdleTimer();
|
|
2958
|
-
} else if (this.busy) {
|
|
2959
|
-
this.busy = false;
|
|
2960
|
-
this.onSessionIdle?.();
|
|
2961
|
-
this.scheduleIdleDisconnect();
|
|
2962
|
-
} else if (!this.idleTimer) this.scheduleIdleDisconnect();
|
|
2963
|
-
};
|
|
2964
|
-
startBusyPoll() {
|
|
2965
|
-
if (this.busyPollTimer) return;
|
|
2966
|
-
this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
|
|
2967
|
-
}
|
|
2968
|
-
stopBusyPoll() {
|
|
2969
|
-
if (this.busyPollTimer) {
|
|
2970
|
-
clearInterval(this.busyPollTimer);
|
|
2971
|
-
this.busyPollTimer = null;
|
|
2972
|
-
}
|
|
2973
|
-
}
|
|
2974
|
-
scheduleIdleDisconnect() {
|
|
2975
|
-
this.clearIdleTimer();
|
|
2976
|
-
this.idleTimer = setTimeout(() => {
|
|
2977
|
-
this.idleTimer = null;
|
|
2978
|
-
const conn = this.conn;
|
|
2979
|
-
if (!conn || !conn.isConnected()) return;
|
|
2980
|
-
const { imports, exports } = conn.getStats();
|
|
2981
|
-
if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
|
|
2982
|
-
this.logger.debug("Disconnecting idle RPC connection");
|
|
2983
|
-
this.destroyConnection();
|
|
2984
|
-
}
|
|
2985
|
-
}, this.idleDisconnectMs);
|
|
2986
|
-
}
|
|
2987
|
-
clearIdleTimer() {
|
|
2988
|
-
if (this.idleTimer) {
|
|
2989
|
-
clearTimeout(this.idleTimer);
|
|
2990
|
-
this.idleTimer = null;
|
|
2991
|
-
}
|
|
2992
|
-
}
|
|
2993
|
-
destroyConnection() {
|
|
2994
|
-
this.stopBusyPoll();
|
|
2995
|
-
this.clearIdleTimer();
|
|
2996
|
-
if (this.busy) {
|
|
2997
|
-
this.busy = false;
|
|
2998
|
-
this.onSessionIdle?.();
|
|
2999
|
-
}
|
|
3000
|
-
if (this.conn) {
|
|
3001
|
-
this.conn.disconnect();
|
|
3002
|
-
this.conn = null;
|
|
3003
|
-
}
|
|
3004
|
-
}
|
|
3005
|
-
get commands() {
|
|
3006
|
-
return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
|
|
3007
|
-
}
|
|
3008
|
-
get files() {
|
|
3009
|
-
return wrapStub(this.getConnection().rpc().files, this.renewActivity);
|
|
3010
|
-
}
|
|
3011
|
-
get processes() {
|
|
3012
|
-
return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
|
|
3013
|
-
}
|
|
3014
|
-
get ports() {
|
|
3015
|
-
return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
|
|
3016
|
-
}
|
|
3017
|
-
get git() {
|
|
3018
|
-
return wrapStub(this.getConnection().rpc().git, this.renewActivity);
|
|
3019
|
-
}
|
|
3020
|
-
get utils() {
|
|
3021
|
-
return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
|
|
3022
|
-
}
|
|
3023
|
-
get backup() {
|
|
3024
|
-
return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
|
|
3025
|
-
}
|
|
3026
|
-
get watch() {
|
|
3027
|
-
return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
|
|
3028
|
-
}
|
|
3029
|
-
get tunnels() {
|
|
3030
|
-
return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
|
|
3031
|
-
}
|
|
3032
|
-
get interpreter() {
|
|
3033
|
-
return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
|
|
3034
|
-
}
|
|
3035
|
-
/**
|
|
3036
|
-
* Update the upgrade retry budget. Applies to the current connection
|
|
3037
|
-
* (if any) and is remembered for any future connections created after the
|
|
3038
|
-
* client is torn down and reconnected.
|
|
3039
|
-
*/
|
|
3040
|
-
setRetryTimeoutMs(ms) {
|
|
3041
|
-
this.connOptions.retryTimeoutMs = ms;
|
|
3042
|
-
this.conn?.setRetryTimeoutMs(ms);
|
|
3043
|
-
}
|
|
3044
|
-
getTransportMode() {
|
|
3045
|
-
return "rpc";
|
|
3046
|
-
}
|
|
3047
|
-
isWebSocketConnected() {
|
|
3048
|
-
return this.conn?.isConnected() ?? false;
|
|
3049
|
-
}
|
|
3050
|
-
async connect() {
|
|
3051
|
-
await this.getConnection().connect();
|
|
3052
|
-
}
|
|
3053
|
-
disconnect() {
|
|
3054
|
-
this.destroyConnection();
|
|
3055
|
-
}
|
|
3056
|
-
};
|
|
3057
|
-
|
|
3058
|
-
//#endregion
|
|
3059
|
-
//#region src/current-runtime-identity.ts
|
|
3060
|
-
const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
|
|
3061
|
-
var RuntimeIdentityInactiveError = class extends Error {
|
|
3062
|
-
constructor() {
|
|
3063
|
-
super("Runtime identity is no longer active");
|
|
3064
|
-
this.name = "RuntimeIdentityInactiveError";
|
|
3065
|
-
}
|
|
3066
|
-
};
|
|
3067
|
-
var RuntimeIdentity = class {
|
|
3068
|
-
id;
|
|
3069
|
-
constructor(record) {
|
|
3070
|
-
this.id = record.id;
|
|
3071
|
-
}
|
|
3072
|
-
owns(record) {
|
|
3073
|
-
return record.runtimeIdentityID === this.id;
|
|
3074
|
-
}
|
|
3075
|
-
scope(value) {
|
|
3076
|
-
return {
|
|
3077
|
-
...value,
|
|
3078
|
-
runtimeIdentityID: this.id
|
|
3079
|
-
};
|
|
3080
|
-
}
|
|
3081
|
-
};
|
|
3082
|
-
var CurrentRuntimeIdentity = class {
|
|
3083
|
-
/**
|
|
3084
|
-
* Runtime identity is stored in Durable Object storage so a reconstructed DO
|
|
3085
|
-
* can still recognize the live container runtime it owns. In-memory state is
|
|
3086
|
-
* only a cache and cannot define runtime-scoped correctness.
|
|
3087
|
-
*/
|
|
3088
|
-
constructor(storage, getContainerState, isContainerRunning) {
|
|
3089
|
-
this.storage = storage;
|
|
3090
|
-
this.getContainerState = getContainerState;
|
|
3091
|
-
this.isContainerRunning = isContainerRunning;
|
|
3092
|
-
}
|
|
3093
|
-
async get() {
|
|
3094
|
-
const status = await this.getStatus();
|
|
3095
|
-
return status.status === "active" ? status.runtime : null;
|
|
3096
|
-
}
|
|
3097
|
-
async getStatus() {
|
|
3098
|
-
const state = await this.getContainerState();
|
|
3099
|
-
if (state.status !== "healthy") return {
|
|
3100
|
-
status: "inactive",
|
|
3101
|
-
reason: "runtime-not-healthy",
|
|
3102
|
-
containerStatus: state.status
|
|
3103
|
-
};
|
|
3104
|
-
if (!this.isContainerRunning()) return {
|
|
3105
|
-
status: "inactive",
|
|
3106
|
-
reason: "runtime-not-running",
|
|
3107
|
-
containerStatus: state.status
|
|
3108
|
-
};
|
|
3109
|
-
const runtime = await this.getStored();
|
|
3110
|
-
if (!runtime) return {
|
|
3111
|
-
status: "inactive",
|
|
3112
|
-
reason: "missing-runtime-id",
|
|
3113
|
-
containerStatus: state.status
|
|
3114
|
-
};
|
|
3115
|
-
return {
|
|
3116
|
-
status: "active",
|
|
3117
|
-
runtime,
|
|
3118
|
-
containerStatus: state.status
|
|
3119
|
-
};
|
|
3120
|
-
}
|
|
3121
|
-
async getStored(storage = this.storage) {
|
|
3122
|
-
const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
|
|
3123
|
-
return record ? new RuntimeIdentity(record) : null;
|
|
3124
|
-
}
|
|
3125
|
-
async markStarted() {
|
|
3126
|
-
const record = { id: crypto.randomUUID() };
|
|
3127
|
-
await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
|
|
3128
|
-
return new RuntimeIdentity(record);
|
|
3129
|
-
}
|
|
3130
|
-
async clear() {
|
|
3131
|
-
await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
|
|
3132
|
-
}
|
|
3133
|
-
async isActive(runtime) {
|
|
3134
|
-
return (await this.get())?.id === runtime.id;
|
|
3135
|
-
}
|
|
3136
|
-
async assertActive(runtime) {
|
|
3137
|
-
if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
|
|
3138
|
-
}
|
|
3139
|
-
};
|
|
3140
|
-
|
|
3141
|
-
//#endregion
|
|
3142
|
-
//#region src/file-stream.ts
|
|
3143
|
-
/**
|
|
3144
|
-
* Parse SSE (Server-Sent Events) lines from a stream
|
|
3145
|
-
*/
|
|
3146
|
-
async function* parseSSE(stream) {
|
|
3147
|
-
const reader = stream.getReader();
|
|
3148
|
-
const decoder = new TextDecoder();
|
|
3149
|
-
let buffer = "";
|
|
3150
|
-
let currentEvent = { data: [] };
|
|
3151
|
-
try {
|
|
3152
|
-
while (true) {
|
|
3153
|
-
const { done, value } = await reader.read();
|
|
3154
|
-
if (done) break;
|
|
3155
|
-
buffer += decoder.decode(value, { stream: true });
|
|
3156
|
-
const parsed = parseSSEFrames(buffer, currentEvent);
|
|
3157
|
-
buffer = parsed.remaining;
|
|
3158
|
-
currentEvent = parsed.currentEvent;
|
|
3159
|
-
for (const frame of parsed.events) try {
|
|
3160
|
-
yield JSON.parse(frame.data);
|
|
3161
|
-
} catch {}
|
|
3162
|
-
}
|
|
3163
|
-
const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
|
|
3164
|
-
for (const frame of finalParsed.events) try {
|
|
3165
|
-
yield JSON.parse(frame.data);
|
|
3166
|
-
} catch {}
|
|
3167
|
-
} finally {
|
|
3168
|
-
try {
|
|
3169
|
-
await reader.cancel();
|
|
3170
|
-
} catch {}
|
|
3171
|
-
reader.releaseLock();
|
|
3172
|
-
}
|
|
3173
|
-
}
|
|
3174
|
-
/**
|
|
3175
|
-
* Stream a file from the sandbox with automatic base64 decoding for binary files
|
|
3176
|
-
*
|
|
3177
|
-
* @param stream - The ReadableStream from readFileStream()
|
|
3178
|
-
* @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary)
|
|
3179
|
-
*
|
|
3180
|
-
* @example
|
|
3181
|
-
* ```ts
|
|
3182
|
-
* const stream = await sandbox.readFileStream('/path/to/file.png');
|
|
3183
|
-
* for await (const chunk of streamFile(stream)) {
|
|
3184
|
-
* if (chunk instanceof Uint8Array) {
|
|
3185
|
-
* // Binary chunk
|
|
3186
|
-
* console.log('Binary chunk:', chunk.length, 'bytes');
|
|
3187
|
-
* } else {
|
|
3188
|
-
* // Text chunk
|
|
3189
|
-
* console.log('Text chunk:', chunk);
|
|
3190
|
-
* }
|
|
3191
|
-
* }
|
|
3192
|
-
* ```
|
|
3193
|
-
*/
|
|
3194
|
-
async function* streamFile(stream) {
|
|
3195
|
-
let metadata = null;
|
|
3196
|
-
for await (const event of parseSSE(stream)) switch (event.type) {
|
|
3197
|
-
case "metadata":
|
|
3198
|
-
metadata = {
|
|
3199
|
-
mimeType: event.mimeType,
|
|
3200
|
-
size: event.size,
|
|
3201
|
-
isBinary: event.isBinary,
|
|
3202
|
-
encoding: event.encoding
|
|
3203
|
-
};
|
|
3204
|
-
break;
|
|
3205
|
-
case "chunk":
|
|
3206
|
-
if (!metadata) throw new Error("Received chunk before metadata");
|
|
3207
|
-
if (metadata.isBinary && metadata.encoding === "base64") {
|
|
3208
|
-
const binaryString = atob(event.data);
|
|
3209
|
-
const bytes = new Uint8Array(binaryString.length);
|
|
3210
|
-
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
3211
|
-
yield bytes;
|
|
3212
|
-
} else yield event.data;
|
|
3213
|
-
break;
|
|
3214
|
-
case "complete":
|
|
3215
|
-
if (!metadata) throw new Error("Stream completed without metadata");
|
|
3216
|
-
return metadata;
|
|
3217
|
-
case "error": throw new Error(`File streaming error: ${event.error}`);
|
|
1704
|
+
get git() {
|
|
1705
|
+
return wrapStub(this.getConnection().rpc().git, this.renewActivity);
|
|
3218
1706
|
}
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
/**
|
|
3222
|
-
* Collect an entire file into memory from a stream
|
|
3223
|
-
*
|
|
3224
|
-
* @param stream - The ReadableStream from readFileStream()
|
|
3225
|
-
* @returns Object containing the file content and metadata
|
|
3226
|
-
*
|
|
3227
|
-
* @example
|
|
3228
|
-
* ```ts
|
|
3229
|
-
* const stream = await sandbox.readFileStream('/path/to/file.txt');
|
|
3230
|
-
* const { content, metadata } = await collectFile(stream);
|
|
3231
|
-
* console.log('Content:', content);
|
|
3232
|
-
* console.log('MIME type:', metadata.mimeType);
|
|
3233
|
-
* ```
|
|
3234
|
-
*/
|
|
3235
|
-
async function collectFile(stream) {
|
|
3236
|
-
const chunks = [];
|
|
3237
|
-
const generator = streamFile(stream);
|
|
3238
|
-
let result = await generator.next();
|
|
3239
|
-
while (!result.done) {
|
|
3240
|
-
chunks.push(result.value);
|
|
3241
|
-
result = await generator.next();
|
|
1707
|
+
get utils() {
|
|
1708
|
+
return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
|
|
3242
1709
|
}
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
if (metadata.isBinary) {
|
|
3246
|
-
const totalLength = chunks.reduce((sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), 0);
|
|
3247
|
-
const combined = new Uint8Array(totalLength);
|
|
3248
|
-
let offset = 0;
|
|
3249
|
-
for (const chunk of chunks) if (chunk instanceof Uint8Array) {
|
|
3250
|
-
combined.set(chunk, offset);
|
|
3251
|
-
offset += chunk.length;
|
|
3252
|
-
}
|
|
3253
|
-
return {
|
|
3254
|
-
content: combined,
|
|
3255
|
-
metadata
|
|
3256
|
-
};
|
|
3257
|
-
} else return {
|
|
3258
|
-
content: chunks.filter((c) => typeof c === "string").join(""),
|
|
3259
|
-
metadata
|
|
3260
|
-
};
|
|
3261
|
-
}
|
|
3262
|
-
|
|
3263
|
-
//#endregion
|
|
3264
|
-
//#region src/security.ts
|
|
3265
|
-
/**
|
|
3266
|
-
* Security utilities for URL construction and input validation
|
|
3267
|
-
*
|
|
3268
|
-
* This module contains critical security functions to prevent:
|
|
3269
|
-
* - URL injection attacks
|
|
3270
|
-
* - SSRF (Server-Side Request Forgery) attacks
|
|
3271
|
-
* - DNS rebinding attacks
|
|
3272
|
-
* - Host header injection
|
|
3273
|
-
* - Open redirect vulnerabilities
|
|
3274
|
-
*/
|
|
3275
|
-
var SandboxSecurityError = class extends Error {
|
|
3276
|
-
constructor(message, code) {
|
|
3277
|
-
super(message);
|
|
3278
|
-
this.code = code;
|
|
3279
|
-
this.name = "SandboxSecurityError";
|
|
1710
|
+
get backup() {
|
|
1711
|
+
return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
|
|
3280
1712
|
}
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
* Validates port numbers for sandbox services.
|
|
3284
|
-
*
|
|
3285
|
-
* Rules:
|
|
3286
|
-
* - Range: 1024-65535 (privileged ports require root, which containers don't have)
|
|
3287
|
-
* - Reserved: 3000 (sandbox control plane)
|
|
3288
|
-
*/
|
|
3289
|
-
function validatePort(port) {
|
|
3290
|
-
if (!Number.isInteger(port)) return false;
|
|
3291
|
-
if (port < 1024 || port > 65535) return false;
|
|
3292
|
-
if ([3e3].includes(port)) return false;
|
|
3293
|
-
return true;
|
|
3294
|
-
}
|
|
3295
|
-
/**
|
|
3296
|
-
* Sanitizes and validates sandbox IDs for DNS compliance and security
|
|
3297
|
-
* Only enforces critical requirements - allows maximum developer flexibility
|
|
3298
|
-
*/
|
|
3299
|
-
function sanitizeSandboxId(id) {
|
|
3300
|
-
if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
|
|
3301
|
-
if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
|
|
3302
|
-
const reservedNames = [
|
|
3303
|
-
"www",
|
|
3304
|
-
"api",
|
|
3305
|
-
"admin",
|
|
3306
|
-
"root",
|
|
3307
|
-
"system",
|
|
3308
|
-
"cloudflare",
|
|
3309
|
-
"workers"
|
|
3310
|
-
];
|
|
3311
|
-
const lowerCaseId = id.toLowerCase();
|
|
3312
|
-
if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
|
|
3313
|
-
return id;
|
|
3314
|
-
}
|
|
3315
|
-
/**
|
|
3316
|
-
* Validates language for code interpreter
|
|
3317
|
-
* Only allows supported languages
|
|
3318
|
-
*/
|
|
3319
|
-
function validateLanguage(language) {
|
|
3320
|
-
if (!language) return;
|
|
3321
|
-
const supportedLanguages = [
|
|
3322
|
-
"python",
|
|
3323
|
-
"python3",
|
|
3324
|
-
"javascript",
|
|
3325
|
-
"js",
|
|
3326
|
-
"node",
|
|
3327
|
-
"typescript",
|
|
3328
|
-
"ts"
|
|
3329
|
-
];
|
|
3330
|
-
const normalized = language.toLowerCase();
|
|
3331
|
-
if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
|
|
3332
|
-
}
|
|
3333
|
-
/**
|
|
3334
|
-
* Validates a single DNS label for use as a Cloudflare Tunnel hostname.
|
|
3335
|
-
*
|
|
3336
|
-
* Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
|
|
3337
|
-
* input client-side before any network call. Whether the chosen label is
|
|
3338
|
-
* actually available under the configured zone is left to the Cloudflare
|
|
3339
|
-
* API (returned as a typed error).
|
|
3340
|
-
*
|
|
3341
|
-
* Rules:
|
|
3342
|
-
* - 1–63 characters
|
|
3343
|
-
* - Lowercase letters, digits, and internal hyphens only
|
|
3344
|
-
* - No leading or trailing hyphen
|
|
3345
|
-
* - No dots — multi-label hostnames need a delegated subdomain zone or
|
|
3346
|
-
* Advanced Certificate Manager, which are out of scope for this
|
|
3347
|
-
* feature. Universal SSL only covers `<label>.<zone>`.
|
|
3348
|
-
*
|
|
3349
|
-
* Throws `SandboxSecurityError` on any violation. Designed to be called
|
|
3350
|
-
* before any other tunnel work so callers see a fast, deterministic
|
|
3351
|
-
* failure.
|
|
3352
|
-
*/
|
|
3353
|
-
const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
3354
|
-
function validateTunnelName(name) {
|
|
3355
|
-
if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
|
|
3356
|
-
if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
|
|
3357
|
-
if (!TUNNEL_NAME_REGEX.test(name)) throw new SandboxSecurityError(`Tunnel name '${name}' is not a valid DNS label. Use lowercase letters, digits, and internal hyphens only (no dots, no leading/trailing hyphens).`, "INVALID_TUNNEL_NAME_FORMAT");
|
|
3358
|
-
}
|
|
3359
|
-
|
|
3360
|
-
//#endregion
|
|
3361
|
-
//#region src/interpreter.ts
|
|
3362
|
-
var CodeInterpreter = class {
|
|
3363
|
-
getInterpreterClient;
|
|
3364
|
-
contexts = /* @__PURE__ */ new Map();
|
|
3365
|
-
constructor(interpreterClient) {
|
|
3366
|
-
this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
|
|
1713
|
+
get watch() {
|
|
1714
|
+
return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
|
|
3367
1715
|
}
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
*/
|
|
3371
|
-
async createCodeContext(options = {}) {
|
|
3372
|
-
validateLanguage(options.language);
|
|
3373
|
-
const context = await this.getInterpreterClient().createCodeContext(options);
|
|
3374
|
-
this.contexts.set(context.id, context);
|
|
3375
|
-
return context;
|
|
1716
|
+
get tunnels() {
|
|
1717
|
+
return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
|
|
3376
1718
|
}
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
*/
|
|
3380
|
-
async runCode(code, options = {}) {
|
|
3381
|
-
let context = options.context;
|
|
3382
|
-
if (!context) {
|
|
3383
|
-
const language = options.language || "python";
|
|
3384
|
-
context = await this.getOrCreateDefaultContext(language);
|
|
3385
|
-
}
|
|
3386
|
-
const execution = new Execution(code, context);
|
|
3387
|
-
await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
|
|
3388
|
-
onStdout: (output) => {
|
|
3389
|
-
execution.logs.stdout.push(output.text);
|
|
3390
|
-
if (options.onStdout) return options.onStdout(output);
|
|
3391
|
-
},
|
|
3392
|
-
onStderr: (output) => {
|
|
3393
|
-
execution.logs.stderr.push(output.text);
|
|
3394
|
-
if (options.onStderr) return options.onStderr(output);
|
|
3395
|
-
},
|
|
3396
|
-
onResult: async (result) => {
|
|
3397
|
-
execution.results.push(new ResultImpl(result));
|
|
3398
|
-
if (options.onResult) return options.onResult(result);
|
|
3399
|
-
},
|
|
3400
|
-
onError: (error) => {
|
|
3401
|
-
execution.error = error;
|
|
3402
|
-
if (options.onError) return options.onError(error);
|
|
3403
|
-
}
|
|
3404
|
-
});
|
|
3405
|
-
return execution;
|
|
1719
|
+
get interpreter() {
|
|
1720
|
+
return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
|
|
3406
1721
|
}
|
|
3407
1722
|
/**
|
|
3408
|
-
*
|
|
1723
|
+
* Update the upgrade retry budget. Applies to the current connection
|
|
1724
|
+
* (if any) and is remembered for any future connections created after the
|
|
1725
|
+
* client is torn down and reconnected.
|
|
3409
1726
|
*/
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
const language = options.language || "python";
|
|
3414
|
-
context = await this.getOrCreateDefaultContext(language);
|
|
3415
|
-
}
|
|
3416
|
-
return this.getInterpreterClient().streamCode(context.id, code, options.language);
|
|
1727
|
+
setRetryTimeoutMs(ms) {
|
|
1728
|
+
this.connOptions.retryTimeoutMs = ms;
|
|
1729
|
+
this.conn?.setRetryTimeoutMs(ms);
|
|
3417
1730
|
}
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
async
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
1731
|
+
isWebSocketConnected() {
|
|
1732
|
+
return this.conn?.isConnected() ?? false;
|
|
1733
|
+
}
|
|
1734
|
+
async connect() {
|
|
1735
|
+
await this.getConnection().connect();
|
|
1736
|
+
}
|
|
1737
|
+
disconnect() {
|
|
1738
|
+
this.destroyConnection();
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
//#endregion
|
|
1743
|
+
//#region src/current-runtime-identity.ts
|
|
1744
|
+
const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
|
|
1745
|
+
var RuntimeIdentityInactiveError = class extends Error {
|
|
1746
|
+
constructor() {
|
|
1747
|
+
super("Runtime identity is no longer active");
|
|
1748
|
+
this.name = "RuntimeIdentityInactiveError";
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
var RuntimeIdentity = class {
|
|
1752
|
+
id;
|
|
1753
|
+
constructor(record) {
|
|
1754
|
+
this.id = record.id;
|
|
1755
|
+
}
|
|
1756
|
+
owns(record) {
|
|
1757
|
+
return record.runtimeIdentityID === this.id;
|
|
1758
|
+
}
|
|
1759
|
+
scope(value) {
|
|
1760
|
+
return {
|
|
1761
|
+
...value,
|
|
1762
|
+
runtimeIdentityID: this.id
|
|
1763
|
+
};
|
|
3425
1764
|
}
|
|
1765
|
+
};
|
|
1766
|
+
var CurrentRuntimeIdentity = class {
|
|
3426
1767
|
/**
|
|
3427
|
-
*
|
|
1768
|
+
* Runtime identity is stored in Durable Object storage so a reconstructed DO
|
|
1769
|
+
* can still recognize the live container runtime it owns. In-memory state is
|
|
1770
|
+
* only a cache and cannot define runtime-scoped correctness.
|
|
3428
1771
|
*/
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
this.
|
|
1772
|
+
constructor(storage, getContainerState, isContainerRunning) {
|
|
1773
|
+
this.storage = storage;
|
|
1774
|
+
this.getContainerState = getContainerState;
|
|
1775
|
+
this.isContainerRunning = isContainerRunning;
|
|
3432
1776
|
}
|
|
3433
|
-
async
|
|
3434
|
-
|
|
3435
|
-
return
|
|
1777
|
+
async get() {
|
|
1778
|
+
const status = await this.getStatus();
|
|
1779
|
+
return status.status === "active" ? status.runtime : null;
|
|
1780
|
+
}
|
|
1781
|
+
async getStatus() {
|
|
1782
|
+
const state = await this.getContainerState();
|
|
1783
|
+
if (state.status !== "healthy") return {
|
|
1784
|
+
status: "inactive",
|
|
1785
|
+
reason: "runtime-not-healthy",
|
|
1786
|
+
containerStatus: state.status
|
|
1787
|
+
};
|
|
1788
|
+
if (!this.isContainerRunning()) return {
|
|
1789
|
+
status: "inactive",
|
|
1790
|
+
reason: "runtime-not-running",
|
|
1791
|
+
containerStatus: state.status
|
|
1792
|
+
};
|
|
1793
|
+
const runtime = await this.getStored();
|
|
1794
|
+
if (!runtime) return {
|
|
1795
|
+
status: "inactive",
|
|
1796
|
+
reason: "missing-runtime-id",
|
|
1797
|
+
containerStatus: state.status
|
|
1798
|
+
};
|
|
1799
|
+
return {
|
|
1800
|
+
status: "active",
|
|
1801
|
+
runtime,
|
|
1802
|
+
containerStatus: state.status
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
async getStored(storage = this.storage) {
|
|
1806
|
+
const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
|
|
1807
|
+
return record ? new RuntimeIdentity(record) : null;
|
|
1808
|
+
}
|
|
1809
|
+
async markStarted() {
|
|
1810
|
+
const record = { id: crypto.randomUUID() };
|
|
1811
|
+
await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
|
|
1812
|
+
return new RuntimeIdentity(record);
|
|
1813
|
+
}
|
|
1814
|
+
async clear() {
|
|
1815
|
+
await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
|
|
1816
|
+
}
|
|
1817
|
+
async isActive(runtime) {
|
|
1818
|
+
return (await this.get())?.id === runtime.id;
|
|
1819
|
+
}
|
|
1820
|
+
async assertActive(runtime) {
|
|
1821
|
+
if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
|
|
3436
1822
|
}
|
|
3437
1823
|
};
|
|
3438
1824
|
|
|
@@ -4099,24 +2485,6 @@ function bridgePreviewWebSocket(response, lifecycle, settleForward) {
|
|
|
4099
2485
|
});
|
|
4100
2486
|
}
|
|
4101
2487
|
|
|
4102
|
-
//#endregion
|
|
4103
|
-
//#region src/preview-proxy-protocol.ts
|
|
4104
|
-
/** @internal */
|
|
4105
|
-
const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
|
|
4106
|
-
/** @internal */
|
|
4107
|
-
const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
|
|
4108
|
-
/** @internal */
|
|
4109
|
-
const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
|
|
4110
|
-
/** @internal */
|
|
4111
|
-
const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
|
|
4112
|
-
/** @internal */
|
|
4113
|
-
const PREVIEW_PROXY_HEADERS = [
|
|
4114
|
-
PREVIEW_PROXY_HEADER,
|
|
4115
|
-
PREVIEW_PROXY_PORT_HEADER,
|
|
4116
|
-
PREVIEW_PROXY_TOKEN_HEADER,
|
|
4117
|
-
PREVIEW_PROXY_SANDBOX_ID_HEADER
|
|
4118
|
-
];
|
|
4119
|
-
|
|
4120
2488
|
//#endregion
|
|
4121
2489
|
//#region src/preview-url.ts
|
|
4122
2490
|
function isLocalhostPattern(hostname) {
|
|
@@ -4129,20 +2497,6 @@ function isLocalhostPattern(hostname) {
|
|
|
4129
2497
|
return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
|
|
4130
2498
|
}
|
|
4131
2499
|
|
|
4132
|
-
//#endregion
|
|
4133
|
-
//#region src/pty/proxy.ts
|
|
4134
|
-
async function proxyTerminal(stub, sessionId, request, options) {
|
|
4135
|
-
if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
|
|
4136
|
-
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
|
|
4137
|
-
const params = new URLSearchParams({ sessionId });
|
|
4138
|
-
if (options?.cols) params.set("cols", String(options.cols));
|
|
4139
|
-
if (options?.rows) params.set("rows", String(options.rows));
|
|
4140
|
-
if (options?.shell) params.set("shell", options.shell);
|
|
4141
|
-
const ptyUrl = `http://localhost/ws/pty?${params}`;
|
|
4142
|
-
const ptyRequest = new Request(ptyUrl, request);
|
|
4143
|
-
return stub.fetch(switchPort(ptyRequest, 3e3));
|
|
4144
|
-
}
|
|
4145
|
-
|
|
4146
2500
|
//#endregion
|
|
4147
2501
|
//#region src/storage-mount/r2-egress-handler.ts
|
|
4148
2502
|
const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
|
|
@@ -5521,10 +3875,9 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
|
5521
3875
|
* 4. spawn cloudflared inside the container
|
|
5522
3876
|
* 5. persist the record + meta
|
|
5523
3877
|
*
|
|
5524
|
-
* Failure between (2) and (5)
|
|
5525
|
-
*
|
|
5526
|
-
*
|
|
5527
|
-
* `.plans/09-named-tunnel-api.md § Retry-friendly failure model`.
|
|
3878
|
+
* Failure between (2) and (5) leaves the Cloudflare-side resources in
|
|
3879
|
+
* place. Later calls re-discover the tunnel with `findTunnelByName` and
|
|
3880
|
+
* reuse the DNS record through the CNAME upsert path.
|
|
5528
3881
|
*/
|
|
5529
3882
|
async #provisionNamedTunnel(port, name) {
|
|
5530
3883
|
if (!this.#host.sandboxId) throw new Error("Named tunnels require host.sandboxId on the tunnels handler.");
|
|
@@ -5803,11 +4156,9 @@ function createTunnelsHandler(host) {
|
|
|
5803
4156
|
* cache hit falls through to `#provisionNamedTunnel` to respawn
|
|
5804
4157
|
* `cloudflared`.
|
|
5805
4158
|
*
|
|
5806
|
-
*
|
|
5807
|
-
*
|
|
5808
|
-
*
|
|
5809
|
-
* unconditionally — the previous behaviour — silently leaked the tunnel
|
|
5810
|
-
* and DNS record on every restart.
|
|
4159
|
+
* Named-tunnel metadata, including `dnsRecordId`, is preserved so
|
|
4160
|
+
* `destroy(port)` and `sandbox.destroy()` can clean up Cloudflare-side
|
|
4161
|
+
* resources after a restart.
|
|
5811
4162
|
*/
|
|
5812
4163
|
async function pruneTunnelsForRestart(storage) {
|
|
5813
4164
|
await storage.transaction(async (txn) => {
|
|
@@ -5834,7 +4185,7 @@ async function pruneTunnelsForRestart(storage) {
|
|
|
5834
4185
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
5835
4186
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
5836
4187
|
*/
|
|
5837
|
-
const SDK_VERSION = "0.
|
|
4188
|
+
const SDK_VERSION = "0.13.0-next.633.1";
|
|
5838
4189
|
|
|
5839
4190
|
//#endregion
|
|
5840
4191
|
//#region src/sandbox.ts
|
|
@@ -5901,6 +4252,8 @@ const BACKUP_MULTIPART_MAX_PARTS = 64;
|
|
|
5901
4252
|
const BACKUP_DOWNLOAD_PARALLEL_PARTS = 8;
|
|
5902
4253
|
const BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE = 10 * 1024 * 1024;
|
|
5903
4254
|
const BACKUP_DOWNLOAD_MAX_PARTS = 64;
|
|
4255
|
+
const DEFAULT_SESSION_INIT_MAX_ATTEMPTS = 2;
|
|
4256
|
+
const DEFAULT_SESSION_RESTART_SETTLE_MS = 100;
|
|
5904
4257
|
/**
|
|
5905
4258
|
* Calculate the optimal number of parts for multipart upload/download
|
|
5906
4259
|
* based on archive size. Larger archives benefit from more parallelism.
|
|
@@ -5968,11 +4321,10 @@ function buildSandboxConfiguration(effectiveId, options, cached) {
|
|
|
5968
4321
|
if (options?.sleepAfter !== void 0 && cached?.sleepAfter !== options.sleepAfter) configuration.sleepAfter = options.sleepAfter;
|
|
5969
4322
|
if (options?.keepAlive !== void 0 && cached?.keepAlive !== options.keepAlive) configuration.keepAlive = options.keepAlive;
|
|
5970
4323
|
if (options?.containerTimeouts && !sameContainerTimeouts(cached?.containerTimeouts, options.containerTimeouts)) configuration.containerTimeouts = options.containerTimeouts;
|
|
5971
|
-
if (options?.transport !== void 0 && cached?.transport !== options.transport) configuration.transport = options.transport;
|
|
5972
4324
|
return configuration;
|
|
5973
4325
|
}
|
|
5974
4326
|
function hasSandboxConfiguration(configuration) {
|
|
5975
|
-
return configuration.sandboxName !== void 0 || configuration.sleepAfter !== void 0 || configuration.keepAlive !== void 0 || configuration.containerTimeouts !== void 0
|
|
4327
|
+
return configuration.sandboxName !== void 0 || configuration.sleepAfter !== void 0 || configuration.keepAlive !== void 0 || configuration.containerTimeouts !== void 0;
|
|
5976
4328
|
}
|
|
5977
4329
|
function mergeSandboxConfiguration(cached, configuration) {
|
|
5978
4330
|
return {
|
|
@@ -5983,8 +4335,7 @@ function mergeSandboxConfiguration(cached, configuration) {
|
|
|
5983
4335
|
},
|
|
5984
4336
|
...configuration.sleepAfter !== void 0 && { sleepAfter: configuration.sleepAfter },
|
|
5985
4337
|
...configuration.keepAlive !== void 0 && { keepAlive: configuration.keepAlive },
|
|
5986
|
-
...configuration.containerTimeouts !== void 0 && { containerTimeouts: configuration.containerTimeouts }
|
|
5987
|
-
...configuration.transport !== void 0 && { transport: configuration.transport }
|
|
4338
|
+
...configuration.containerTimeouts !== void 0 && { containerTimeouts: configuration.containerTimeouts }
|
|
5988
4339
|
};
|
|
5989
4340
|
}
|
|
5990
4341
|
function applySandboxConfiguration(stub, configuration) {
|
|
@@ -5994,7 +4345,6 @@ function applySandboxConfiguration(stub, configuration) {
|
|
|
5994
4345
|
if (configuration.sleepAfter !== void 0) operations.push(stub.setSleepAfter?.(configuration.sleepAfter) ?? Promise.resolve());
|
|
5995
4346
|
if (configuration.keepAlive !== void 0) operations.push(stub.setKeepAlive?.(configuration.keepAlive) ?? Promise.resolve());
|
|
5996
4347
|
if (configuration.containerTimeouts !== void 0) operations.push(stub.setContainerTimeouts?.(configuration.containerTimeouts) ?? Promise.resolve());
|
|
5997
|
-
if (configuration.transport !== void 0) operations.push(stub.setTransport?.(configuration.transport) ?? Promise.resolve());
|
|
5998
4348
|
return Promise.all(operations).then(() => void 0);
|
|
5999
4349
|
}
|
|
6000
4350
|
function getSandbox(ns, id, options) {
|
|
@@ -6120,14 +4470,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6120
4470
|
activeMounts = /* @__PURE__ */ new Map();
|
|
6121
4471
|
mountOperationQueue = Promise.resolve();
|
|
6122
4472
|
currentRuntime;
|
|
6123
|
-
transport = "http";
|
|
6124
|
-
/**
|
|
6125
|
-
* True once transport has been written to storage at least once (either
|
|
6126
|
-
* via setTransport or restored on cold start). Gates the idempotency
|
|
6127
|
-
* check so a first explicit call persists even when the requested value
|
|
6128
|
-
* already equals the env-derived in-memory default.
|
|
6129
|
-
*/
|
|
6130
|
-
hasStoredTransport = false;
|
|
6131
4473
|
backupBucket = null;
|
|
6132
4474
|
/**
|
|
6133
4475
|
* Serializes backup operations to prevent concurrent create/restore on the same sandbox.
|
|
@@ -6202,63 +4544,40 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6202
4544
|
return fn.apply(client, args);
|
|
6203
4545
|
}
|
|
6204
4546
|
/**
|
|
6205
|
-
* Compute the
|
|
4547
|
+
* Compute the control-channel upgrade retry budget from current container
|
|
4548
|
+
* timeouts.
|
|
6206
4549
|
*
|
|
6207
4550
|
* The budget covers the full container startup window (instance provisioning
|
|
6208
|
-
* + port readiness) plus a 30s margin for the maximum single backoff delay
|
|
6209
|
-
*
|
|
6210
|
-
* default for short timeout configurations.
|
|
4551
|
+
* + port readiness) plus a 30s margin for the maximum single backoff delay.
|
|
4552
|
+
* The 120s floor preserves the default for short timeout configurations.
|
|
6211
4553
|
*/
|
|
6212
4554
|
computeRetryTimeoutMs() {
|
|
6213
4555
|
const startupBudgetMs = this.containerTimeouts.instanceGetTimeoutMS + this.containerTimeouts.portReadyTimeoutMS;
|
|
6214
4556
|
return Math.max(12e4, startupBudgetMs + 3e4);
|
|
6215
4557
|
}
|
|
6216
4558
|
/**
|
|
6217
|
-
* Create the
|
|
6218
|
-
* transport settings.
|
|
4559
|
+
* Create the single control-plane client used for all SDK operations.
|
|
6219
4560
|
*/
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
port: 3e3,
|
|
4561
|
+
createClient() {
|
|
4562
|
+
const self = this;
|
|
4563
|
+
return new ContainerControlClient({
|
|
6224
4564
|
stub: this,
|
|
4565
|
+
port: 3e3,
|
|
4566
|
+
logger: this.logger,
|
|
6225
4567
|
retryTimeoutMs: this.computeRetryTimeoutMs(),
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
4568
|
+
localMain: this.controlCallback,
|
|
4569
|
+
onActivity: () => {
|
|
4570
|
+
this.renewActivityTimeout();
|
|
4571
|
+
},
|
|
4572
|
+
onSessionBusy: () => {
|
|
4573
|
+
self.inflightRequests = (self.inflightRequests ?? 0) + 1;
|
|
4574
|
+
},
|
|
4575
|
+
onSessionIdle: () => {
|
|
4576
|
+
self.inflightRequests = Math.max(0, (self.inflightRequests ?? 0) - 1);
|
|
4577
|
+
if (self.inflightRequests === 0) this.renewActivityTimeout();
|
|
6230
4578
|
}
|
|
6231
4579
|
});
|
|
6232
4580
|
}
|
|
6233
|
-
/**
|
|
6234
|
-
* Create the appropriate client for the configured control path.
|
|
6235
|
-
*
|
|
6236
|
-
* `rpc` currently selects the primary container-control client. `http` and
|
|
6237
|
-
* `websocket` select the route-based compatibility client.
|
|
6238
|
-
*/
|
|
6239
|
-
createClientForTransport(transport) {
|
|
6240
|
-
if (transport === "rpc") {
|
|
6241
|
-
const self = this;
|
|
6242
|
-
return new ContainerControlClient({
|
|
6243
|
-
stub: this,
|
|
6244
|
-
port: 3e3,
|
|
6245
|
-
logger: this.logger,
|
|
6246
|
-
retryTimeoutMs: this.computeRetryTimeoutMs(),
|
|
6247
|
-
localMain: this.controlCallback,
|
|
6248
|
-
onActivity: () => {
|
|
6249
|
-
this.renewActivityTimeout();
|
|
6250
|
-
},
|
|
6251
|
-
onSessionBusy: () => {
|
|
6252
|
-
self.inflightRequests++;
|
|
6253
|
-
},
|
|
6254
|
-
onSessionIdle: () => {
|
|
6255
|
-
self.inflightRequests = Math.max(0, self.inflightRequests - 1);
|
|
6256
|
-
if (self.inflightRequests === 0) this.renewActivityTimeout();
|
|
6257
|
-
}
|
|
6258
|
-
});
|
|
6259
|
-
}
|
|
6260
|
-
return this.createSandboxClient();
|
|
6261
|
-
}
|
|
6262
4581
|
constructor(ctx, env$1) {
|
|
6263
4582
|
super(ctx, env$1);
|
|
6264
4583
|
const envObj = env$1;
|
|
@@ -6271,10 +4590,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6271
4590
|
sandboxId: this.ctx.id.toString()
|
|
6272
4591
|
});
|
|
6273
4592
|
this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
|
|
6274
|
-
const transportEnv = envObj?.SANDBOX_TRANSPORT;
|
|
6275
|
-
if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
|
|
6276
|
-
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
|
|
6277
|
-
this.logger.info(`Using ${this.transport} transport`);
|
|
6278
4593
|
const backupBucket = envObj?.BACKUP_BUCKET;
|
|
6279
4594
|
if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
|
|
6280
4595
|
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_R2_ACCOUNT_ID") ?? getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
@@ -6333,7 +4648,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6333
4648
|
secretAccessKey: this.r2SecretAccessKey
|
|
6334
4649
|
});
|
|
6335
4650
|
this.controlCallback = new SandboxControlCallbackImpl(() => this.tunnelExitHandler, this.logger);
|
|
6336
|
-
this.client = this.
|
|
4651
|
+
this.client = this.createClient();
|
|
6337
4652
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
6338
4653
|
this.ctx.blockConcurrencyWhile(async () => {
|
|
6339
4654
|
this.sandboxName = await this.ctx.storage.get("sandboxName") ?? null;
|
|
@@ -6354,18 +4669,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6354
4669
|
this.sleepAfter = storedSleepAfter;
|
|
6355
4670
|
this.renewActivityTimeout();
|
|
6356
4671
|
}
|
|
6357
|
-
const storedTransport = await this.ctx.storage.get("transport");
|
|
6358
|
-
if (storedTransport && storedTransport !== this.transport) {
|
|
6359
|
-
this.transport = storedTransport;
|
|
6360
|
-
const previousClient = this.client;
|
|
6361
|
-
this.client = this.createClientForTransport(storedTransport);
|
|
6362
|
-
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
6363
|
-
this.tunnelsHandler = null;
|
|
6364
|
-
this.tunnelExitHandler = null;
|
|
6365
|
-
this.destroyAllTunnels = null;
|
|
6366
|
-
previousClient.disconnect();
|
|
6367
|
-
}
|
|
6368
|
-
if (storedTransport) this.hasStoredTransport = true;
|
|
6369
4672
|
if (this.interceptHttps) this.envVars = {
|
|
6370
4673
|
...this.envVars,
|
|
6371
4674
|
SANDBOX_INTERCEPT_HTTPS: "1"
|
|
@@ -6384,7 +4687,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6384
4687
|
if (configuration.sleepAfter !== void 0) await this.setSleepAfter(configuration.sleepAfter);
|
|
6385
4688
|
if (configuration.keepAlive !== void 0) await this.setKeepAlive(configuration.keepAlive);
|
|
6386
4689
|
if (configuration.containerTimeouts !== void 0) await this.setContainerTimeouts(configuration.containerTimeouts);
|
|
6387
|
-
if (configuration.transport !== void 0) await this.setTransport(configuration.transport);
|
|
6388
4690
|
}
|
|
6389
4691
|
async setSleepAfter(sleepAfter) {
|
|
6390
4692
|
if (this.sleepAfter === sleepAfter) return;
|
|
@@ -6430,25 +4732,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6430
4732
|
this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
|
|
6431
4733
|
this.logger.debug("Container timeouts updated", this.containerTimeouts);
|
|
6432
4734
|
}
|
|
6433
|
-
async setTransport(transport) {
|
|
6434
|
-
if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
|
|
6435
|
-
this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
|
|
6436
|
-
return;
|
|
6437
|
-
}
|
|
6438
|
-
if (this.hasStoredTransport && this.transport === transport) return;
|
|
6439
|
-
await this.ctx.storage.put("transport", transport);
|
|
6440
|
-
const previousClient = this.client;
|
|
6441
|
-
this.transport = transport;
|
|
6442
|
-
this.hasStoredTransport = true;
|
|
6443
|
-
this.client = this.createClientForTransport(transport);
|
|
6444
|
-
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
6445
|
-
this.tunnelsHandler = null;
|
|
6446
|
-
this.tunnelExitHandler = null;
|
|
6447
|
-
this.destroyAllTunnels = null;
|
|
6448
|
-
previousClient.disconnect();
|
|
6449
|
-
this.renewActivityTimeout();
|
|
6450
|
-
this.logger.debug("Transport updated", { transport });
|
|
6451
|
-
}
|
|
6452
4735
|
validateTimeout(value, name, min, max) {
|
|
6453
4736
|
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
|
|
6454
4737
|
if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
|
|
@@ -7272,12 +5555,12 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7272
5555
|
} catch (e) {
|
|
7273
5556
|
if (this.isNoInstanceError(e)) {
|
|
7274
5557
|
const errorBody$1 = {
|
|
7275
|
-
code: ErrorCode.
|
|
5558
|
+
code: ErrorCode.CONTAINER_UNAVAILABLE,
|
|
7276
5559
|
message: "Container is currently provisioning. This can take several minutes on first deployment.",
|
|
7277
|
-
context: {
|
|
5560
|
+
context: { reason: "provisioning" },
|
|
7278
5561
|
httpStatus: 503,
|
|
7279
5562
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7280
|
-
suggestion: "
|
|
5563
|
+
suggestion: "The container is still being provisioned. Retry the operation in a moment."
|
|
7281
5564
|
};
|
|
7282
5565
|
return new Response(JSON.stringify(errorBody$1), {
|
|
7283
5566
|
status: 503,
|
|
@@ -7319,15 +5602,12 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7319
5602
|
error: e instanceof Error ? e.message : String(e)
|
|
7320
5603
|
});
|
|
7321
5604
|
const errorBody$1 = {
|
|
7322
|
-
code: ErrorCode.
|
|
5605
|
+
code: ErrorCode.CONTAINER_UNAVAILABLE,
|
|
7323
5606
|
message: "Container is starting. Please retry in a moment.",
|
|
7324
|
-
context: {
|
|
7325
|
-
phase: "startup",
|
|
7326
|
-
error: e instanceof Error ? e.message : String(e)
|
|
7327
|
-
},
|
|
5607
|
+
context: { reason: "startup" },
|
|
7328
5608
|
httpStatus: 503,
|
|
7329
5609
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7330
|
-
suggestion: "The container is
|
|
5610
|
+
suggestion: "The container is not ready yet. Retry the operation in a moment."
|
|
7331
5611
|
};
|
|
7332
5612
|
return new Response(JSON.stringify(errorBody$1), {
|
|
7333
5613
|
status: 503,
|
|
@@ -7343,15 +5623,12 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7343
5623
|
error: e instanceof Error ? e.message : String(e)
|
|
7344
5624
|
});
|
|
7345
5625
|
const errorBody = {
|
|
7346
|
-
code: ErrorCode.
|
|
5626
|
+
code: ErrorCode.CONTAINER_UNAVAILABLE,
|
|
7347
5627
|
message: "Container is starting. Please retry in a moment.",
|
|
7348
|
-
context: {
|
|
7349
|
-
phase: "startup",
|
|
7350
|
-
error: e instanceof Error ? e.message : String(e)
|
|
7351
|
-
},
|
|
5628
|
+
context: { reason: "startup" },
|
|
7352
5629
|
httpStatus: 503,
|
|
7353
5630
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7354
|
-
suggestion: "The
|
|
5631
|
+
suggestion: "The container is not ready yet. Retry the operation in a moment."
|
|
7355
5632
|
};
|
|
7356
5633
|
return new Response(JSON.stringify(errorBody), {
|
|
7357
5634
|
status: 503,
|
|
@@ -7597,6 +5874,15 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7597
5874
|
*/
|
|
7598
5875
|
async ensureDefaultSession() {
|
|
7599
5876
|
const sessionId = `sandbox-${this.sandboxName || "default"}`;
|
|
5877
|
+
for (let attempt = 1; attempt <= DEFAULT_SESSION_INIT_MAX_ATTEMPTS; attempt++) try {
|
|
5878
|
+
return await this.getOrInitializeDefaultSession(sessionId);
|
|
5879
|
+
} catch (error) {
|
|
5880
|
+
if (!this.isDefaultSessionInitContainerRestart(error) || attempt === DEFAULT_SESSION_INIT_MAX_ATTEMPTS) throw error;
|
|
5881
|
+
await new Promise((resolve) => setTimeout(resolve, DEFAULT_SESSION_RESTART_SETTLE_MS));
|
|
5882
|
+
}
|
|
5883
|
+
throw new Error("Default session initialization failed unexpectedly");
|
|
5884
|
+
}
|
|
5885
|
+
async getOrInitializeDefaultSession(sessionId) {
|
|
7600
5886
|
if (this.defaultSession === sessionId) return this.defaultSession;
|
|
7601
5887
|
const generation = this.containerGeneration;
|
|
7602
5888
|
const pending = this.defaultSessionInit;
|
|
@@ -7614,6 +5900,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7614
5900
|
if (this.defaultSessionInit === init) this.defaultSessionInit = null;
|
|
7615
5901
|
}
|
|
7616
5902
|
}
|
|
5903
|
+
isDefaultSessionInitContainerRestart(error) {
|
|
5904
|
+
return error instanceof ContainerUnavailableError && error.context.reason === "container_restarted";
|
|
5905
|
+
}
|
|
7617
5906
|
async initializeDefaultSession(sessionId, generation) {
|
|
7618
5907
|
let placementId;
|
|
7619
5908
|
try {
|
|
@@ -7627,7 +5916,17 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7627
5916
|
placementId = error.containerPlacementId;
|
|
7628
5917
|
this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
|
|
7629
5918
|
}
|
|
7630
|
-
if (generation !== this.containerGeneration) throw new
|
|
5919
|
+
if (generation !== this.containerGeneration) throw new ContainerUnavailableError({
|
|
5920
|
+
code: ErrorCode.CONTAINER_UNAVAILABLE,
|
|
5921
|
+
message: "Container restarted while the default session was being initialized.",
|
|
5922
|
+
context: {
|
|
5923
|
+
reason: "container_restarted",
|
|
5924
|
+
sessionId
|
|
5925
|
+
},
|
|
5926
|
+
httpStatus: 503,
|
|
5927
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5928
|
+
suggestion: "The container restarted while the SDK was preparing the default session. Retry the operation to use the new container."
|
|
5929
|
+
});
|
|
7631
5930
|
await this.ctx.storage.put("defaultSession", sessionId);
|
|
7632
5931
|
await this.capturePlacementId(placementId);
|
|
7633
5932
|
this.defaultSession = sessionId;
|
|
@@ -8465,7 +6764,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8465
6764
|
}
|
|
8466
6765
|
/**
|
|
8467
6766
|
* Namespaced tunnel API. Quick tunnels are zero-config preview URLs
|
|
8468
|
-
* backed by Cloudflare's trycloudflare service.
|
|
6767
|
+
* backed by Cloudflare's trycloudflare service. Named tunnels bind a
|
|
6768
|
+
* stable hostname under the configured Cloudflare zone.
|
|
8469
6769
|
*
|
|
8470
6770
|
* - `tunnels.get(port)` — idempotent. Returns the cached tunnel for
|
|
8471
6771
|
* `port` if one exists in DO storage, otherwise spawns a fresh
|
|
@@ -8475,12 +6775,11 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8475
6775
|
* - `tunnels.destroy(portOrInfo)` — tear down by port number or by
|
|
8476
6776
|
* the record returned from `get()`.
|
|
8477
6777
|
*
|
|
8478
|
-
*
|
|
8479
|
-
*
|
|
8480
|
-
*
|
|
8481
|
-
*
|
|
8482
|
-
*
|
|
8483
|
-
* throws "RPC transport required".
|
|
6778
|
+
* Container restarts drop quick-tunnel records because their
|
|
6779
|
+
* `*.trycloudflare.com` URLs are tied to the dead cloudflared process.
|
|
6780
|
+
* Named-tunnel records stay in storage and are marked for respawn so the
|
|
6781
|
+
* next `get(port, { name })` call reuses the Cloudflare tunnel and DNS
|
|
6782
|
+
* record while starting a fresh cloudflared process.
|
|
8484
6783
|
*/
|
|
8485
6784
|
get tunnels() {
|
|
8486
6785
|
this.ensureTunnelsBuilt();
|
|
@@ -8489,8 +6788,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
8489
6788
|
/**
|
|
8490
6789
|
* Lazily construct both the public tunnels handler and its sibling
|
|
8491
6790
|
* exit-handler callback. Called from the `tunnels` getter on first
|
|
8492
|
-
* access
|
|
8493
|
-
* fields.
|
|
6791
|
+
* access.
|
|
8494
6792
|
*/
|
|
8495
6793
|
ensureTunnelsBuilt() {
|
|
8496
6794
|
if (this.tunnelsHandler) return;
|
|
@@ -9857,34 +8155,18 @@ var Sandbox = class Sandbox extends Container {
|
|
|
9857
8155
|
backupSession = await this.ensureBackupSession();
|
|
9858
8156
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
|
|
9859
8157
|
await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
await this.client.files.writeFileStream(archivePath, body, backupSession);
|
|
9873
|
-
} else {
|
|
9874
|
-
const archiveBuffer = await archiveObject.arrayBuffer();
|
|
9875
|
-
const base64Content = Buffer.from(archiveBuffer).toString("base64");
|
|
9876
|
-
const writeResult = await this.client.files.writeFile(archivePath, base64Content, backupSession, { encoding: "base64" });
|
|
9877
|
-
if (!writeResult.success) throw new BackupRestoreError({
|
|
9878
|
-
message: `Failed to write backup archive to ${archivePath}: ${"error" in writeResult && typeof writeResult.error === "object" && writeResult.error !== null && "message" in writeResult.error && typeof writeResult.error.message === "string" ? writeResult.error.message : `File write returned success: false for '${archivePath}'`}`,
|
|
9879
|
-
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
9880
|
-
httpStatus: 500,
|
|
9881
|
-
context: {
|
|
9882
|
-
dir,
|
|
9883
|
-
backupId: id
|
|
9884
|
-
},
|
|
9885
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
9886
|
-
});
|
|
9887
|
-
}
|
|
8158
|
+
const body = archiveObject.body;
|
|
8159
|
+
if (!body) throw new BackupRestoreError({
|
|
8160
|
+
message: `R2 archive object has no body stream for backup ${id}`,
|
|
8161
|
+
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
8162
|
+
httpStatus: 500,
|
|
8163
|
+
context: {
|
|
8164
|
+
dir,
|
|
8165
|
+
backupId: id
|
|
8166
|
+
},
|
|
8167
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
8168
|
+
});
|
|
8169
|
+
await this.client.files.writeFileStream(archivePath, body, backupSession);
|
|
9888
8170
|
const extractResult = await this.execWithSession(`/usr/bin/unsquashfs -f -d ${shellEscape(dir)} ${shellEscape(archivePath)}`, backupSession, { origin: "internal" });
|
|
9889
8171
|
if (extractResult.exitCode !== 0) throw new BackupRestoreError({
|
|
9890
8172
|
message: `unsquashfs extraction failed (exit code ${extractResult.exitCode}): ${extractResult.stderr}`,
|
|
@@ -9984,5 +8266,5 @@ var Sandbox = class Sandbox extends Container {
|
|
|
9984
8266
|
};
|
|
9985
8267
|
|
|
9986
8268
|
//#endregion
|
|
9987
|
-
export {
|
|
9988
|
-
//# sourceMappingURL=sandbox-
|
|
8269
|
+
export { InvalidBackupConfigError as A, collectFile as C, BackupNotFoundError as D, BackupExpiredError as E, ProcessReadyTimeoutError as M, RPCTransportError as N, BackupRestoreError as O, SessionTerminatedError as P, validateTunnelName as S, BackupCreateError as T, proxyTerminal as _, BucketUnmountError as a, sanitizeSandboxId as b, S3FSMountError as c, responseToAsyncIterable as d, PREVIEW_PROXY_HEADER as f, PREVIEW_PROXY_TOKEN_HEADER as g, PREVIEW_PROXY_SANDBOX_ID_HEADER as h, BucketMountError as i, ProcessExitedBeforeReadyError as j, ContainerUnavailableError as k, asyncIterableToSSEStream as l, PREVIEW_PROXY_PORT_HEADER as m, Sandbox as n, InvalidMountConfigError as o, PREVIEW_PROXY_HEADERS as p, getSandbox as r, MissingCredentialsError as s, ContainerProxy$1 as t, parseSSEStream as u, CodeInterpreter as v, streamFile as w, validatePort as x, SandboxSecurityError as y };
|
|
8270
|
+
//# sourceMappingURL=sandbox-C9CHzLDx.js.map
|