@cloudflare/sandbox 0.8.14 → 0.9.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/dist/bridge/index.js +3 -3
- package/dist/{contexts-icMN26lE.d.ts → contexts-D6kt6WyG.d.ts} +7 -2
- package/dist/contexts-D6kt6WyG.d.ts.map +1 -0
- package/dist/{dist-Ilf8VjmX.js → dist-B_eXrP83.js} +35 -48
- package/dist/dist-B_eXrP83.js.map +1 -0
- package/dist/{errors-Bz21XTBJ.js → errors-LE3HHcRb.js} +11 -2
- package/dist/errors-LE3HHcRb.js.map +1 -0
- package/dist/index.d.ts +16 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/openai/index.d.ts +1 -1
- package/dist/openai/index.js +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/opencode/index.js +2 -2
- package/dist/{sandbox-Chr1Ebo-.d.ts → sandbox-Bb3n0SeC.d.ts} +452 -20
- package/dist/sandbox-Bb3n0SeC.d.ts.map +1 -0
- package/dist/{sandbox-CUVJMlma.js → sandbox-PAYx1CcU.js} +652 -61
- package/dist/sandbox-PAYx1CcU.js.map +1 -0
- package/package.json +2 -1
- package/dist/contexts-icMN26lE.d.ts.map +0 -1
- package/dist/dist-Ilf8VjmX.js.map +0 -1
- package/dist/errors-Bz21XTBJ.js.map +0 -1
- package/dist/sandbox-CUVJMlma.js.map +0 -1
- package/dist/sandbox-Chr1Ebo-.d.ts.map +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { _ as GitLogger, b as getEnvString, c as parseSSEFrames, d as createNoOpLogger, f as TraceContext, g as DEFAULT_GIT_CLONE_TIMEOUT_MS, h as ResultImpl, i as isWSStreamChunk, l as shellEscape, m as Execution, n as isWSError, p as logCanonicalEvent, r as isWSResponse, t as generateRequestId, u as createLogger, v as extractRepoName, x as partitionEnvVars, y as filterEnvVars } from "./dist-
|
|
2
|
-
import {
|
|
1
|
+
import { _ as GitLogger, b as getEnvString, c as parseSSEFrames, d as createNoOpLogger, f as TraceContext, g as DEFAULT_GIT_CLONE_TIMEOUT_MS, h as ResultImpl, i as isWSStreamChunk, l as shellEscape, m as Execution, n as isWSError, p as logCanonicalEvent, r as isWSResponse, t as generateRequestId, u as createLogger, v as extractRepoName, x as partitionEnvVars, y as filterEnvVars } from "./dist-B_eXrP83.js";
|
|
2
|
+
import { n as ErrorCode, t as getHttpStatus } from "./errors-LE3HHcRb.js";
|
|
3
3
|
import { Container, getContainer, switchPort } from "@cloudflare/containers";
|
|
4
4
|
import { AwsClient } from "aws4fetch";
|
|
5
|
+
import { RpcSession } from "capnweb";
|
|
5
6
|
import path from "node:path/posix";
|
|
6
7
|
|
|
7
8
|
//#region src/errors/classes.ts
|
|
@@ -194,6 +195,9 @@ var SessionAlreadyExistsError = class extends SandboxError {
|
|
|
194
195
|
get sessionId() {
|
|
195
196
|
return this.context.sessionId;
|
|
196
197
|
}
|
|
198
|
+
get containerPlacementId() {
|
|
199
|
+
return this.context.containerPlacementId;
|
|
200
|
+
}
|
|
197
201
|
};
|
|
198
202
|
/**
|
|
199
203
|
* Error thrown when a session was destroyed while a command was executing
|
|
@@ -208,6 +212,24 @@ var SessionDestroyedError = class extends SandboxError {
|
|
|
208
212
|
}
|
|
209
213
|
};
|
|
210
214
|
/**
|
|
215
|
+
* Error thrown when a session's underlying shell exited without an explicit
|
|
216
|
+
* `destroy()` call (user ran `exit`, the shell crashed, or a child process
|
|
217
|
+
* took the shell down). The session-local state is gone, but the next call
|
|
218
|
+
* with the same sessionId will transparently start a fresh session.
|
|
219
|
+
*/
|
|
220
|
+
var SessionTerminatedError = class extends SandboxError {
|
|
221
|
+
constructor(errorResponse) {
|
|
222
|
+
super(errorResponse);
|
|
223
|
+
this.name = "SessionTerminatedError";
|
|
224
|
+
}
|
|
225
|
+
get sessionId() {
|
|
226
|
+
return this.context.sessionId;
|
|
227
|
+
}
|
|
228
|
+
get exitCode() {
|
|
229
|
+
return this.context.exitCode;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
211
233
|
* Error thrown when a port is already exposed
|
|
212
234
|
*/
|
|
213
235
|
var PortAlreadyExposedError = class extends SandboxError {
|
|
@@ -674,6 +696,7 @@ function createErrorFromResponse(errorResponse) {
|
|
|
674
696
|
case ErrorCode.PROCESS_ERROR: return new ProcessError(errorResponse);
|
|
675
697
|
case ErrorCode.SESSION_ALREADY_EXISTS: return new SessionAlreadyExistsError(errorResponse);
|
|
676
698
|
case ErrorCode.SESSION_DESTROYED: return new SessionDestroyedError(errorResponse);
|
|
699
|
+
case ErrorCode.SESSION_TERMINATED: return new SessionTerminatedError(errorResponse);
|
|
677
700
|
case ErrorCode.PORT_ALREADY_EXPOSED: return new PortAlreadyExposedError(errorResponse);
|
|
678
701
|
case ErrorCode.PORT_NOT_EXPOSED: return new PortNotExposedError(errorResponse);
|
|
679
702
|
case ErrorCode.INVALID_PORT_NUMBER:
|
|
@@ -854,8 +877,8 @@ var HttpTransport = class extends BaseTransport {
|
|
|
854
877
|
*/
|
|
855
878
|
const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
856
879
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
857
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
858
|
-
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
880
|
+
const DEFAULT_CONNECT_TIMEOUT_MS$1 = 3e4;
|
|
881
|
+
const DEFAULT_IDLE_DISCONNECT_MS$1 = 1e3;
|
|
859
882
|
const MIN_TIME_FOR_CONNECT_RETRY_MS = 15e3;
|
|
860
883
|
/**
|
|
861
884
|
* WebSocket transport implementation
|
|
@@ -1011,7 +1034,7 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1011
1034
|
* parent Container class that supports the WebSocket protocol.
|
|
1012
1035
|
*/
|
|
1013
1036
|
async connectViaFetch() {
|
|
1014
|
-
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1037
|
+
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
|
|
1015
1038
|
try {
|
|
1016
1039
|
const wsPath = new URL(this.config.wsUrl).pathname;
|
|
1017
1040
|
const httpUrl = `http://localhost:${this.config.port || 3e3}${wsPath}`;
|
|
@@ -1052,7 +1075,7 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1052
1075
|
*/
|
|
1053
1076
|
connectViaWebSocket() {
|
|
1054
1077
|
return new Promise((resolve, reject) => {
|
|
1055
|
-
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1078
|
+
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
|
|
1056
1079
|
const timeout = setTimeout(() => {
|
|
1057
1080
|
this.cleanup();
|
|
1058
1081
|
reject(/* @__PURE__ */ new Error(`WebSocket connection timeout after ${timeoutMs}ms`));
|
|
@@ -1434,7 +1457,7 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1434
1457
|
this.logger.debug("Disconnecting idle WebSocket transport");
|
|
1435
1458
|
this.cleanup();
|
|
1436
1459
|
}
|
|
1437
|
-
}, DEFAULT_IDLE_DISCONNECT_MS);
|
|
1460
|
+
}, DEFAULT_IDLE_DISCONNECT_MS$1);
|
|
1438
1461
|
}
|
|
1439
1462
|
clearIdleDisconnectTimer() {
|
|
1440
1463
|
if (this.idleDisconnectTimer) {
|
|
@@ -1644,12 +1667,12 @@ var BackupClient = class extends BaseHttpClient {
|
|
|
1644
1667
|
* @param archivePath - Where the container should write the archive
|
|
1645
1668
|
* @param sessionId - Session context
|
|
1646
1669
|
*/
|
|
1647
|
-
async createArchive(dir, archivePath, sessionId,
|
|
1670
|
+
async createArchive(dir, archivePath, sessionId, options) {
|
|
1648
1671
|
const data = {
|
|
1649
1672
|
dir,
|
|
1650
1673
|
archivePath,
|
|
1651
|
-
gitignore,
|
|
1652
|
-
excludes,
|
|
1674
|
+
gitignore: options?.gitignore ?? false,
|
|
1675
|
+
excludes: options?.excludes ?? [],
|
|
1653
1676
|
sessionId
|
|
1654
1677
|
};
|
|
1655
1678
|
return await this.post("/api/backup/create", data);
|
|
@@ -2570,14 +2593,13 @@ var WatchClient = class extends BaseHttpClient {
|
|
|
2570
2593
|
//#endregion
|
|
2571
2594
|
//#region src/clients/sandbox-client.ts
|
|
2572
2595
|
/**
|
|
2573
|
-
* Main sandbox client that composes all domain-specific clients
|
|
2574
|
-
* Provides organized access to all sandbox functionality
|
|
2596
|
+
* Main sandbox client that composes all domain-specific clients.
|
|
2597
|
+
* Provides organized access to all sandbox functionality.
|
|
2575
2598
|
*
|
|
2576
2599
|
* Supports two transport modes:
|
|
2577
2600
|
* - HTTP (default): Each request is a separate HTTP call
|
|
2578
|
-
* - WebSocket: All requests multiplexed over a single connection
|
|
2579
|
-
*
|
|
2580
|
-
* WebSocket mode reduces sub-request count when running inside Workers/Durable Objects.
|
|
2601
|
+
* - WebSocket: All requests multiplexed over a single connection,
|
|
2602
|
+
* reducing sub-request count inside Workers/Durable Objects
|
|
2581
2603
|
*/
|
|
2582
2604
|
var SandboxClient = class {
|
|
2583
2605
|
backup;
|
|
@@ -2593,7 +2615,7 @@ var SandboxClient = class {
|
|
|
2593
2615
|
transport = null;
|
|
2594
2616
|
constructor(options) {
|
|
2595
2617
|
if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
|
|
2596
|
-
mode:
|
|
2618
|
+
mode: options.transportMode,
|
|
2597
2619
|
wsUrl: options.wsUrl,
|
|
2598
2620
|
baseUrl: options.baseUrl,
|
|
2599
2621
|
logger: options.logger,
|
|
@@ -2652,6 +2674,16 @@ var SandboxClient = class {
|
|
|
2652
2674
|
return this.transport?.isConnected() ?? false;
|
|
2653
2675
|
}
|
|
2654
2676
|
/**
|
|
2677
|
+
* Stream a file directly to the container over a binary RPC channel.
|
|
2678
|
+
*
|
|
2679
|
+
* Requires the capnweb transport (`useWebSocket: 'rpc'`). Calling this
|
|
2680
|
+
* method with the HTTP or WebSocket transports throws an error because those
|
|
2681
|
+
* transports do not support binary streaming.
|
|
2682
|
+
*/
|
|
2683
|
+
writeFileStream(_path, _content, _sessionId) {
|
|
2684
|
+
throw new Error("writeFileStream requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.");
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2655
2687
|
* Connect WebSocket transport (no-op in HTTP mode)
|
|
2656
2688
|
* Called automatically on first request, but can be called explicitly
|
|
2657
2689
|
* to establish connection upfront.
|
|
@@ -2681,6 +2713,452 @@ const BACKUP_ALLOWED_PREFIXES = [
|
|
|
2681
2713
|
"/app"
|
|
2682
2714
|
];
|
|
2683
2715
|
|
|
2716
|
+
//#endregion
|
|
2717
|
+
//#region src/container-connection.ts
|
|
2718
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
2719
|
+
/**
|
|
2720
|
+
* Manages a capnweb WebSocket RPC session to the container.
|
|
2721
|
+
*
|
|
2722
|
+
* The RPC stub is created eagerly in the constructor using a deferred
|
|
2723
|
+
* transport. Calls made before `connect()` completes are queued in the
|
|
2724
|
+
* transport and flushed once the WebSocket is established.
|
|
2725
|
+
*/
|
|
2726
|
+
var ContainerConnection = class {
|
|
2727
|
+
stub;
|
|
2728
|
+
session;
|
|
2729
|
+
transport;
|
|
2730
|
+
ws = null;
|
|
2731
|
+
connected = false;
|
|
2732
|
+
connectPromise = null;
|
|
2733
|
+
containerStub;
|
|
2734
|
+
port;
|
|
2735
|
+
logger;
|
|
2736
|
+
constructor(options) {
|
|
2737
|
+
this.containerStub = options.stub;
|
|
2738
|
+
this.port = options.port ?? 3e3;
|
|
2739
|
+
this.logger = options.logger ?? createNoOpLogger();
|
|
2740
|
+
this.transport = new DeferredTransport();
|
|
2741
|
+
this.session = new RpcSession(this.transport);
|
|
2742
|
+
this.stub = this.session.getRemoteMain();
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Get the typed RPC stub.
|
|
2746
|
+
*
|
|
2747
|
+
* The stub is available immediately — calls made before connect()
|
|
2748
|
+
* completes are queued in the deferred transport and flushed once
|
|
2749
|
+
* the WebSocket is established.
|
|
2750
|
+
*/
|
|
2751
|
+
rpc() {
|
|
2752
|
+
if (!this.connected && !this.connectPromise) this.connect().catch(() => {});
|
|
2753
|
+
return this.stub;
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Return capnweb session statistics. The `imports` and `exports` counts
|
|
2757
|
+
* reflect all in-flight RPC calls, streams, and peer-held references.
|
|
2758
|
+
* An idle session has imports <= 1 && exports <= 1 (the bootstrap stubs).
|
|
2759
|
+
*/
|
|
2760
|
+
getStats() {
|
|
2761
|
+
return this.session.getStats();
|
|
2762
|
+
}
|
|
2763
|
+
isConnected() {
|
|
2764
|
+
return this.connected;
|
|
2765
|
+
}
|
|
2766
|
+
async connect() {
|
|
2767
|
+
if (this.connected) return;
|
|
2768
|
+
if (this.connectPromise) return this.connectPromise;
|
|
2769
|
+
this.connectPromise = this.doConnect();
|
|
2770
|
+
try {
|
|
2771
|
+
await this.connectPromise;
|
|
2772
|
+
} finally {
|
|
2773
|
+
this.connectPromise = null;
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
disconnect() {
|
|
2777
|
+
try {
|
|
2778
|
+
this.stub[Symbol.dispose]?.();
|
|
2779
|
+
} catch {}
|
|
2780
|
+
if (this.ws) {
|
|
2781
|
+
try {
|
|
2782
|
+
this.ws.close();
|
|
2783
|
+
} catch {}
|
|
2784
|
+
this.ws = null;
|
|
2785
|
+
}
|
|
2786
|
+
this.connected = false;
|
|
2787
|
+
this.connectPromise = null;
|
|
2788
|
+
}
|
|
2789
|
+
async doConnect() {
|
|
2790
|
+
const controller = new AbortController();
|
|
2791
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
|
|
2792
|
+
try {
|
|
2793
|
+
const url = `http://localhost:${this.port}/rpc`;
|
|
2794
|
+
const request = new Request(url, {
|
|
2795
|
+
headers: {
|
|
2796
|
+
Upgrade: "websocket",
|
|
2797
|
+
Connection: "Upgrade"
|
|
2798
|
+
},
|
|
2799
|
+
signal: controller.signal
|
|
2800
|
+
});
|
|
2801
|
+
const response = await this.containerStub.fetch(request);
|
|
2802
|
+
clearTimeout(timeout);
|
|
2803
|
+
if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
|
|
2804
|
+
const ws = response.webSocket;
|
|
2805
|
+
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
2806
|
+
ws.accept();
|
|
2807
|
+
ws.addEventListener("close", () => {
|
|
2808
|
+
this.connected = false;
|
|
2809
|
+
this.ws = null;
|
|
2810
|
+
this.logger.debug("ContainerConnection WebSocket closed");
|
|
2811
|
+
});
|
|
2812
|
+
ws.addEventListener("error", () => {
|
|
2813
|
+
this.connected = false;
|
|
2814
|
+
this.ws = null;
|
|
2815
|
+
});
|
|
2816
|
+
this.ws = ws;
|
|
2817
|
+
this.transport.activate(ws);
|
|
2818
|
+
this.connected = true;
|
|
2819
|
+
this.logger.debug("ContainerConnection established", { port: this.port });
|
|
2820
|
+
} catch (error) {
|
|
2821
|
+
clearTimeout(timeout);
|
|
2822
|
+
this.connected = false;
|
|
2823
|
+
this.transport.abort(error);
|
|
2824
|
+
this.logger.error("ContainerConnection failed", error instanceof Error ? error : new Error(String(error)));
|
|
2825
|
+
throw error;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
};
|
|
2829
|
+
/**
|
|
2830
|
+
* RPC transport that queues sends and blocks receives until a WebSocket
|
|
2831
|
+
* is provided via `activate()`. Allows the RPC stub to be created before
|
|
2832
|
+
* the connection is established — queued calls flush automatically.
|
|
2833
|
+
*/
|
|
2834
|
+
var DeferredTransport = class {
|
|
2835
|
+
#ws = null;
|
|
2836
|
+
#sendQueue = [];
|
|
2837
|
+
#receiveQueue = [];
|
|
2838
|
+
#receiveResolver;
|
|
2839
|
+
#receiveRejecter;
|
|
2840
|
+
#error;
|
|
2841
|
+
activate(ws) {
|
|
2842
|
+
this.#ws = ws;
|
|
2843
|
+
ws.addEventListener("message", (event) => {
|
|
2844
|
+
if (this.#error) return;
|
|
2845
|
+
if (typeof event.data === "string") if (this.#receiveResolver) {
|
|
2846
|
+
this.#receiveResolver(event.data);
|
|
2847
|
+
this.#receiveResolver = void 0;
|
|
2848
|
+
this.#receiveRejecter = void 0;
|
|
2849
|
+
} else this.#receiveQueue.push(event.data);
|
|
2850
|
+
});
|
|
2851
|
+
ws.addEventListener("close", (event) => {
|
|
2852
|
+
this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
|
|
2853
|
+
});
|
|
2854
|
+
ws.addEventListener("error", () => {
|
|
2855
|
+
this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed"));
|
|
2856
|
+
});
|
|
2857
|
+
for (const msg of this.#sendQueue) ws.send(msg);
|
|
2858
|
+
this.#sendQueue = [];
|
|
2859
|
+
}
|
|
2860
|
+
async send(message) {
|
|
2861
|
+
if (this.#ws) this.#ws.send(message);
|
|
2862
|
+
else this.#sendQueue.push(message);
|
|
2863
|
+
}
|
|
2864
|
+
async receive() {
|
|
2865
|
+
if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
|
|
2866
|
+
if (this.#error) throw this.#error;
|
|
2867
|
+
return new Promise((resolve, reject) => {
|
|
2868
|
+
this.#receiveResolver = resolve;
|
|
2869
|
+
this.#receiveRejecter = reject;
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
abort(reason) {
|
|
2873
|
+
this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
|
|
2874
|
+
if (this.#ws) {
|
|
2875
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
2876
|
+
this.#ws.close(3e3, message);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
#fail(err) {
|
|
2880
|
+
if (this.#error) return;
|
|
2881
|
+
this.#error = err;
|
|
2882
|
+
this.#receiveRejecter?.(err);
|
|
2883
|
+
this.#receiveResolver = void 0;
|
|
2884
|
+
this.#receiveRejecter = void 0;
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
|
|
2888
|
+
//#endregion
|
|
2889
|
+
//#region src/clients/rpc-sandbox-client.ts
|
|
2890
|
+
/** Close the idle capnweb WebSocket promptly so the DO can sleep. */
|
|
2891
|
+
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
2892
|
+
/**
|
|
2893
|
+
* How often the busy/idle poller samples `getStats()`.
|
|
2894
|
+
*
|
|
2895
|
+
* Sets two worst-case bounds:
|
|
2896
|
+
*
|
|
2897
|
+
* 1. **Idle-detection lag.** Time between the session going idle on
|
|
2898
|
+
* the wire and the DO observing it (and arming the disconnect).
|
|
2899
|
+
* Bounded by `pollInterval`.
|
|
2900
|
+
* 2. **Activity-renewal lag while busy.** While a stream is active we
|
|
2901
|
+
* renew the DO's activity timeout once per tick. The alarm could
|
|
2902
|
+
* fire as late as `sleepAfter` after the last renew, so the
|
|
2903
|
+
* effective margin against a mid-stream sleep is
|
|
2904
|
+
* `sleepAfter - pollInterval`.
|
|
2905
|
+
*
|
|
2906
|
+
* **Invariant: `pollInterval` must be comfortably less than the
|
|
2907
|
+
* smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
|
|
2908
|
+
* The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
|
|
2909
|
+
* 3× margin and at least two renewals during a 3s window. If a smaller
|
|
2910
|
+
* `sleepAfter` is ever supported, drop this proportionally.
|
|
2911
|
+
*/
|
|
2912
|
+
const BUSY_POLL_INTERVAL_MS = 1e3;
|
|
2913
|
+
/**
|
|
2914
|
+
* Baseline getStats() values for an idle session. The bootstrap stub on each
|
|
2915
|
+
* side accounts for 1 import and 1 export.
|
|
2916
|
+
*/
|
|
2917
|
+
const IDLE_IMPORT_THRESHOLD = 1;
|
|
2918
|
+
const IDLE_EXPORT_THRESHOLD = 1;
|
|
2919
|
+
/**
|
|
2920
|
+
* Translate a capnweb-propagated error into a typed SandboxError.
|
|
2921
|
+
*
|
|
2922
|
+
* capnweb only preserves `error.name` and `error.message` across the wire.
|
|
2923
|
+
* The container encodes the full error as a JSON object in the message
|
|
2924
|
+
* string: `{"code":"...","message":"...","context":{...}}`.
|
|
2925
|
+
*/
|
|
2926
|
+
function translateRPCError(error) {
|
|
2927
|
+
if (error instanceof Error) try {
|
|
2928
|
+
const payload = JSON.parse(error.message);
|
|
2929
|
+
if (typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
|
|
2930
|
+
code: payload.code,
|
|
2931
|
+
message: payload.message,
|
|
2932
|
+
context: payload.context ?? {},
|
|
2933
|
+
httpStatus: getHttpStatus(payload.code),
|
|
2934
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2935
|
+
});
|
|
2936
|
+
} catch (e) {
|
|
2937
|
+
if (e instanceof Error && e !== error) throw e;
|
|
2938
|
+
}
|
|
2939
|
+
throw error;
|
|
2940
|
+
}
|
|
2941
|
+
/**
|
|
2942
|
+
* Wrap a capnweb RPC stub so that every method call translates errors
|
|
2943
|
+
* from the JSON wire format into typed SandboxError instances and signals
|
|
2944
|
+
* activity at call start.
|
|
2945
|
+
*
|
|
2946
|
+
* `onCallStarted` fires synchronously when an RPC method is invoked. The
|
|
2947
|
+
* RPCSandboxClient uses this to renew the DO's activity timeout
|
|
2948
|
+
* immediately, so even a call that completes entirely between two
|
|
2949
|
+
* busy-poll ticks still pushes the sleepAfter deadline forward.
|
|
2950
|
+
*
|
|
2951
|
+
* Note: there is no `onCallSettled` hook. A method whose returned promise
|
|
2952
|
+
* resolves with a `ReadableStream` is *not* finished when the promise
|
|
2953
|
+
* settles — capnweb keeps the export alive until the stream ends. The
|
|
2954
|
+
* busy/idle poll on `getStats()` is the source of truth for that.
|
|
2955
|
+
*/
|
|
2956
|
+
function wrapStub(stub, onCallStarted) {
|
|
2957
|
+
return new Proxy(stub, { get(target, prop, receiver) {
|
|
2958
|
+
const value = Reflect.get(target, prop, receiver);
|
|
2959
|
+
if (typeof value !== "function") return value;
|
|
2960
|
+
return (...args) => {
|
|
2961
|
+
onCallStarted();
|
|
2962
|
+
try {
|
|
2963
|
+
const result = Reflect.apply(value, target, args);
|
|
2964
|
+
if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
|
|
2965
|
+
return result;
|
|
2966
|
+
} catch (err) {
|
|
2967
|
+
translateRPCError(err);
|
|
2968
|
+
}
|
|
2969
|
+
};
|
|
2970
|
+
} });
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* SandboxClient backed by direct capnweb RPC.
|
|
2974
|
+
*
|
|
2975
|
+
* Drop-in replacement for SandboxClient when the capnweb transport is active.
|
|
2976
|
+
* All operations call the container's SandboxRPCAPI directly over capnweb,
|
|
2977
|
+
* bypassing the HTTP handler/router layer entirely.
|
|
2978
|
+
*
|
|
2979
|
+
* Manages its own WebSocket lifecycle: a fresh `ContainerConnection` is
|
|
2980
|
+
* created on demand and torn down after `idleDisconnectMs` of inactivity.
|
|
2981
|
+
* Busy/idle detection relies on `RpcSession.getStats()` which tracks all
|
|
2982
|
+
* in-flight RPC calls and stream exports — including long-lived streaming
|
|
2983
|
+
* RPCs that would be invisible to a simple per-call request counter (see
|
|
2984
|
+
* the file-level comment for the full rationale).
|
|
2985
|
+
*/
|
|
2986
|
+
var RPCSandboxClient = class {
|
|
2987
|
+
connOptions;
|
|
2988
|
+
idleDisconnectMs;
|
|
2989
|
+
busyPollIntervalMs;
|
|
2990
|
+
logger;
|
|
2991
|
+
onActivity;
|
|
2992
|
+
onSessionBusy;
|
|
2993
|
+
onSessionIdle;
|
|
2994
|
+
conn = null;
|
|
2995
|
+
idleTimer = null;
|
|
2996
|
+
busyPollTimer = null;
|
|
2997
|
+
/** Tracks whether we currently believe the session is busy. */
|
|
2998
|
+
busy = false;
|
|
2999
|
+
/**
|
|
3000
|
+
* Set the first time the poller observes `conn.isConnected() === true`,
|
|
3001
|
+
* cleared in `destroyConnection()`. Lets us distinguish "the WebSocket
|
|
3002
|
+
* upgrade is still in progress" (don't tear down) from "we were
|
|
3003
|
+
* connected and the peer went away" (do tear down).
|
|
3004
|
+
*/
|
|
3005
|
+
wasEverConnected = false;
|
|
3006
|
+
constructor(options) {
|
|
3007
|
+
this.connOptions = {
|
|
3008
|
+
stub: options.stub,
|
|
3009
|
+
port: options.port,
|
|
3010
|
+
logger: options.logger
|
|
3011
|
+
};
|
|
3012
|
+
this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
|
|
3013
|
+
this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
|
|
3014
|
+
this.logger = options.logger ?? createNoOpLogger();
|
|
3015
|
+
this.onActivity = options.onActivity;
|
|
3016
|
+
this.onSessionBusy = options.onSessionBusy;
|
|
3017
|
+
this.onSessionIdle = options.onSessionIdle;
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* Return the current connection, creating a new one if none exists or the
|
|
3021
|
+
* previous one was torn down by an idle disconnect. Starts the busy-poll
|
|
3022
|
+
* timer the first time a connection is materialized.
|
|
3023
|
+
*/
|
|
3024
|
+
getConnection() {
|
|
3025
|
+
if (!this.conn) {
|
|
3026
|
+
this.conn = new ContainerConnection(this.connOptions);
|
|
3027
|
+
this.startBusyPoll();
|
|
3028
|
+
}
|
|
3029
|
+
return this.conn;
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* Called synchronously at the start of each RPC method invocation.
|
|
3033
|
+
* Renews the DO activity timeout so the sleepAfter alarm is pushed
|
|
3034
|
+
* forward before the container processes the call.
|
|
3035
|
+
*/
|
|
3036
|
+
renewActivity = () => {
|
|
3037
|
+
this.onActivity?.();
|
|
3038
|
+
};
|
|
3039
|
+
/**
|
|
3040
|
+
* Sample `getStats()` and update busy/idle state. While busy, renews the
|
|
3041
|
+
* activity timeout each tick so an in-flight stream keeps pushing the
|
|
3042
|
+
* sleepAfter deadline forward. On the busy → idle edge, fires
|
|
3043
|
+
* `onSessionIdle` and schedules the WebSocket disconnect.
|
|
3044
|
+
*
|
|
3045
|
+
* If the WebSocket has dropped underneath us (container crash, network
|
|
3046
|
+
* blip) we tear the connection down here. `destroyConnection()` fires
|
|
3047
|
+
* `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
|
|
3048
|
+
* stay pinned forever waiting for a peer that's never going to reply.
|
|
3049
|
+
*/
|
|
3050
|
+
pollBusyState = () => {
|
|
3051
|
+
const conn = this.conn;
|
|
3052
|
+
if (!conn) return;
|
|
3053
|
+
if (!conn.isConnected()) {
|
|
3054
|
+
if (this.wasEverConnected) this.destroyConnection();
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
this.wasEverConnected = true;
|
|
3058
|
+
const { imports, exports } = conn.getStats();
|
|
3059
|
+
if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
|
|
3060
|
+
if (!this.busy) {
|
|
3061
|
+
this.busy = true;
|
|
3062
|
+
this.onSessionBusy?.();
|
|
3063
|
+
}
|
|
3064
|
+
this.onActivity?.();
|
|
3065
|
+
this.clearIdleTimer();
|
|
3066
|
+
} else if (this.busy) {
|
|
3067
|
+
this.busy = false;
|
|
3068
|
+
this.onSessionIdle?.();
|
|
3069
|
+
this.scheduleIdleDisconnect();
|
|
3070
|
+
} else if (!this.idleTimer) this.scheduleIdleDisconnect();
|
|
3071
|
+
};
|
|
3072
|
+
startBusyPoll() {
|
|
3073
|
+
if (this.busyPollTimer) return;
|
|
3074
|
+
this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
|
|
3075
|
+
}
|
|
3076
|
+
stopBusyPoll() {
|
|
3077
|
+
if (this.busyPollTimer) {
|
|
3078
|
+
clearInterval(this.busyPollTimer);
|
|
3079
|
+
this.busyPollTimer = null;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
scheduleIdleDisconnect() {
|
|
3083
|
+
this.clearIdleTimer();
|
|
3084
|
+
this.idleTimer = setTimeout(() => {
|
|
3085
|
+
this.idleTimer = null;
|
|
3086
|
+
const conn = this.conn;
|
|
3087
|
+
if (!conn || !conn.isConnected()) return;
|
|
3088
|
+
const { imports, exports } = conn.getStats();
|
|
3089
|
+
if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
|
|
3090
|
+
this.logger.debug("Disconnecting idle capnweb connection");
|
|
3091
|
+
this.destroyConnection();
|
|
3092
|
+
}
|
|
3093
|
+
}, this.idleDisconnectMs);
|
|
3094
|
+
}
|
|
3095
|
+
clearIdleTimer() {
|
|
3096
|
+
if (this.idleTimer) {
|
|
3097
|
+
clearTimeout(this.idleTimer);
|
|
3098
|
+
this.idleTimer = null;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
destroyConnection() {
|
|
3102
|
+
this.stopBusyPoll();
|
|
3103
|
+
this.clearIdleTimer();
|
|
3104
|
+
if (this.busy) {
|
|
3105
|
+
this.busy = false;
|
|
3106
|
+
this.onSessionIdle?.();
|
|
3107
|
+
}
|
|
3108
|
+
if (this.conn) {
|
|
3109
|
+
this.conn.disconnect();
|
|
3110
|
+
this.conn = null;
|
|
3111
|
+
}
|
|
3112
|
+
this.wasEverConnected = false;
|
|
3113
|
+
}
|
|
3114
|
+
get commands() {
|
|
3115
|
+
return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
|
|
3116
|
+
}
|
|
3117
|
+
get files() {
|
|
3118
|
+
return wrapStub(this.getConnection().rpc().files, this.renewActivity);
|
|
3119
|
+
}
|
|
3120
|
+
get processes() {
|
|
3121
|
+
return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
|
|
3122
|
+
}
|
|
3123
|
+
get ports() {
|
|
3124
|
+
return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
|
|
3125
|
+
}
|
|
3126
|
+
get git() {
|
|
3127
|
+
return wrapStub(this.getConnection().rpc().git, this.renewActivity);
|
|
3128
|
+
}
|
|
3129
|
+
get utils() {
|
|
3130
|
+
return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
|
|
3131
|
+
}
|
|
3132
|
+
get backup() {
|
|
3133
|
+
return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
|
|
3134
|
+
}
|
|
3135
|
+
get desktop() {
|
|
3136
|
+
return wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
|
|
3137
|
+
}
|
|
3138
|
+
get watch() {
|
|
3139
|
+
return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
|
|
3140
|
+
}
|
|
3141
|
+
get interpreter() {
|
|
3142
|
+
return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
|
|
3143
|
+
}
|
|
3144
|
+
setRetryTimeoutMs(_ms) {}
|
|
3145
|
+
getTransportMode() {
|
|
3146
|
+
return "rpc";
|
|
3147
|
+
}
|
|
3148
|
+
isWebSocketConnected() {
|
|
3149
|
+
return this.conn?.isConnected() ?? false;
|
|
3150
|
+
}
|
|
3151
|
+
async connect() {
|
|
3152
|
+
await this.getConnection().connect();
|
|
3153
|
+
}
|
|
3154
|
+
disconnect() {
|
|
3155
|
+
this.destroyConnection();
|
|
3156
|
+
}
|
|
3157
|
+
async writeFileStream(path$1, stream, sessionId) {
|
|
3158
|
+
return this.files.writeFileStream(path$1, stream, sessionId);
|
|
3159
|
+
}
|
|
3160
|
+
};
|
|
3161
|
+
|
|
2684
3162
|
//#endregion
|
|
2685
3163
|
//#region src/file-stream.ts
|
|
2686
3164
|
/**
|
|
@@ -2815,11 +3293,11 @@ async function collectFile(stream) {
|
|
|
2815
3293
|
* - Host header injection
|
|
2816
3294
|
* - Open redirect vulnerabilities
|
|
2817
3295
|
*/
|
|
2818
|
-
var
|
|
3296
|
+
var SandboxSecurityError = class extends Error {
|
|
2819
3297
|
constructor(message, code) {
|
|
2820
3298
|
super(message);
|
|
2821
3299
|
this.code = code;
|
|
2822
|
-
this.name = "
|
|
3300
|
+
this.name = "SandboxSecurityError";
|
|
2823
3301
|
}
|
|
2824
3302
|
};
|
|
2825
3303
|
/**
|
|
@@ -2840,8 +3318,8 @@ function validatePort(port) {
|
|
|
2840
3318
|
* Only enforces critical requirements - allows maximum developer flexibility
|
|
2841
3319
|
*/
|
|
2842
3320
|
function sanitizeSandboxId(id) {
|
|
2843
|
-
if (!id || id.length > 63) throw new
|
|
2844
|
-
if (id.startsWith("-") || id.endsWith("-")) throw new
|
|
3321
|
+
if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
|
|
3322
|
+
if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
|
|
2845
3323
|
const reservedNames = [
|
|
2846
3324
|
"www",
|
|
2847
3325
|
"api",
|
|
@@ -2852,7 +3330,7 @@ function sanitizeSandboxId(id) {
|
|
|
2852
3330
|
"workers"
|
|
2853
3331
|
];
|
|
2854
3332
|
const lowerCaseId = id.toLowerCase();
|
|
2855
|
-
if (reservedNames.includes(lowerCaseId)) throw new
|
|
3333
|
+
if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
|
|
2856
3334
|
return id;
|
|
2857
3335
|
}
|
|
2858
3336
|
/**
|
|
@@ -2871,23 +3349,23 @@ function validateLanguage(language) {
|
|
|
2871
3349
|
"ts"
|
|
2872
3350
|
];
|
|
2873
3351
|
const normalized = language.toLowerCase();
|
|
2874
|
-
if (!supportedLanguages.includes(normalized)) throw new
|
|
3352
|
+
if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
|
|
2875
3353
|
}
|
|
2876
3354
|
|
|
2877
3355
|
//#endregion
|
|
2878
3356
|
//#region src/interpreter.ts
|
|
2879
3357
|
var CodeInterpreter = class {
|
|
2880
|
-
|
|
3358
|
+
getInterpreterClient;
|
|
2881
3359
|
contexts = /* @__PURE__ */ new Map();
|
|
2882
|
-
constructor(
|
|
2883
|
-
this.
|
|
3360
|
+
constructor(interpreterClient) {
|
|
3361
|
+
this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
|
|
2884
3362
|
}
|
|
2885
3363
|
/**
|
|
2886
3364
|
* Create a new code execution context
|
|
2887
3365
|
*/
|
|
2888
3366
|
async createCodeContext(options = {}) {
|
|
2889
3367
|
validateLanguage(options.language);
|
|
2890
|
-
const context = await this.
|
|
3368
|
+
const context = await this.getInterpreterClient().createCodeContext(options);
|
|
2891
3369
|
this.contexts.set(context.id, context);
|
|
2892
3370
|
return context;
|
|
2893
3371
|
}
|
|
@@ -2901,7 +3379,7 @@ var CodeInterpreter = class {
|
|
|
2901
3379
|
context = await this.getOrCreateDefaultContext(language);
|
|
2902
3380
|
}
|
|
2903
3381
|
const execution = new Execution(code, context);
|
|
2904
|
-
await this.
|
|
3382
|
+
await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
|
|
2905
3383
|
onStdout: (output) => {
|
|
2906
3384
|
execution.logs.stdout.push(output.text);
|
|
2907
3385
|
if (options.onStdout) return options.onStdout(output);
|
|
@@ -2930,13 +3408,13 @@ var CodeInterpreter = class {
|
|
|
2930
3408
|
const language = options.language || "python";
|
|
2931
3409
|
context = await this.getOrCreateDefaultContext(language);
|
|
2932
3410
|
}
|
|
2933
|
-
return this.
|
|
3411
|
+
return this.getInterpreterClient().streamCode(context.id, code, options.language);
|
|
2934
3412
|
}
|
|
2935
3413
|
/**
|
|
2936
3414
|
* List all code contexts
|
|
2937
3415
|
*/
|
|
2938
3416
|
async listCodeContexts() {
|
|
2939
|
-
const contexts = await this.
|
|
3417
|
+
const contexts = await this.getInterpreterClient().listCodeContexts();
|
|
2940
3418
|
for (const context of contexts) this.contexts.set(context.id, context);
|
|
2941
3419
|
return contexts;
|
|
2942
3420
|
}
|
|
@@ -2944,7 +3422,7 @@ var CodeInterpreter = class {
|
|
|
2944
3422
|
* Delete a code context
|
|
2945
3423
|
*/
|
|
2946
3424
|
async deleteCodeContext(contextId) {
|
|
2947
|
-
await this.
|
|
3425
|
+
await this.getInterpreterClient().deleteCodeContext(contextId);
|
|
2948
3426
|
this.contexts.delete(contextId);
|
|
2949
3427
|
}
|
|
2950
3428
|
async getOrCreateDefaultContext(language) {
|
|
@@ -3633,7 +4111,7 @@ function isLocalhostPattern(hostname) {
|
|
|
3633
4111
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
3634
4112
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
3635
4113
|
*/
|
|
3636
|
-
const SDK_VERSION = "0.
|
|
4114
|
+
const SDK_VERSION = "0.9.1";
|
|
3637
4115
|
|
|
3638
4116
|
//#endregion
|
|
3639
4117
|
//#region src/sandbox.ts
|
|
@@ -3741,7 +4219,7 @@ function enhanceSession(stub, rpcSession) {
|
|
|
3741
4219
|
}
|
|
3742
4220
|
function connect(stub) {
|
|
3743
4221
|
return async (request, port) => {
|
|
3744
|
-
if (!validatePort(port)) throw new
|
|
4222
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
3745
4223
|
const portSwitchedRequest = switchPort(request, port);
|
|
3746
4224
|
return await stub.fetch(portSwitchedRequest);
|
|
3747
4225
|
};
|
|
@@ -3894,6 +4372,30 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3894
4372
|
}
|
|
3895
4373
|
});
|
|
3896
4374
|
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Create the appropriate client for a given transport protocol.
|
|
4377
|
+
*/
|
|
4378
|
+
createClientForTransport(transport) {
|
|
4379
|
+
if (transport === "rpc") {
|
|
4380
|
+
const self = this;
|
|
4381
|
+
return new RPCSandboxClient({
|
|
4382
|
+
stub: this,
|
|
4383
|
+
port: 3e3,
|
|
4384
|
+
logger: this.logger,
|
|
4385
|
+
onActivity: () => {
|
|
4386
|
+
this.renewActivityTimeout();
|
|
4387
|
+
},
|
|
4388
|
+
onSessionBusy: () => {
|
|
4389
|
+
self.inflightRequests++;
|
|
4390
|
+
},
|
|
4391
|
+
onSessionIdle: () => {
|
|
4392
|
+
self.inflightRequests = Math.max(0, self.inflightRequests - 1);
|
|
4393
|
+
if (self.inflightRequests === 0) this.renewActivityTimeout();
|
|
4394
|
+
}
|
|
4395
|
+
});
|
|
4396
|
+
}
|
|
4397
|
+
return this.createSandboxClient();
|
|
4398
|
+
}
|
|
3897
4399
|
constructor(ctx, env) {
|
|
3898
4400
|
super(ctx, env);
|
|
3899
4401
|
const envObj = env;
|
|
@@ -3906,8 +4408,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3906
4408
|
sandboxId: this.ctx.id.toString()
|
|
3907
4409
|
});
|
|
3908
4410
|
const transportEnv = envObj?.SANDBOX_TRANSPORT;
|
|
3909
|
-
if (transportEnv === "websocket") this.transport =
|
|
3910
|
-
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http" or "
|
|
4411
|
+
if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
|
|
4412
|
+
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
|
|
4413
|
+
this.logger.info(`Using ${this.transport} transport`);
|
|
3911
4414
|
const backupBucket = envObj?.BACKUP_BUCKET;
|
|
3912
4415
|
if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
|
|
3913
4416
|
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
@@ -3918,8 +4421,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3918
4421
|
accessKeyId: this.r2AccessKeyId,
|
|
3919
4422
|
secretAccessKey: this.r2SecretAccessKey
|
|
3920
4423
|
});
|
|
3921
|
-
this.client = this.
|
|
3922
|
-
this.codeInterpreter = new CodeInterpreter(this);
|
|
4424
|
+
this.client = this.createClientForTransport(this.transport);
|
|
4425
|
+
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
3923
4426
|
this.ctx.blockConcurrencyWhile(async () => {
|
|
3924
4427
|
this.sandboxName = await this.ctx.storage.get("sandboxName") ?? null;
|
|
3925
4428
|
this.normalizeId = await this.ctx.storage.get("normalizeId") ?? false;
|
|
@@ -3943,8 +4446,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3943
4446
|
if (storedTransport && storedTransport !== this.transport) {
|
|
3944
4447
|
this.transport = storedTransport;
|
|
3945
4448
|
const previousClient = this.client;
|
|
3946
|
-
this.client = this.
|
|
3947
|
-
this.codeInterpreter = new CodeInterpreter(this);
|
|
4449
|
+
this.client = this.createClientForTransport(storedTransport);
|
|
4450
|
+
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
3948
4451
|
previousClient.disconnect();
|
|
3949
4452
|
}
|
|
3950
4453
|
if (storedTransport) this.hasStoredTransport = true;
|
|
@@ -4025,8 +4528,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4025
4528
|
* Storage is written before the in-memory state and client are updated.
|
|
4026
4529
|
*/
|
|
4027
4530
|
async setTransport(transport) {
|
|
4028
|
-
if (transport !== "http" && transport !== "websocket") {
|
|
4029
|
-
this.logger.warn(`Invalid transport value: "${transport}". Must be "http" or "
|
|
4531
|
+
if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
|
|
4532
|
+
this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
|
|
4030
4533
|
return;
|
|
4031
4534
|
}
|
|
4032
4535
|
if (this.hasStoredTransport && this.transport === transport) return;
|
|
@@ -4034,9 +4537,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4034
4537
|
const previousClient = this.client;
|
|
4035
4538
|
this.transport = transport;
|
|
4036
4539
|
this.hasStoredTransport = true;
|
|
4037
|
-
this.client = this.
|
|
4038
|
-
this.codeInterpreter = new CodeInterpreter(this);
|
|
4540
|
+
this.client = this.createClientForTransport(transport);
|
|
4541
|
+
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
4039
4542
|
previousClient.disconnect();
|
|
4543
|
+
this.renewActivityTimeout();
|
|
4040
4544
|
this.logger.debug("Transport updated", { transport });
|
|
4041
4545
|
}
|
|
4042
4546
|
/**
|
|
@@ -4325,9 +4829,46 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4325
4829
|
if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
|
|
4326
4830
|
}
|
|
4327
4831
|
/**
|
|
4328
|
-
*
|
|
4832
|
+
* In-flight `destroy()` promise. While set, concurrent callers coalesce
|
|
4833
|
+
* onto the same teardown instead of triggering a second one. Cleared when
|
|
4834
|
+
* the underlying work settles, so a later call that genuinely needs to
|
|
4835
|
+
* recreate a destroyed sandbox still runs.
|
|
4836
|
+
*
|
|
4837
|
+
* If the underlying teardown hangs (e.g. `super.destroy()` never resolves
|
|
4838
|
+
* because the Containers control plane is unresponsive), every coalesced
|
|
4839
|
+
* caller hangs on the same promise until the Durable Object is evicted.
|
|
4840
|
+
* This is deliberate: a second concurrent teardown would not make a stuck
|
|
4841
|
+
* control plane unstuck, and spawning one would defeat the point of
|
|
4842
|
+
* coalescing. Callers that need bounded waits must apply their own
|
|
4843
|
+
* timeout around `destroy()`.
|
|
4844
|
+
*/
|
|
4845
|
+
inflightDestroy = null;
|
|
4846
|
+
/**
|
|
4847
|
+
* Cleanup and destroy the sandbox container.
|
|
4848
|
+
*
|
|
4849
|
+
* Concurrent calls coalesce: if a previous `destroy()` is still in flight,
|
|
4850
|
+
* subsequent calls await the same underlying work instead of starting a
|
|
4851
|
+
* second teardown. A canonical `sandbox.destroy.coalesced` event is logged
|
|
4852
|
+
* per coalesced call so repeated destroy traffic is observable.
|
|
4329
4853
|
*/
|
|
4330
4854
|
async destroy() {
|
|
4855
|
+
if (this.inflightDestroy) {
|
|
4856
|
+
logCanonicalEvent(this.logger, {
|
|
4857
|
+
event: "sandbox.destroy.coalesced",
|
|
4858
|
+
outcome: "success",
|
|
4859
|
+
durationMs: 0
|
|
4860
|
+
});
|
|
4861
|
+
return this.inflightDestroy;
|
|
4862
|
+
}
|
|
4863
|
+
const work = this.doDestroy();
|
|
4864
|
+
this.inflightDestroy = work;
|
|
4865
|
+
try {
|
|
4866
|
+
await work;
|
|
4867
|
+
} finally {
|
|
4868
|
+
if (this.inflightDestroy === work) this.inflightDestroy = null;
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
async doDestroy() {
|
|
4331
4872
|
const startTime = Date.now();
|
|
4332
4873
|
let mountsProcessed = 0;
|
|
4333
4874
|
let mountFailures = 0;
|
|
@@ -4337,7 +4878,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4337
4878
|
if (this.ctx.container?.running) try {
|
|
4338
4879
|
await this.client.desktop.stop();
|
|
4339
4880
|
} catch {}
|
|
4340
|
-
this.client.disconnect();
|
|
4341
4881
|
for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
|
|
4342
4882
|
mountsProcessed++;
|
|
4343
4883
|
if (mountInfo.mountType === "local-sync") try {
|
|
@@ -4362,6 +4902,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4362
4902
|
}
|
|
4363
4903
|
}
|
|
4364
4904
|
await this.ctx.storage.delete("portTokens");
|
|
4905
|
+
this.client.disconnect();
|
|
4365
4906
|
outcome = "success";
|
|
4366
4907
|
await super.destroy();
|
|
4367
4908
|
} catch (error) {
|
|
@@ -4489,6 +5030,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4489
5030
|
this.containerGeneration++;
|
|
4490
5031
|
this.defaultSession = null;
|
|
4491
5032
|
this.defaultSessionInit = null;
|
|
5033
|
+
this.client.disconnect();
|
|
4492
5034
|
for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
|
|
4493
5035
|
this.activeMounts.clear();
|
|
4494
5036
|
await this.ctx.storage.delete("defaultSession");
|
|
@@ -4767,22 +5309,43 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4767
5309
|
}
|
|
4768
5310
|
}
|
|
4769
5311
|
async initializeDefaultSession(sessionId, generation) {
|
|
5312
|
+
let placementId;
|
|
4770
5313
|
try {
|
|
4771
|
-
await this.client.utils.createSession({
|
|
5314
|
+
placementId = (await this.client.utils.createSession({
|
|
4772
5315
|
id: sessionId,
|
|
4773
5316
|
env: this.envVars || {},
|
|
4774
5317
|
cwd: "/workspace"
|
|
4775
|
-
});
|
|
5318
|
+
})).containerPlacementId;
|
|
4776
5319
|
} catch (error) {
|
|
4777
5320
|
if (!(error instanceof SessionAlreadyExistsError)) throw error;
|
|
5321
|
+
placementId = error.containerPlacementId;
|
|
4778
5322
|
this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
|
|
4779
5323
|
}
|
|
4780
5324
|
if (generation !== this.containerGeneration) throw new Error("Default session initialization was invalidated by a container stop");
|
|
4781
5325
|
await this.ctx.storage.put("defaultSession", sessionId);
|
|
5326
|
+
await this.capturePlacementId(placementId);
|
|
4782
5327
|
this.defaultSession = sessionId;
|
|
4783
5328
|
this.logger.debug("Default session initialized", { sessionId });
|
|
4784
5329
|
return sessionId;
|
|
4785
5330
|
}
|
|
5331
|
+
/**
|
|
5332
|
+
* Persist the container's placement ID in DO storage.
|
|
5333
|
+
*
|
|
5334
|
+
* Called from the session-create handshake so subsequent reads via
|
|
5335
|
+
* `getContainerPlacementId()` do not require a round-trip to the container. The value
|
|
5336
|
+
* is overwritten on every handshake so that container replacements (which
|
|
5337
|
+
* assign a new placement ID) are reflected on the next session-create.
|
|
5338
|
+
*
|
|
5339
|
+
* A value of `undefined` means the handshake response omitted the field
|
|
5340
|
+
* (older container, unexpected error shape) and the stored value is left
|
|
5341
|
+
* untouched. `null` means the env var is not set in the container and is
|
|
5342
|
+
* stored as-is so callers can distinguish "observed and absent" from "not
|
|
5343
|
+
* yet observed."
|
|
5344
|
+
*/
|
|
5345
|
+
async capturePlacementId(containerPlacementId) {
|
|
5346
|
+
if (containerPlacementId === void 0) return;
|
|
5347
|
+
await this.ctx.storage.put("containerPlacementId", containerPlacementId);
|
|
5348
|
+
}
|
|
4786
5349
|
async exec(command, options) {
|
|
4787
5350
|
const session = await this.ensureDefaultSession();
|
|
4788
5351
|
return this.execWithSession(command, session, options);
|
|
@@ -5287,6 +5850,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5287
5850
|
}
|
|
5288
5851
|
async writeFile(path$1, content, options = {}) {
|
|
5289
5852
|
const session = options.sessionId ?? await this.ensureDefaultSession();
|
|
5853
|
+
if (content instanceof ReadableStream) return this.client.writeFileStream(path$1, content, session);
|
|
5290
5854
|
return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
|
|
5291
5855
|
}
|
|
5292
5856
|
async deleteFile(path$1, sessionId) {
|
|
@@ -5436,7 +6000,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5436
6000
|
let outcome = "error";
|
|
5437
6001
|
let caughtError;
|
|
5438
6002
|
try {
|
|
5439
|
-
if (!validatePort(port)) throw new
|
|
6003
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
5440
6004
|
if (options.hostname.endsWith(".workers.dev")) throw new CustomDomainRequiredError({
|
|
5441
6005
|
code: ErrorCode.CUSTOM_DOMAIN_REQUIRED,
|
|
5442
6006
|
message: `Port exposure requires a custom domain. .workers.dev domains do not support wildcard subdomains required for port proxying.`,
|
|
@@ -5452,7 +6016,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5452
6016
|
} else token = this.generatePortToken();
|
|
5453
6017
|
const tokens = await this.readPortTokens();
|
|
5454
6018
|
const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
|
|
5455
|
-
if (existingPort) throw new
|
|
6019
|
+
if (existingPort) throw new SandboxSecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
|
|
5456
6020
|
const sessionId = await this.ensureDefaultSession();
|
|
5457
6021
|
await this.client.ports.exposePort(port, sessionId, options?.name);
|
|
5458
6022
|
tokens[port.toString()] = {
|
|
@@ -5487,7 +6051,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5487
6051
|
let outcome = "error";
|
|
5488
6052
|
let caughtError;
|
|
5489
6053
|
try {
|
|
5490
|
-
if (!validatePort(port)) throw new
|
|
6054
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
5491
6055
|
const tokens = await this.readPortTokens();
|
|
5492
6056
|
if (tokens[port.toString()]) {
|
|
5493
6057
|
delete tokens[port.toString()];
|
|
@@ -5553,9 +6117,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5553
6117
|
}
|
|
5554
6118
|
}
|
|
5555
6119
|
validateCustomToken(token) {
|
|
5556
|
-
if (token.length === 0) throw new
|
|
5557
|
-
if (token.length > 16) throw new
|
|
5558
|
-
if (!/^[a-z0-9_]+$/.test(token)) throw new
|
|
6120
|
+
if (token.length === 0) throw new SandboxSecurityError(`Custom token cannot be empty.`);
|
|
6121
|
+
if (token.length > 16) throw new SandboxSecurityError(`Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.`);
|
|
6122
|
+
if (!/^[a-z0-9_]+$/.test(token)) throw new SandboxSecurityError(`Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.`);
|
|
5559
6123
|
}
|
|
5560
6124
|
generatePortToken() {
|
|
5561
6125
|
const array = new Uint8Array(12);
|
|
@@ -5563,10 +6127,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5563
6127
|
return btoa(String.fromCharCode(...array)).replace(/\+/g, "_").replace(/\//g, "_").replace(/=/g, "").toLowerCase();
|
|
5564
6128
|
}
|
|
5565
6129
|
constructPreviewUrl(port, sandboxId, hostname, token) {
|
|
5566
|
-
if (!validatePort(port)) throw new
|
|
6130
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
5567
6131
|
const effectiveId = this.sandboxName || sandboxId;
|
|
5568
6132
|
const hasUppercase = /[A-Z]/.test(effectiveId);
|
|
5569
|
-
if (!this.normalizeId && hasUppercase) throw new
|
|
6133
|
+
if (!this.normalizeId && hasUppercase) throw new SandboxSecurityError(`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\nTo fix this:\n1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\nNote: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`);
|
|
5570
6134
|
const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
|
|
5571
6135
|
if (isLocalhostPattern(hostname)) {
|
|
5572
6136
|
const [host, portStr] = hostname.split(":");
|
|
@@ -5576,7 +6140,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5576
6140
|
baseUrl.hostname = `${port}-${sanitizedSandboxId}-${token}.${host}`;
|
|
5577
6141
|
return baseUrl.toString();
|
|
5578
6142
|
} catch (error) {
|
|
5579
|
-
throw new
|
|
6143
|
+
throw new SandboxSecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
5580
6144
|
}
|
|
5581
6145
|
}
|
|
5582
6146
|
try {
|
|
@@ -5584,7 +6148,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5584
6148
|
baseUrl.hostname = `${port}-${sanitizedSandboxId}-${token}.${hostname}`;
|
|
5585
6149
|
return baseUrl.toString();
|
|
5586
6150
|
} catch (error) {
|
|
5587
|
-
throw new
|
|
6151
|
+
throw new SandboxSecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
5588
6152
|
}
|
|
5589
6153
|
}
|
|
5590
6154
|
/**
|
|
@@ -5598,12 +6162,13 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5598
6162
|
...options?.env ?? {}
|
|
5599
6163
|
});
|
|
5600
6164
|
const envPayload = Object.keys(filteredEnv).length > 0 ? filteredEnv : void 0;
|
|
5601
|
-
await this.client.utils.createSession({
|
|
6165
|
+
const response = await this.client.utils.createSession({
|
|
5602
6166
|
id: sessionId,
|
|
5603
6167
|
...envPayload && { env: envPayload },
|
|
5604
6168
|
...options?.cwd && { cwd: options.cwd },
|
|
5605
6169
|
...options?.commandTimeoutMs !== void 0 && { commandTimeoutMs: options.commandTimeoutMs }
|
|
5606
6170
|
});
|
|
6171
|
+
await this.capturePlacementId(response.containerPlacementId);
|
|
5607
6172
|
return this.getSessionWrapper(sessionId);
|
|
5608
6173
|
}
|
|
5609
6174
|
/**
|
|
@@ -5638,6 +6203,26 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5638
6203
|
timestamp: response.timestamp
|
|
5639
6204
|
};
|
|
5640
6205
|
}
|
|
6206
|
+
/**
|
|
6207
|
+
* Get the Cloudflare placement ID observed for the underlying container.
|
|
6208
|
+
*
|
|
6209
|
+
* The placement ID is captured during the first session-create handshake
|
|
6210
|
+
* after a container start and stored in Durable Object storage, so this
|
|
6211
|
+
* method returns the cached value without contacting the container. A new
|
|
6212
|
+
* placement ID is captured on each subsequent session-create handshake,
|
|
6213
|
+
* which occurs whenever the container has been replaced.
|
|
6214
|
+
*
|
|
6215
|
+
* Returns `null` when a handshake has completed but the container's
|
|
6216
|
+
* `CLOUDFLARE_PLACEMENT_ID` environment variable is not set (for example,
|
|
6217
|
+
* in local development).
|
|
6218
|
+
*
|
|
6219
|
+
* Returns `undefined` when no handshake has been observed yet on this
|
|
6220
|
+
* sandbox. Call any method that triggers session creation (such as
|
|
6221
|
+
* `exec()`) to populate the value.
|
|
6222
|
+
*/
|
|
6223
|
+
async getContainerPlacementId() {
|
|
6224
|
+
return this.ctx.storage.get("containerPlacementId");
|
|
6225
|
+
}
|
|
5641
6226
|
getSessionWrapper(sessionId) {
|
|
5642
6227
|
return {
|
|
5643
6228
|
id: sessionId,
|
|
@@ -6010,7 +6595,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6010
6595
|
backupSession = await this.ensureBackupSession();
|
|
6011
6596
|
backupId = crypto.randomUUID();
|
|
6012
6597
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
|
|
6013
|
-
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession,
|
|
6598
|
+
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
|
|
6599
|
+
gitignore,
|
|
6600
|
+
excludes
|
|
6601
|
+
});
|
|
6014
6602
|
if (!createResult.success) throw new BackupCreateError({
|
|
6015
6603
|
message: "Container failed to create backup archive",
|
|
6016
6604
|
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
@@ -6128,7 +6716,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6128
6716
|
backupSession = await this.ensureBackupSession();
|
|
6129
6717
|
backupId = crypto.randomUUID();
|
|
6130
6718
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
|
|
6131
|
-
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession,
|
|
6719
|
+
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
|
|
6720
|
+
gitignore,
|
|
6721
|
+
excludes
|
|
6722
|
+
});
|
|
6132
6723
|
if (!createResult.success) throw new BackupCreateError({
|
|
6133
6724
|
message: "Container failed to create backup archive",
|
|
6134
6725
|
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
@@ -6479,5 +7070,5 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6479
7070
|
};
|
|
6480
7071
|
|
|
6481
7072
|
//#endregion
|
|
6482
|
-
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, 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 };
|
|
6483
|
-
//# sourceMappingURL=sandbox-
|
|
7073
|
+
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, SessionTerminatedError 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 };
|
|
7074
|
+
//# sourceMappingURL=sandbox-PAYx1CcU.js.map
|