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