@cloudflare/sandbox 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/index.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/openai/index.d.ts +1 -1
- package/dist/opencode/index.d.ts +1 -1
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/{sandbox-YMrVC62F.d.ts → sandbox-C-AzrX_L.d.ts} +232 -118
- package/dist/sandbox-C-AzrX_L.d.ts.map +1 -0
- package/dist/{sandbox-CReFGUtF.js → sandbox-CdWjEUHl.js} +525 -126
- package/dist/sandbox-CdWjEUHl.js.map +1 -0
- package/package.json +1 -1
- package/dist/sandbox-CReFGUtF.js.map +0 -1
- package/dist/sandbox-YMrVC62F.d.ts.map +0 -1
|
@@ -765,8 +765,8 @@ function createErrorFromResponse(errorResponse, options) {
|
|
|
765
765
|
/**
|
|
766
766
|
* Container startup retry configuration
|
|
767
767
|
*/
|
|
768
|
-
const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
|
|
769
|
-
const MIN_TIME_FOR_RETRY_MS = 15e3;
|
|
768
|
+
const DEFAULT_RETRY_TIMEOUT_MS$1 = 12e4;
|
|
769
|
+
const MIN_TIME_FOR_RETRY_MS$1 = 15e3;
|
|
770
770
|
/**
|
|
771
771
|
* Abstract base transport with shared retry logic
|
|
772
772
|
*
|
|
@@ -780,7 +780,7 @@ var BaseTransport = class {
|
|
|
780
780
|
constructor(config) {
|
|
781
781
|
this.config = config;
|
|
782
782
|
this.logger = config.logger ?? createNoOpLogger();
|
|
783
|
-
this.retryTimeoutMs = config.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
|
|
783
|
+
this.retryTimeoutMs = config.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS$1;
|
|
784
784
|
}
|
|
785
785
|
setRetryTimeoutMs(ms) {
|
|
786
786
|
this.retryTimeoutMs = ms;
|
|
@@ -802,7 +802,7 @@ var BaseTransport = class {
|
|
|
802
802
|
if (response.status === 503) {
|
|
803
803
|
const elapsed = Date.now() - startTime;
|
|
804
804
|
const remaining = this.retryTimeoutMs - elapsed;
|
|
805
|
-
if (remaining > MIN_TIME_FOR_RETRY_MS) {
|
|
805
|
+
if (remaining > MIN_TIME_FOR_RETRY_MS$1) {
|
|
806
806
|
const delay = Math.min(3e3 * 2 ** attempt, 3e4);
|
|
807
807
|
this.logger.info("Container not ready, retrying", {
|
|
808
808
|
status: response.status,
|
|
@@ -1495,10 +1495,10 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1495
1495
|
//#endregion
|
|
1496
1496
|
//#region src/clients/transport/factory.ts
|
|
1497
1497
|
/**
|
|
1498
|
-
* Create a transport instance based on mode
|
|
1498
|
+
* Create a route-based compatibility transport instance based on mode.
|
|
1499
1499
|
*
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1500
|
+
* Selects the HTTP or custom WebSocket transport for the route-based client
|
|
1501
|
+
* layer.
|
|
1502
1502
|
*
|
|
1503
1503
|
* @example
|
|
1504
1504
|
* ```typescript
|
|
@@ -1517,23 +1517,23 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1517
1517
|
*/
|
|
1518
1518
|
function createTransport(options) {
|
|
1519
1519
|
switch (options.mode) {
|
|
1520
|
+
case "http": return new HttpTransport(options);
|
|
1520
1521
|
case "websocket": return new WebSocketTransport(options);
|
|
1521
|
-
default: return new HttpTransport(options);
|
|
1522
1522
|
}
|
|
1523
1523
|
}
|
|
1524
1524
|
|
|
1525
1525
|
//#endregion
|
|
1526
1526
|
//#region src/clients/base-client.ts
|
|
1527
1527
|
/**
|
|
1528
|
-
* Abstract base class
|
|
1528
|
+
* Abstract base class for route-based HTTP/WebSocket compatibility clients.
|
|
1529
1529
|
*
|
|
1530
|
-
*
|
|
1531
|
-
* - HTTP and WebSocket modes transparently
|
|
1532
|
-
* - Automatic retry for 503 errors
|
|
1533
|
-
* - Streaming responses
|
|
1530
|
+
* Requests go through the Transport abstraction layer, which handles:
|
|
1531
|
+
* - HTTP and WebSocket route-based modes transparently
|
|
1532
|
+
* - Automatic retry for 503 errors while the container is starting
|
|
1533
|
+
* - Streaming responses for the existing route API
|
|
1534
1534
|
*
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1535
|
+
* DO-to-container control-channel capabilities live in `container-control/`.
|
|
1536
|
+
* This layer supports the route-based compatibility API.
|
|
1537
1537
|
*/
|
|
1538
1538
|
var BaseHttpClient = class {
|
|
1539
1539
|
options;
|
|
@@ -1654,7 +1654,7 @@ var BaseHttpClient = class {
|
|
|
1654
1654
|
/**
|
|
1655
1655
|
* Stream request handler
|
|
1656
1656
|
*
|
|
1657
|
-
*
|
|
1657
|
+
* HTTP mode uses doFetch + handleStreamResponse for typed error handling.
|
|
1658
1658
|
* For WebSocket mode, uses Transport's streaming support.
|
|
1659
1659
|
*
|
|
1660
1660
|
* @param path - The API path to call
|
|
@@ -1698,6 +1698,7 @@ var BackupClient = class extends BaseHttpClient {
|
|
|
1698
1698
|
archivePath,
|
|
1699
1699
|
gitignore: options?.gitignore ?? false,
|
|
1700
1700
|
excludes: options?.excludes ?? [],
|
|
1701
|
+
compression: options?.compression,
|
|
1701
1702
|
sessionId
|
|
1702
1703
|
};
|
|
1703
1704
|
return await this.post("/api/backup/create", data);
|
|
@@ -1716,6 +1717,12 @@ var BackupClient = class extends BaseHttpClient {
|
|
|
1716
1717
|
};
|
|
1717
1718
|
return await this.post("/api/backup/restore", data);
|
|
1718
1719
|
}
|
|
1720
|
+
async uploadParts(request, sessionId) {
|
|
1721
|
+
return this.post("/api/backup/upload-parts", {
|
|
1722
|
+
...request,
|
|
1723
|
+
sessionId: sessionId ?? request.sessionId
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1719
1726
|
};
|
|
1720
1727
|
|
|
1721
1728
|
//#endregion
|
|
@@ -2618,13 +2625,13 @@ var WatchClient = class extends BaseHttpClient {
|
|
|
2618
2625
|
//#endregion
|
|
2619
2626
|
//#region src/clients/sandbox-client.ts
|
|
2620
2627
|
/**
|
|
2621
|
-
*
|
|
2622
|
-
*
|
|
2628
|
+
* Route-based compatibility sandbox client that composes all domain-specific
|
|
2629
|
+
* HTTP API clients.
|
|
2623
2630
|
*
|
|
2624
|
-
*
|
|
2625
|
-
*
|
|
2626
|
-
* -
|
|
2627
|
-
*
|
|
2631
|
+
* This client supports the route-based HTTP and custom WebSocket transports.
|
|
2632
|
+
* The primary DO-to-container control path is ContainerControlClient under
|
|
2633
|
+
* `container-control/`. This client supports route-based compatibility,
|
|
2634
|
+
* debugging, local development, and fallback behavior.
|
|
2628
2635
|
*/
|
|
2629
2636
|
var SandboxClient = class {
|
|
2630
2637
|
backup;
|
|
@@ -2701,9 +2708,9 @@ var SandboxClient = class {
|
|
|
2701
2708
|
/**
|
|
2702
2709
|
* Stream a file directly to the container over a binary RPC channel.
|
|
2703
2710
|
*
|
|
2704
|
-
* Requires the
|
|
2705
|
-
* method with the HTTP or WebSocket transports throws an error because
|
|
2706
|
-
* transports do not support binary streaming.
|
|
2711
|
+
* Requires the container-control path (`transport: 'rpc'`). Calling this
|
|
2712
|
+
* method with the HTTP or WebSocket route transports throws an error because
|
|
2713
|
+
* those transports do not support binary streaming.
|
|
2707
2714
|
*/
|
|
2708
2715
|
writeFileStream(_path, _content, _sessionId) {
|
|
2709
2716
|
throw new Error("writeFileStream requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.");
|
|
@@ -2747,8 +2754,11 @@ function normalizeBackupExcludePattern(pattern) {
|
|
|
2747
2754
|
}
|
|
2748
2755
|
|
|
2749
2756
|
//#endregion
|
|
2750
|
-
//#region src/container-connection.ts
|
|
2757
|
+
//#region src/container-control/connection.ts
|
|
2751
2758
|
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
2759
|
+
const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
|
|
2760
|
+
const MIN_TIME_FOR_RETRY_MS = 15e3;
|
|
2761
|
+
const MAX_RETRY_BACKOFF_MS = 3e4;
|
|
2752
2762
|
/**
|
|
2753
2763
|
* Manages a capnweb WebSocket RPC session to the container.
|
|
2754
2764
|
*
|
|
@@ -2756,7 +2766,7 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
|
2756
2766
|
* transport. Calls made before `connect()` completes are queued in the
|
|
2757
2767
|
* transport and flushed once the WebSocket is established.
|
|
2758
2768
|
*/
|
|
2759
|
-
var
|
|
2769
|
+
var ContainerControlConnection = class {
|
|
2760
2770
|
stub;
|
|
2761
2771
|
session;
|
|
2762
2772
|
transport;
|
|
@@ -2766,10 +2776,12 @@ var ContainerConnection = class {
|
|
|
2766
2776
|
containerStub;
|
|
2767
2777
|
port;
|
|
2768
2778
|
logger;
|
|
2779
|
+
retryTimeoutMs;
|
|
2769
2780
|
constructor(options) {
|
|
2770
2781
|
this.containerStub = options.stub;
|
|
2771
2782
|
this.port = options.port ?? 3e3;
|
|
2772
2783
|
this.logger = options.logger ?? createNoOpLogger();
|
|
2784
|
+
this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
|
|
2773
2785
|
this.transport = new DeferredTransport();
|
|
2774
2786
|
this.session = new RpcSession(this.transport);
|
|
2775
2787
|
this.stub = this.session.getRemoteMain();
|
|
@@ -2819,20 +2831,17 @@ var ContainerConnection = class {
|
|
|
2819
2831
|
this.connected = false;
|
|
2820
2832
|
this.connectPromise = null;
|
|
2821
2833
|
}
|
|
2834
|
+
/**
|
|
2835
|
+
* Update the 503 retry budget without recreating the connection. Takes
|
|
2836
|
+
* effect on the next `connect()`; an in-flight connect uses the value
|
|
2837
|
+
* captured at start. Mirrors `WebSocketTransport.setRetryTimeoutMs`.
|
|
2838
|
+
*/
|
|
2839
|
+
setRetryTimeoutMs(ms) {
|
|
2840
|
+
this.retryTimeoutMs = ms;
|
|
2841
|
+
}
|
|
2822
2842
|
async doConnect() {
|
|
2823
|
-
const controller = new AbortController();
|
|
2824
|
-
const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
|
|
2825
2843
|
try {
|
|
2826
|
-
const
|
|
2827
|
-
const request = new Request(url, {
|
|
2828
|
-
headers: {
|
|
2829
|
-
Upgrade: "websocket",
|
|
2830
|
-
Connection: "Upgrade"
|
|
2831
|
-
},
|
|
2832
|
-
signal: controller.signal
|
|
2833
|
-
});
|
|
2834
|
-
const response = await this.containerStub.fetch(request);
|
|
2835
|
-
clearTimeout(timeout);
|
|
2844
|
+
const response = await this.fetchUpgradeWithRetry();
|
|
2836
2845
|
if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
|
|
2837
2846
|
const ws = response.webSocket;
|
|
2838
2847
|
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
@@ -2840,7 +2849,7 @@ var ContainerConnection = class {
|
|
|
2840
2849
|
ws.addEventListener("close", () => {
|
|
2841
2850
|
this.connected = false;
|
|
2842
2851
|
this.ws = null;
|
|
2843
|
-
this.logger.debug("
|
|
2852
|
+
this.logger.debug("ContainerControlConnection WebSocket closed");
|
|
2844
2853
|
});
|
|
2845
2854
|
ws.addEventListener("error", () => {
|
|
2846
2855
|
this.connected = false;
|
|
@@ -2849,15 +2858,65 @@ var ContainerConnection = class {
|
|
|
2849
2858
|
this.ws = ws;
|
|
2850
2859
|
this.transport.activate(ws);
|
|
2851
2860
|
this.connected = true;
|
|
2852
|
-
this.logger.debug("
|
|
2861
|
+
this.logger.debug("ContainerControlConnection established", { port: this.port });
|
|
2853
2862
|
} catch (error) {
|
|
2854
|
-
clearTimeout(timeout);
|
|
2855
2863
|
this.connected = false;
|
|
2856
2864
|
this.transport.abort(error);
|
|
2857
|
-
this.logger.error("
|
|
2865
|
+
this.logger.error("ContainerControlConnection failed", error instanceof Error ? error : new Error(String(error)));
|
|
2858
2866
|
throw error;
|
|
2859
2867
|
}
|
|
2860
2868
|
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Issue WebSocket upgrade fetches, retrying transient 503 responses with
|
|
2871
|
+
* exponential backoff (3s → 6s → 12s → … capped at 30s) until either
|
|
2872
|
+
* the upgrade succeeds, a non-503 status is returned, or the retry budget
|
|
2873
|
+
* runs out. Mirrors `WebSocketTransport.fetchUpgradeWithRetry` so both
|
|
2874
|
+
* transports behave the same way during container startup.
|
|
2875
|
+
*/
|
|
2876
|
+
async fetchUpgradeWithRetry() {
|
|
2877
|
+
const retryTimeoutMs = this.retryTimeoutMs;
|
|
2878
|
+
const startTime = Date.now();
|
|
2879
|
+
let attempt = 0;
|
|
2880
|
+
while (true) {
|
|
2881
|
+
const response = await this.fetchUpgradeAttempt();
|
|
2882
|
+
if (response.status !== 503) return response;
|
|
2883
|
+
const remaining = retryTimeoutMs - (Date.now() - startTime);
|
|
2884
|
+
if (remaining <= MIN_TIME_FOR_RETRY_MS) return response;
|
|
2885
|
+
const delay = Math.min(3e3 * 2 ** attempt, MAX_RETRY_BACKOFF_MS);
|
|
2886
|
+
this.logger.info("ContainerControlConnection upgrade returned 503, retrying", {
|
|
2887
|
+
attempt: attempt + 1,
|
|
2888
|
+
delayMs: delay,
|
|
2889
|
+
remainingSec: Math.floor(remaining / 1e3)
|
|
2890
|
+
});
|
|
2891
|
+
await this.sleep(delay);
|
|
2892
|
+
attempt++;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* Single WebSocket-upgrade fetch attempt. Owns its own AbortController so
|
|
2897
|
+
* each retry gets a fresh per-attempt connect timeout independent of the
|
|
2898
|
+
* total retry budget.
|
|
2899
|
+
*/
|
|
2900
|
+
async fetchUpgradeAttempt() {
|
|
2901
|
+
const controller = new AbortController();
|
|
2902
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
|
|
2903
|
+
try {
|
|
2904
|
+
const url = `http://localhost:${this.port}/rpc`;
|
|
2905
|
+
const request = new Request(url, {
|
|
2906
|
+
headers: {
|
|
2907
|
+
Upgrade: "websocket",
|
|
2908
|
+
Connection: "Upgrade"
|
|
2909
|
+
},
|
|
2910
|
+
signal: controller.signal
|
|
2911
|
+
});
|
|
2912
|
+
return await this.containerStub.fetch(request);
|
|
2913
|
+
} finally {
|
|
2914
|
+
clearTimeout(timeout);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
sleep(ms) {
|
|
2918
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2919
|
+
}
|
|
2861
2920
|
};
|
|
2862
2921
|
/**
|
|
2863
2922
|
* RPC transport that queues sends and blocks receives until a WebSocket
|
|
@@ -2920,7 +2979,7 @@ var DeferredTransport = class {
|
|
|
2920
2979
|
};
|
|
2921
2980
|
|
|
2922
2981
|
//#endregion
|
|
2923
|
-
//#region src/
|
|
2982
|
+
//#region src/container-control/client.ts
|
|
2924
2983
|
/** Close the idle capnweb WebSocket promptly so the DO can sleep. */
|
|
2925
2984
|
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
2926
2985
|
/**
|
|
@@ -2978,7 +3037,7 @@ function translateRPCError(error) {
|
|
|
2978
3037
|
* Inspect a transport-level Error's message and produce the ErrorResponse
|
|
2979
3038
|
* that becomes an RPCTransportError. Pattern strings are pinned to the exact
|
|
2980
3039
|
* messages emitted by capnweb's WebSocketTransport (see capnweb's
|
|
2981
|
-
* src/websocket.ts) and our DeferredTransport in container-connection.ts —
|
|
3040
|
+
* src/websocket.ts) and our DeferredTransport in container-control/connection.ts —
|
|
2982
3041
|
* notably the trailing period in `WebSocket connection failed.` matches
|
|
2983
3042
|
* capnweb verbatim. The DeferredTransport tests in
|
|
2984
3043
|
* tests/container-connection.test.ts pin the literal strings.
|
|
@@ -3024,7 +3083,7 @@ function buildTransportErrorResponse(error) {
|
|
|
3024
3083
|
* activity at call start.
|
|
3025
3084
|
*
|
|
3026
3085
|
* `onCallStarted` fires synchronously when an RPC method is invoked. The
|
|
3027
|
-
*
|
|
3086
|
+
* ContainerControlClient uses this to renew the DO's activity timeout
|
|
3028
3087
|
* immediately, so even a call that completes entirely between two
|
|
3029
3088
|
* busy-poll ticks still pushes the sleepAfter deadline forward.
|
|
3030
3089
|
*
|
|
@@ -3050,20 +3109,19 @@ function wrapStub(stub, onCallStarted) {
|
|
|
3050
3109
|
} });
|
|
3051
3110
|
}
|
|
3052
3111
|
/**
|
|
3053
|
-
* SandboxClient backed by direct capnweb RPC.
|
|
3112
|
+
* SandboxClient-compatible facade backed by direct capnweb RPC.
|
|
3054
3113
|
*
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
3057
|
-
* bypassing the HTTP handler/router layer entirely.
|
|
3114
|
+
* All operations call the container's SandboxAPI control interface directly
|
|
3115
|
+
* over capnweb, bypassing the HTTP handler/router layer entirely.
|
|
3058
3116
|
*
|
|
3059
|
-
* Manages its own WebSocket lifecycle: a fresh `
|
|
3117
|
+
* Manages its own WebSocket lifecycle: a fresh `ContainerControlConnection` is
|
|
3060
3118
|
* created on demand and torn down after `idleDisconnectMs` of inactivity.
|
|
3061
3119
|
* Busy/idle detection relies on `RpcSession.getStats()` which tracks all
|
|
3062
3120
|
* in-flight RPC calls and stream exports — including long-lived streaming
|
|
3063
3121
|
* RPCs that would be invisible to a simple per-call request counter (see
|
|
3064
3122
|
* the file-level comment for the full rationale).
|
|
3065
3123
|
*/
|
|
3066
|
-
var
|
|
3124
|
+
var ContainerControlClient = class {
|
|
3067
3125
|
connOptions;
|
|
3068
3126
|
idleDisconnectMs;
|
|
3069
3127
|
busyPollIntervalMs;
|
|
@@ -3087,7 +3145,8 @@ var RPCSandboxClient = class {
|
|
|
3087
3145
|
this.connOptions = {
|
|
3088
3146
|
stub: options.stub,
|
|
3089
3147
|
port: options.port,
|
|
3090
|
-
logger: options.logger
|
|
3148
|
+
logger: options.logger,
|
|
3149
|
+
retryTimeoutMs: options.retryTimeoutMs
|
|
3091
3150
|
};
|
|
3092
3151
|
this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
|
|
3093
3152
|
this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
|
|
@@ -3097,13 +3156,12 @@ var RPCSandboxClient = class {
|
|
|
3097
3156
|
this.onSessionIdle = options.onSessionIdle;
|
|
3098
3157
|
}
|
|
3099
3158
|
/**
|
|
3100
|
-
* Return the current connection, creating
|
|
3101
|
-
*
|
|
3102
|
-
* timer the first time a connection is materialized.
|
|
3159
|
+
* Return the current connection, creating one when the client is disconnected.
|
|
3160
|
+
* Starts the busy-poll timer the first time a connection is materialized.
|
|
3103
3161
|
*/
|
|
3104
3162
|
getConnection() {
|
|
3105
3163
|
if (!this.conn) {
|
|
3106
|
-
this.conn = new
|
|
3164
|
+
this.conn = new ContainerControlConnection(this.connOptions);
|
|
3107
3165
|
this.startBusyPoll();
|
|
3108
3166
|
}
|
|
3109
3167
|
return this.conn;
|
|
@@ -3221,7 +3279,15 @@ var RPCSandboxClient = class {
|
|
|
3221
3279
|
get interpreter() {
|
|
3222
3280
|
return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
|
|
3223
3281
|
}
|
|
3224
|
-
|
|
3282
|
+
/**
|
|
3283
|
+
* Update the 503 upgrade-retry budget. Applies to the current connection
|
|
3284
|
+
* (if any) and is remembered for any future connections created after the
|
|
3285
|
+
* client is torn down and reconnected.
|
|
3286
|
+
*/
|
|
3287
|
+
setRetryTimeoutMs(ms) {
|
|
3288
|
+
this.connOptions.retryTimeoutMs = ms;
|
|
3289
|
+
this.conn?.setRetryTimeoutMs(ms);
|
|
3290
|
+
}
|
|
3225
3291
|
getTransportMode() {
|
|
3226
3292
|
return "rpc";
|
|
3227
3293
|
}
|
|
@@ -4191,7 +4257,7 @@ function isLocalhostPattern(hostname) {
|
|
|
4191
4257
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
4192
4258
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
4193
4259
|
*/
|
|
4194
|
-
const SDK_VERSION = "0.9.
|
|
4260
|
+
const SDK_VERSION = "0.9.4";
|
|
4195
4261
|
|
|
4196
4262
|
//#endregion
|
|
4197
4263
|
//#region src/sandbox.ts
|
|
@@ -4202,6 +4268,63 @@ const BACKUP_CONTAINER_DIR = "/var/backups";
|
|
|
4202
4268
|
const BACKUP_STORAGE_PREFIX = "backups";
|
|
4203
4269
|
const BACKUP_ARCHIVE_OBJECT_NAME = "data.sqsh";
|
|
4204
4270
|
const BACKUP_METADATA_OBJECT_NAME = "meta.json";
|
|
4271
|
+
const BACKUP_DEFAULT_COMPRESSION = "lz4";
|
|
4272
|
+
const BACKUP_DEFAULT_COMPRESS_THREADS = 8;
|
|
4273
|
+
const BACKUP_MULTIPART_MIN_SIZE = 10 * 1024 * 1024;
|
|
4274
|
+
const BACKUP_MULTIPART_TARGET_PARTS = 16;
|
|
4275
|
+
const BACKUP_MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
4276
|
+
const BACKUP_MULTIPART_MAX_PARTS = 64;
|
|
4277
|
+
const BACKUP_DOWNLOAD_PARALLEL_PARTS = 8;
|
|
4278
|
+
const BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE = 10 * 1024 * 1024;
|
|
4279
|
+
const BACKUP_DOWNLOAD_MAX_PARTS = 64;
|
|
4280
|
+
/**
|
|
4281
|
+
* Calculate the optimal number of parts for multipart upload/download
|
|
4282
|
+
* based on archive size. Larger archives benefit from more parallelism.
|
|
4283
|
+
*/
|
|
4284
|
+
function calculatePartCount(sizeBytes, defaultParts, maxParts) {
|
|
4285
|
+
if (sizeBytes < 100 * 1024 * 1024) return defaultParts;
|
|
4286
|
+
if (sizeBytes < 1024 * 1024 * 1024) return Math.min(32, defaultParts * 2);
|
|
4287
|
+
return maxParts;
|
|
4288
|
+
}
|
|
4289
|
+
/**
|
|
4290
|
+
* Tagged template literal that shell-escapes every interpolated value.
|
|
4291
|
+
* Use for composing in-container scripts where the template body is
|
|
4292
|
+
* trusted shell and the interpolations are untrusted strings.
|
|
4293
|
+
*/
|
|
4294
|
+
function sh(strings, ...values) {
|
|
4295
|
+
let out = strings[0];
|
|
4296
|
+
for (let i = 0; i < values.length; i++) out += shellEscape(String(values[i])) + strings[i + 1];
|
|
4297
|
+
return out;
|
|
4298
|
+
}
|
|
4299
|
+
/**
|
|
4300
|
+
* Hex string of `bytes` random bytes (length = bytes * 2). Used for short
|
|
4301
|
+
* non-cryptographic identifiers — e.g. tempfile suffixes.
|
|
4302
|
+
*/
|
|
4303
|
+
function randomHex(bytes) {
|
|
4304
|
+
const buf = new Uint8Array(bytes);
|
|
4305
|
+
crypto.getRandomValues(buf);
|
|
4306
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
4307
|
+
}
|
|
4308
|
+
/**
|
|
4309
|
+
* Parse an array of `key=value` / bare-flag s3fs options into a Record.
|
|
4310
|
+
* Bare flags become `{ flag: true }`. Later entries overwrite earlier ones.
|
|
4311
|
+
*/
|
|
4312
|
+
function parseS3fsOptions(entries) {
|
|
4313
|
+
const result = {};
|
|
4314
|
+
for (const entry of entries) {
|
|
4315
|
+
const eq = entry.indexOf("=");
|
|
4316
|
+
if (eq === -1) result[entry] = true;
|
|
4317
|
+
else result[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
4318
|
+
}
|
|
4319
|
+
return result;
|
|
4320
|
+
}
|
|
4321
|
+
/**
|
|
4322
|
+
* Serialise an s3fs options Record into the comma-separated `-o` argument.
|
|
4323
|
+
* Boolean true emits the bare flag; false drops it.
|
|
4324
|
+
*/
|
|
4325
|
+
function serializeS3fsOptions(options) {
|
|
4326
|
+
return Object.entries(options).filter(([, v]) => v !== false).map(([k, v]) => v === true ? k : `${k}=${v}`).join(",");
|
|
4327
|
+
}
|
|
4205
4328
|
function getNamespaceConfigurationCache(namespace) {
|
|
4206
4329
|
const existing = sandboxConfigurationCache.get(namespace);
|
|
4207
4330
|
if (existing) return existing;
|
|
@@ -4437,7 +4560,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4437
4560
|
return Math.max(12e4, startupBudgetMs + 3e4);
|
|
4438
4561
|
}
|
|
4439
4562
|
/**
|
|
4440
|
-
* Create
|
|
4563
|
+
* Create the route-based compatibility client with current HTTP/WebSocket
|
|
4564
|
+
* transport settings.
|
|
4441
4565
|
*/
|
|
4442
4566
|
createSandboxClient() {
|
|
4443
4567
|
return new SandboxClient({
|
|
@@ -4453,15 +4577,19 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4453
4577
|
});
|
|
4454
4578
|
}
|
|
4455
4579
|
/**
|
|
4456
|
-
* Create the appropriate client for
|
|
4580
|
+
* Create the appropriate client for the configured control path.
|
|
4581
|
+
*
|
|
4582
|
+
* `rpc` currently selects the primary container-control client. `http` and
|
|
4583
|
+
* `websocket` select the route-based compatibility client.
|
|
4457
4584
|
*/
|
|
4458
4585
|
createClientForTransport(transport) {
|
|
4459
4586
|
if (transport === "rpc") {
|
|
4460
4587
|
const self = this;
|
|
4461
|
-
return new
|
|
4588
|
+
return new ContainerControlClient({
|
|
4462
4589
|
stub: this,
|
|
4463
4590
|
port: 3e3,
|
|
4464
4591
|
logger: this.logger,
|
|
4592
|
+
retryTimeoutMs: this.computeRetryTimeoutMs(),
|
|
4465
4593
|
onActivity: () => {
|
|
4466
4594
|
this.renewActivityTimeout();
|
|
4467
4595
|
},
|
|
@@ -4743,6 +4871,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4743
4871
|
let mountError;
|
|
4744
4872
|
let passwordFilePath;
|
|
4745
4873
|
let provider = null;
|
|
4874
|
+
let dirExisted = true;
|
|
4746
4875
|
try {
|
|
4747
4876
|
this.validateMountOptions(bucket, mountPath, {
|
|
4748
4877
|
...options,
|
|
@@ -4774,6 +4903,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4774
4903
|
};
|
|
4775
4904
|
this.activeMounts.set(mountPath, mountInfo);
|
|
4776
4905
|
await this.createPasswordFile(passwordFilePath, bucket, credentials);
|
|
4906
|
+
dirExisted = (await this.execInternal(`test -d ${shellEscape(mountPath)}`)).exitCode === 0;
|
|
4777
4907
|
await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
|
|
4778
4908
|
await this.executeS3FSMount(s3fsSource, mountPath, options, provider, passwordFilePath);
|
|
4779
4909
|
mountInfo.mounted = true;
|
|
@@ -4781,6 +4911,12 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4781
4911
|
} catch (error) {
|
|
4782
4912
|
mountError = error instanceof Error ? error : new Error(String(error));
|
|
4783
4913
|
if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
|
|
4914
|
+
try {
|
|
4915
|
+
await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && fusermount -u ${shellEscape(mountPath)}`);
|
|
4916
|
+
} catch {}
|
|
4917
|
+
if (!dirExisted) try {
|
|
4918
|
+
await this.execInternal(`rmdir ${shellEscape(mountPath)} 2>/dev/null`);
|
|
4919
|
+
} catch {}
|
|
4784
4920
|
this.activeMounts.delete(mountPath);
|
|
4785
4921
|
throw error;
|
|
4786
4922
|
} finally {
|
|
@@ -4897,16 +5033,31 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4897
5033
|
* Execute S3FS mount command
|
|
4898
5034
|
*/
|
|
4899
5035
|
async executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath, sessionId) {
|
|
4900
|
-
const
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
const
|
|
4909
|
-
|
|
5036
|
+
const s3fsOptions = {
|
|
5037
|
+
logfile: `/tmp/.s3fs-log-${randomHex(4)}`,
|
|
5038
|
+
...parseS3fsOptions(resolveS3fsOptions(provider)),
|
|
5039
|
+
...parseS3fsOptions(options.s3fsOptions ?? []),
|
|
5040
|
+
passwd_file: passwordFilePath,
|
|
5041
|
+
url: options.endpoint,
|
|
5042
|
+
...options.readOnly ? { ro: true } : {}
|
|
5043
|
+
};
|
|
5044
|
+
const logFile = s3fsOptions.logfile;
|
|
5045
|
+
const script = sh`(
|
|
5046
|
+
s3fs ${bucket} ${mountPath} -o ${serializeS3fsOptions(s3fsOptions)} >${logFile} 2>&1
|
|
5047
|
+
rc=$?
|
|
5048
|
+
if [ "$rc" -ne 0 ]; then tail -n 20 ${logFile} 2>/dev/null || true; exit 2; fi
|
|
5049
|
+
for _ in $(seq 1 60); do
|
|
5050
|
+
if mountpoint -q ${mountPath}; then exit 0; fi
|
|
5051
|
+
sleep 0.1
|
|
5052
|
+
done
|
|
5053
|
+
tail -n 20 ${logFile} 2>/dev/null || true
|
|
5054
|
+
exit 3
|
|
5055
|
+
)`;
|
|
5056
|
+
const result = await (sessionId ? (cmd) => this.execWithSession(cmd, sessionId, { origin: "internal" }) : (cmd) => this.execInternal(cmd))(script);
|
|
5057
|
+
if (result.exitCode === 0) return;
|
|
5058
|
+
const detail = result.stdout?.trim() || result.stderr?.trim() || "";
|
|
5059
|
+
if (result.exitCode === 2) throw new S3FSMountError(`S3FS mount failed: ${detail || "Unknown error"}`);
|
|
5060
|
+
throw new S3FSMountError(`S3FS mount failed: FUSE filesystem never appeared at ${mountPath}. ${detail ? `s3fs log: ${detail}` : "No s3fs log output captured. The s3fs daemon may have exited before writing logs."}`);
|
|
4910
5061
|
}
|
|
4911
5062
|
/**
|
|
4912
5063
|
* In-flight `destroy()` promise. While set, concurrent callers coalesce
|
|
@@ -6458,6 +6609,42 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6458
6609
|
}
|
|
6459
6610
|
return normalizedExcludes;
|
|
6460
6611
|
}
|
|
6612
|
+
resolveBackupCompression(compression) {
|
|
6613
|
+
if (compression !== void 0) {
|
|
6614
|
+
if (typeof compression !== "object" || compression === null) throw new InvalidBackupConfigError({
|
|
6615
|
+
message: "BackupOptions.compression must be an object",
|
|
6616
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6617
|
+
httpStatus: 400,
|
|
6618
|
+
context: { reason: "compression must be an object" },
|
|
6619
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6620
|
+
});
|
|
6621
|
+
}
|
|
6622
|
+
const compressionOptions = compression;
|
|
6623
|
+
const format = compressionOptions?.format ?? BACKUP_DEFAULT_COMPRESSION;
|
|
6624
|
+
const threads = compressionOptions?.threads ?? BACKUP_DEFAULT_COMPRESS_THREADS;
|
|
6625
|
+
if (typeof format !== "string" || ![
|
|
6626
|
+
"gzip",
|
|
6627
|
+
"lz4",
|
|
6628
|
+
"zstd"
|
|
6629
|
+
].includes(format)) throw new InvalidBackupConfigError({
|
|
6630
|
+
message: "BackupOptions.compression.format must be one of: gzip, lz4, zstd",
|
|
6631
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6632
|
+
httpStatus: 400,
|
|
6633
|
+
context: { reason: "compression.format must be one of: gzip, lz4, zstd" },
|
|
6634
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6635
|
+
});
|
|
6636
|
+
if (typeof threads !== "number" || !Number.isInteger(threads) || threads < 1) throw new InvalidBackupConfigError({
|
|
6637
|
+
message: "BackupOptions.compression.threads must be a positive integer",
|
|
6638
|
+
code: ErrorCode.INVALID_BACKUP_CONFIG,
|
|
6639
|
+
httpStatus: 400,
|
|
6640
|
+
context: { reason: "compression.threads must be a positive integer" },
|
|
6641
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6642
|
+
});
|
|
6643
|
+
return {
|
|
6644
|
+
format,
|
|
6645
|
+
threads
|
|
6646
|
+
};
|
|
6647
|
+
}
|
|
6461
6648
|
static PRESIGNED_URL_EXPIRY_SECONDS = 3600;
|
|
6462
6649
|
/**
|
|
6463
6650
|
* Create a unique, dedicated session for a single backup operation.
|
|
@@ -6499,6 +6686,18 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6499
6686
|
};
|
|
6500
6687
|
}
|
|
6501
6688
|
/**
|
|
6689
|
+
* Generate a presigned GET URL for downloading an object from R2.
|
|
6690
|
+
* The container can curl this URL directly without credentials.
|
|
6691
|
+
*/
|
|
6692
|
+
async generatePresignedGetUrl(r2Key) {
|
|
6693
|
+
const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
|
|
6694
|
+
const encodedBucket = encodeURIComponent(bucketName);
|
|
6695
|
+
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
6696
|
+
const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
|
|
6697
|
+
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
6698
|
+
return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
|
|
6699
|
+
}
|
|
6700
|
+
/**
|
|
6502
6701
|
* Generate a presigned PUT URL for uploading an object to R2.
|
|
6503
6702
|
* The container can curl PUT to this URL directly without credentials.
|
|
6504
6703
|
*/
|
|
@@ -6558,51 +6757,248 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6558
6757
|
}
|
|
6559
6758
|
}
|
|
6560
6759
|
/**
|
|
6561
|
-
*
|
|
6760
|
+
* Generate a presigned PUT URL for a single part in a multipart upload.
|
|
6562
6761
|
*/
|
|
6563
|
-
async
|
|
6564
|
-
const { accountId, bucketName } = this.requirePresignedUrlSupport();
|
|
6565
|
-
const
|
|
6566
|
-
const
|
|
6567
|
-
const
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6762
|
+
async generatePresignedPartUrl(r2Key, uploadId, partNumber) {
|
|
6763
|
+
const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
|
|
6764
|
+
const encodedBucket = encodeURIComponent(bucketName);
|
|
6765
|
+
const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
6766
|
+
const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
|
|
6767
|
+
url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
|
|
6768
|
+
url.searchParams.set("partNumber", String(partNumber));
|
|
6769
|
+
url.searchParams.set("uploadId", uploadId);
|
|
6770
|
+
return (await client.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } })).url;
|
|
6771
|
+
}
|
|
6772
|
+
/**
|
|
6773
|
+
* Upload a backup archive to R2 using parallel multipart upload.
|
|
6774
|
+
* Uses the S3-compatible API exclusively for create/complete/abort so that
|
|
6775
|
+
* the uploadId is in the same namespace as the presigned part PUT URLs.
|
|
6776
|
+
*/
|
|
6777
|
+
async uploadBackupMultipart(archivePath, r2Key, sizeBytes, backupId, dir, backupSession) {
|
|
6778
|
+
const targetParts = calculatePartCount(sizeBytes, BACKUP_MULTIPART_TARGET_PARTS, BACKUP_MULTIPART_MAX_PARTS);
|
|
6779
|
+
const numParts = Math.min(targetParts, Math.floor(sizeBytes / BACKUP_MULTIPART_MIN_PART_SIZE));
|
|
6780
|
+
if (numParts <= 1) return this.uploadBackupPresigned(archivePath, r2Key, sizeBytes, backupId, dir, backupSession);
|
|
6781
|
+
const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
|
|
6782
|
+
const objectUrl = `https://${accountId}.r2.cloudflarestorage.com/${encodeURIComponent(bucketName)}/${r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/")}`;
|
|
6783
|
+
const createResp = await client.fetch(`${objectUrl}?uploads`, { method: "POST" });
|
|
6784
|
+
if (!createResp.ok) throw new BackupCreateError({
|
|
6785
|
+
message: `Failed to initiate multipart upload: HTTP ${createResp.status}`,
|
|
6786
|
+
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
6787
|
+
httpStatus: 500,
|
|
6788
|
+
context: {
|
|
6789
|
+
dir,
|
|
6790
|
+
backupId
|
|
6791
|
+
},
|
|
6792
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6793
|
+
});
|
|
6794
|
+
const uploadId = (await createResp.text()).match(/<UploadId>([^<]+)<\/UploadId>/)?.[1];
|
|
6795
|
+
if (!uploadId) throw new BackupCreateError({
|
|
6796
|
+
message: "Multipart upload response did not contain an UploadId",
|
|
6797
|
+
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
6798
|
+
httpStatus: 500,
|
|
6799
|
+
context: {
|
|
6800
|
+
dir,
|
|
6801
|
+
backupId
|
|
6802
|
+
},
|
|
6803
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6583
6804
|
});
|
|
6584
|
-
const
|
|
6585
|
-
|
|
6586
|
-
bucket: s3fsSource,
|
|
6587
|
-
mountPath,
|
|
6588
|
-
endpoint,
|
|
6589
|
-
provider: "r2",
|
|
6590
|
-
passwordFilePath,
|
|
6591
|
-
mounted: false
|
|
6805
|
+
const abortMultipart = async () => {
|
|
6806
|
+
await client.fetch(`${objectUrl}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
|
|
6592
6807
|
};
|
|
6593
|
-
this.activeMounts.set(mountPath, mountInfo);
|
|
6594
6808
|
try {
|
|
6595
|
-
|
|
6596
|
-
await
|
|
6597
|
-
|
|
6598
|
-
|
|
6809
|
+
const partSize = Math.ceil(sizeBytes / numParts);
|
|
6810
|
+
const parts = await Promise.all(Array.from({ length: numParts }, (_, i) => ({
|
|
6811
|
+
partNumber: i + 1,
|
|
6812
|
+
url: "",
|
|
6813
|
+
offset: i * partSize,
|
|
6814
|
+
size: i === numParts - 1 ? sizeBytes - i * partSize : partSize
|
|
6815
|
+
})).map(async (part) => ({
|
|
6816
|
+
...part,
|
|
6817
|
+
url: await this.generatePresignedPartUrl(r2Key, uploadId, part.partNumber)
|
|
6818
|
+
})));
|
|
6819
|
+
let uploadResult;
|
|
6820
|
+
try {
|
|
6821
|
+
uploadResult = await this.client.backup.uploadParts({
|
|
6822
|
+
archivePath,
|
|
6823
|
+
parts,
|
|
6824
|
+
sessionId: backupSession
|
|
6825
|
+
});
|
|
6826
|
+
} catch (err) {
|
|
6827
|
+
if (err instanceof SandboxError && err.errorResponse.httpStatus === 404) {
|
|
6828
|
+
await abortMultipart();
|
|
6829
|
+
return this.uploadBackupPresigned(archivePath, r2Key, sizeBytes, backupId, dir, backupSession);
|
|
6830
|
+
}
|
|
6831
|
+
throw err;
|
|
6832
|
+
}
|
|
6833
|
+
if (!uploadResult.success || uploadResult.parts.length !== numParts) throw new BackupCreateError({
|
|
6834
|
+
message: `Multipart upload returned ${uploadResult.parts.length} of ${numParts} parts`,
|
|
6835
|
+
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
6836
|
+
httpStatus: 500,
|
|
6837
|
+
context: {
|
|
6838
|
+
dir,
|
|
6839
|
+
backupId
|
|
6840
|
+
},
|
|
6841
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6842
|
+
});
|
|
6843
|
+
const completeXml = [
|
|
6844
|
+
"<CompleteMultipartUpload>",
|
|
6845
|
+
...uploadResult.parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`),
|
|
6846
|
+
"</CompleteMultipartUpload>"
|
|
6847
|
+
].join("");
|
|
6848
|
+
const completeResp = await client.fetch(`${objectUrl}?uploadId=${encodeURIComponent(uploadId)}`, {
|
|
6849
|
+
method: "POST",
|
|
6850
|
+
headers: { "Content-Type": "application/xml" },
|
|
6851
|
+
body: completeXml
|
|
6852
|
+
});
|
|
6853
|
+
if (!completeResp.ok) {
|
|
6854
|
+
const body = await completeResp.text().catch(() => "");
|
|
6855
|
+
throw new BackupCreateError({
|
|
6856
|
+
message: `Multipart upload completion failed: HTTP ${completeResp.status} ${body}`,
|
|
6857
|
+
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
6858
|
+
httpStatus: 500,
|
|
6859
|
+
context: {
|
|
6860
|
+
dir,
|
|
6861
|
+
backupId
|
|
6862
|
+
},
|
|
6863
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6864
|
+
});
|
|
6865
|
+
}
|
|
6866
|
+
const head = await this.requireBackupBucket().head(r2Key);
|
|
6867
|
+
if (!head || head.size !== sizeBytes) throw new BackupCreateError({
|
|
6868
|
+
message: `Multipart upload verification failed: expected ${sizeBytes} bytes, got ${head?.size ?? 0}`,
|
|
6869
|
+
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
6870
|
+
httpStatus: 500,
|
|
6871
|
+
context: {
|
|
6872
|
+
dir,
|
|
6873
|
+
backupId
|
|
6874
|
+
},
|
|
6875
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6876
|
+
});
|
|
6599
6877
|
} catch (error) {
|
|
6600
|
-
await
|
|
6601
|
-
this.activeMounts.delete(mountPath);
|
|
6878
|
+
await abortMultipart();
|
|
6602
6879
|
throw error;
|
|
6603
6880
|
}
|
|
6604
6881
|
}
|
|
6605
6882
|
/**
|
|
6883
|
+
* Download a backup archive from R2 via presigned GET URL.
|
|
6884
|
+
* For archives >= BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE, uses BACKUP_DOWNLOAD_PARALLEL_PARTS
|
|
6885
|
+
* concurrent curl processes (each downloading a byte-range) to maximise both
|
|
6886
|
+
* network and disk-write throughput. Parts are written into a pre-sized file
|
|
6887
|
+
* with dd using byte offsets, then atomically moved to the final path.
|
|
6888
|
+
*/
|
|
6889
|
+
async downloadBackupParallel(archivePath, r2Key, expectedSize, backupId, dir, backupSession) {
|
|
6890
|
+
const presignedUrl = await this.generatePresignedGetUrl(r2Key);
|
|
6891
|
+
await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
|
|
6892
|
+
const tmpPath = `${archivePath}.tmp`;
|
|
6893
|
+
if (expectedSize < BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE) {
|
|
6894
|
+
const curlCmd = [
|
|
6895
|
+
"curl -sSf",
|
|
6896
|
+
"--connect-timeout 10",
|
|
6897
|
+
"--max-time 1800",
|
|
6898
|
+
"--retry 2",
|
|
6899
|
+
"--retry-max-time 60",
|
|
6900
|
+
`-o ${shellEscape(tmpPath)}`,
|
|
6901
|
+
shellEscape(presignedUrl)
|
|
6902
|
+
].join(" ");
|
|
6903
|
+
const result = await this.execWithSession(curlCmd, backupSession, {
|
|
6904
|
+
timeout: 181e4,
|
|
6905
|
+
origin: "internal"
|
|
6906
|
+
});
|
|
6907
|
+
if (result.exitCode !== 0) {
|
|
6908
|
+
await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession, { origin: "internal" }).catch(() => {});
|
|
6909
|
+
throw new BackupRestoreError({
|
|
6910
|
+
message: `Presigned URL download failed (exit code ${result.exitCode}): ${result.stderr}`,
|
|
6911
|
+
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
6912
|
+
httpStatus: 500,
|
|
6913
|
+
context: {
|
|
6914
|
+
dir,
|
|
6915
|
+
backupId
|
|
6916
|
+
},
|
|
6917
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6918
|
+
});
|
|
6919
|
+
}
|
|
6920
|
+
} else {
|
|
6921
|
+
const numParts = calculatePartCount(expectedSize, BACKUP_DOWNLOAD_PARALLEL_PARTS, BACKUP_DOWNLOAD_MAX_PARTS);
|
|
6922
|
+
const partSize = Math.floor(expectedSize / numParts);
|
|
6923
|
+
const startLines = Array.from({ length: numParts }, (_, i) => {
|
|
6924
|
+
const start = i * partSize;
|
|
6925
|
+
return {
|
|
6926
|
+
start,
|
|
6927
|
+
range: `${start}-${i < numParts - 1 ? start + partSize - 1 : expectedSize - 1}`
|
|
6928
|
+
};
|
|
6929
|
+
}).map(({ start, range }) => [
|
|
6930
|
+
"curl -sSf",
|
|
6931
|
+
"--connect-timeout 10",
|
|
6932
|
+
"--max-time 1800",
|
|
6933
|
+
`-H ${shellEscape(`Range: bytes=${range}`)}`,
|
|
6934
|
+
shellEscape(presignedUrl),
|
|
6935
|
+
"|",
|
|
6936
|
+
"dd",
|
|
6937
|
+
`of=${shellEscape(tmpPath)}`,
|
|
6938
|
+
"oflag=seek_bytes",
|
|
6939
|
+
`seek=${start}`,
|
|
6940
|
+
"conv=notrunc",
|
|
6941
|
+
"2>/dev/null"
|
|
6942
|
+
].join(" ")).map((cmd, i) => `(set -o pipefail; ${cmd}) & J${i}=$!`);
|
|
6943
|
+
const waitLines = Array.from({ length: numParts }, (_, i) => `wait $J${i}; E${i}=$?`);
|
|
6944
|
+
const exitVars = Array.from({ length: numParts }, (_, i) => `$E${i}`);
|
|
6945
|
+
const script = [
|
|
6946
|
+
`rm -f ${shellEscape(tmpPath)}`,
|
|
6947
|
+
`truncate -s ${expectedSize} ${shellEscape(tmpPath)}`,
|
|
6948
|
+
...startLines,
|
|
6949
|
+
...waitLines,
|
|
6950
|
+
`FAILED=$(( ${exitVars.join(" + ")} ))`,
|
|
6951
|
+
`if [ "$FAILED" -ne 0 ]; then rm -f ${shellEscape(tmpPath)}; exit 1; fi`
|
|
6952
|
+
].join("; ");
|
|
6953
|
+
const result = await this.execWithSession(script, backupSession, {
|
|
6954
|
+
timeout: 181e4,
|
|
6955
|
+
origin: "internal"
|
|
6956
|
+
});
|
|
6957
|
+
if (result.exitCode !== 0) {
|
|
6958
|
+
await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession, { origin: "internal" }).catch(() => {});
|
|
6959
|
+
throw new BackupRestoreError({
|
|
6960
|
+
message: `Parallel download failed (exit code ${result.exitCode}): ${result.stderr}`,
|
|
6961
|
+
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
6962
|
+
httpStatus: 500,
|
|
6963
|
+
context: {
|
|
6964
|
+
dir,
|
|
6965
|
+
backupId
|
|
6966
|
+
},
|
|
6967
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6968
|
+
});
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
const sizeCheck = await this.execWithSession(`stat -c %s ${shellEscape(tmpPath)}`, backupSession, { origin: "internal" });
|
|
6972
|
+
const actualSize = parseInt(sizeCheck.stdout.trim(), 10);
|
|
6973
|
+
if (actualSize !== expectedSize) {
|
|
6974
|
+
await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession, { origin: "internal" }).catch(() => {});
|
|
6975
|
+
throw new BackupRestoreError({
|
|
6976
|
+
message: `Downloaded archive size mismatch: expected ${expectedSize}, got ${actualSize}`,
|
|
6977
|
+
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
6978
|
+
httpStatus: 500,
|
|
6979
|
+
context: {
|
|
6980
|
+
dir,
|
|
6981
|
+
backupId
|
|
6982
|
+
},
|
|
6983
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6984
|
+
});
|
|
6985
|
+
}
|
|
6986
|
+
const mvResult = await this.execWithSession(`mv ${shellEscape(tmpPath)} ${shellEscape(archivePath)}`, backupSession, { origin: "internal" });
|
|
6987
|
+
if (mvResult.exitCode !== 0) {
|
|
6988
|
+
await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession, { origin: "internal" }).catch(() => {});
|
|
6989
|
+
throw new BackupRestoreError({
|
|
6990
|
+
message: `Failed to finalize downloaded archive: ${mvResult.stderr}`,
|
|
6991
|
+
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
6992
|
+
httpStatus: 500,
|
|
6993
|
+
context: {
|
|
6994
|
+
dir,
|
|
6995
|
+
backupId
|
|
6996
|
+
},
|
|
6997
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6998
|
+
});
|
|
6999
|
+
}
|
|
7000
|
+
}
|
|
7001
|
+
/**
|
|
6606
7002
|
* Serialize backup operations on this sandbox instance.
|
|
6607
7003
|
* Concurrent backup/restore calls are queued so the multi-step
|
|
6608
7004
|
* create-archive → read → upload (or mount → extract) flow
|
|
@@ -6642,7 +7038,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6642
7038
|
async doCreateBackup(options) {
|
|
6643
7039
|
const bucket = this.requireBackupBucket();
|
|
6644
7040
|
this.requirePresignedUrlSupport();
|
|
6645
|
-
const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [] } = options;
|
|
7041
|
+
const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [], compression, multipart = true } = options;
|
|
6646
7042
|
const backupStartTime = Date.now();
|
|
6647
7043
|
let backupId;
|
|
6648
7044
|
let sizeBytes;
|
|
@@ -6688,13 +7084,15 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6688
7084
|
context: { reason: "excludes must be an array of strings" },
|
|
6689
7085
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6690
7086
|
});
|
|
7087
|
+
const resolvedCompression = this.resolveBackupCompression(compression);
|
|
6691
7088
|
const normalizedExcludes = this.normalizeBackupExcludes(excludes);
|
|
6692
7089
|
backupSession = await this.ensureBackupSession();
|
|
6693
7090
|
backupId = crypto.randomUUID();
|
|
6694
7091
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
|
|
6695
7092
|
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
|
|
6696
7093
|
gitignore,
|
|
6697
|
-
excludes: normalizedExcludes
|
|
7094
|
+
excludes: normalizedExcludes,
|
|
7095
|
+
compression: resolvedCompression
|
|
6698
7096
|
});
|
|
6699
7097
|
if (!createResult.success) throw new BackupCreateError({
|
|
6700
7098
|
message: "Container failed to create backup archive",
|
|
@@ -6709,7 +7107,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6709
7107
|
sizeBytes = createResult.sizeBytes;
|
|
6710
7108
|
const r2Key = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
|
|
6711
7109
|
const metaKey = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_METADATA_OBJECT_NAME}`;
|
|
6712
|
-
await this.
|
|
7110
|
+
if (multipart && createResult.sizeBytes >= BACKUP_MULTIPART_MIN_SIZE) await this.uploadBackupMultipart(archivePath, r2Key, createResult.sizeBytes, backupId, dir, backupSession);
|
|
7111
|
+
else await this.uploadBackupPresigned(archivePath, r2Key, createResult.sizeBytes, backupId, dir, backupSession);
|
|
6713
7112
|
const metadata = {
|
|
6714
7113
|
id: backupId,
|
|
6715
7114
|
dir,
|
|
@@ -6756,7 +7155,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6756
7155
|
* Archive format is identical to production (squashfs + meta.json).
|
|
6757
7156
|
*/
|
|
6758
7157
|
async doCreateBackupLocal(options) {
|
|
6759
|
-
const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [] } = options;
|
|
7158
|
+
const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [], compression } = options;
|
|
6760
7159
|
const backupStartTime = Date.now();
|
|
6761
7160
|
let backupId;
|
|
6762
7161
|
let sizeBytes;
|
|
@@ -6810,13 +7209,15 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6810
7209
|
context: { reason: "excludes must be an array of strings" },
|
|
6811
7210
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6812
7211
|
});
|
|
7212
|
+
const resolvedCompression = this.resolveBackupCompression(compression);
|
|
6813
7213
|
const normalizedExcludes = this.normalizeBackupExcludes(excludes);
|
|
6814
7214
|
backupSession = await this.ensureBackupSession();
|
|
6815
7215
|
backupId = crypto.randomUUID();
|
|
6816
7216
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
|
|
6817
7217
|
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
|
|
6818
7218
|
gitignore,
|
|
6819
|
-
excludes: normalizedExcludes
|
|
7219
|
+
excludes: normalizedExcludes,
|
|
7220
|
+
compression: resolvedCompression
|
|
6820
7221
|
});
|
|
6821
7222
|
if (!createResult.success) throw new BackupCreateError({
|
|
6822
7223
|
message: "Container failed to create backup archive",
|
|
@@ -6982,7 +7383,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6982
7383
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6983
7384
|
});
|
|
6984
7385
|
const r2Key = `${BACKUP_STORAGE_PREFIX}/${id}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
|
|
6985
|
-
|
|
7386
|
+
const archiveHead = await bucket.head(r2Key);
|
|
7387
|
+
if (!archiveHead) throw new BackupNotFoundError({
|
|
6986
7388
|
message: `Backup archive not found in R2: ${id}. The archive may have been deleted by R2 lifecycle rules.`,
|
|
6987
7389
|
code: ErrorCode.BACKUP_NOT_FOUND,
|
|
6988
7390
|
httpStatus: 404,
|
|
@@ -6990,19 +7392,12 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6990
7392
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6991
7393
|
});
|
|
6992
7394
|
backupSession = await this.ensureBackupSession();
|
|
6993
|
-
const
|
|
6994
|
-
const
|
|
6995
|
-
const mountGlob = `/var/backups/mounts/r2mount/${id}/data`;
|
|
7395
|
+
const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
|
|
7396
|
+
const mountGlob = `${BACKUP_CONTAINER_DIR}/mounts/${id}`;
|
|
6996
7397
|
await this.execWithSession(`/usr/bin/fusermount3 -uz ${shellEscape(dir)} 2>/dev/null || true`, backupSession, { origin: "internal" }).catch(() => {});
|
|
6997
7398
|
await this.execWithSession(`for d in ${shellEscape(mountGlob)}_*/lower ${shellEscape(mountGlob)}/lower; do [ -d "$d" ] && /usr/bin/fusermount3 -uz "$d" 2>/dev/null; done; true`, backupSession, { origin: "internal" }).catch(() => {});
|
|
6998
|
-
await this.execWithSession(
|
|
6999
|
-
|
|
7000
|
-
if (previousBackupMount?.mountType === "fuse") {
|
|
7001
|
-
previousBackupMount.mounted = false;
|
|
7002
|
-
this.activeMounts.delete(r2MountPath);
|
|
7003
|
-
await this.deletePasswordFile(previousBackupMount.passwordFilePath);
|
|
7004
|
-
}
|
|
7005
|
-
await this.mountBackupR2(r2MountPath, `backups/${id}/`, backupSession);
|
|
7399
|
+
const sizeCheck = await this.execWithSession(`stat -c %s ${shellEscape(archivePath)} 2>/dev/null || echo 0`, backupSession, { origin: "internal" }).catch(() => ({ stdout: "0" }));
|
|
7400
|
+
if (Number.parseInt((sizeCheck.stdout ?? "0").trim(), 10) !== archiveHead.size) await this.downloadBackupParallel(archivePath, r2Key, archiveHead.size, id, dir, backupSession);
|
|
7006
7401
|
if (!(await this.client.backup.restoreArchive(dir, archivePath, backupSession)).success) throw new BackupRestoreError({
|
|
7007
7402
|
message: "Container failed to restore backup archive",
|
|
7008
7403
|
code: ErrorCode.BACKUP_RESTORE_FAILED,
|
|
@@ -7021,6 +7416,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7021
7416
|
};
|
|
7022
7417
|
} catch (error) {
|
|
7023
7418
|
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
7419
|
+
if (id && backupSession) {
|
|
7420
|
+
const cleanupPath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
|
|
7421
|
+
await this.execWithSession(`rm -f ${shellEscape(cleanupPath)}`, backupSession, { origin: "internal" }).catch(() => {});
|
|
7422
|
+
}
|
|
7024
7423
|
throw error;
|
|
7025
7424
|
} finally {
|
|
7026
7425
|
if (backupSession) await this.client.utils.deleteSession(backupSession).catch(() => {});
|
|
@@ -7169,4 +7568,4 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7169
7568
|
|
|
7170
7569
|
//#endregion
|
|
7171
7570
|
export { DesktopInvalidOptionsError as A, CommandClient as C, BackupNotFoundError as D, BackupExpiredError as E, InvalidBackupConfigError as F, ProcessExitedBeforeReadyError as I, ProcessReadyTimeoutError as L, DesktopProcessCrashedError as M, DesktopStartFailedError as N, BackupRestoreError as O, DesktopUnavailableError as P, RPCTransportError as R, DesktopClient as S, BackupCreateError as T, UtilityClient as _, BucketMountError as a, GitClient as b, MissingCredentialsError as c, parseSSEStream as d, responseToAsyncIterable as f, SandboxClient as g, streamFile as h, proxyTerminal as i, DesktopNotStartedError as j, DesktopInvalidCoordinatesError as k, S3FSMountError as l, collectFile as m, getSandbox as n, BucketUnmountError as o, CodeInterpreter as p, proxyToSandbox as r, InvalidMountConfigError as s, Sandbox as t, asyncIterableToSSEStream as u, ProcessClient as v, BackupClient as w, FileClient as x, PortClient as y, SessionTerminatedError as z };
|
|
7172
|
-
//# sourceMappingURL=sandbox-
|
|
7571
|
+
//# sourceMappingURL=sandbox-CdWjEUHl.js.map
|