@cloudflare/sandbox 0.10.1 → 0.10.2
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/Dockerfile +32 -0
- package/README.md +46 -0
- package/dist/bridge/index.d.ts +0 -6
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/index.js +75 -23
- package/dist/bridge/index.js.map +1 -1
- package/dist/{errors-CBi-O-pF.js → errors-8Hvune8K.js} +2 -2
- package/dist/{errors-CBi-O-pF.js.map → errors-8Hvune8K.js.map} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/openai/index.d.ts +1 -1
- package/dist/opencode/index.d.ts +1 -1
- package/dist/opencode/index.js +1 -1
- package/dist/{sandbox-uC1vzWtG.js → sandbox-BcEq4aUF.js} +890 -78
- package/dist/sandbox-BcEq4aUF.js.map +1 -0
- package/dist/{sandbox-BVgScWy9.d.ts → sandbox-KdzTTnWq.d.ts} +198 -42
- package/dist/sandbox-KdzTTnWq.d.ts.map +1 -0
- package/package.json +1 -1
- package/dist/sandbox-BVgScWy9.d.ts.map +0 -1
- package/dist/sandbox-uC1vzWtG.js.map +0 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
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 getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-
|
|
2
|
+
import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-8Hvune8K.js";
|
|
3
3
|
import { Container, getContainer, switchPort } from "@cloudflare/containers";
|
|
4
4
|
import { AwsClient } from "aws4fetch";
|
|
5
|
-
import { RpcSession } from "capnweb";
|
|
5
|
+
import { RpcSession, RpcTarget } from "capnweb";
|
|
6
6
|
import path from "node:path/posix";
|
|
7
|
+
import { RpcTarget as RpcTarget$1 } from "cloudflare:workers";
|
|
7
8
|
|
|
8
9
|
//#region src/errors/classes.ts
|
|
9
10
|
/**
|
|
@@ -1784,6 +1785,17 @@ var CommandClient = class extends BaseHttpClient {
|
|
|
1784
1785
|
//#endregion
|
|
1785
1786
|
//#region src/clients/desktop-client.ts
|
|
1786
1787
|
/**
|
|
1788
|
+
* Decode a base64-encoded screenshot payload into a Uint8Array.
|
|
1789
|
+
* Shared with the RPC client wrapper, which receives base64 over the
|
|
1790
|
+
* wire and needs the same `format: 'bytes'` convenience.
|
|
1791
|
+
*/
|
|
1792
|
+
function base64ToBytes(data) {
|
|
1793
|
+
const binaryString = atob(data);
|
|
1794
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
1795
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
1796
|
+
return bytes;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1787
1799
|
* Client for desktop environment lifecycle, input, and screen operations
|
|
1788
1800
|
*/
|
|
1789
1801
|
var DesktopClient = class extends BaseHttpClient {
|
|
@@ -1828,15 +1840,10 @@ var DesktopClient = class extends BaseHttpClient {
|
|
|
1828
1840
|
...options?.showCursor !== void 0 && { showCursor: options.showCursor }
|
|
1829
1841
|
};
|
|
1830
1842
|
const response = await this.post("/api/desktop/screenshot", data);
|
|
1831
|
-
if (wantsBytes) {
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
return {
|
|
1836
|
-
...response,
|
|
1837
|
-
data: bytes
|
|
1838
|
-
};
|
|
1839
|
-
}
|
|
1843
|
+
if (wantsBytes) return {
|
|
1844
|
+
...response,
|
|
1845
|
+
data: base64ToBytes(response.data)
|
|
1846
|
+
};
|
|
1840
1847
|
return response;
|
|
1841
1848
|
}
|
|
1842
1849
|
async screenshotRegion(region, options) {
|
|
@@ -1849,15 +1856,10 @@ var DesktopClient = class extends BaseHttpClient {
|
|
|
1849
1856
|
...options?.showCursor !== void 0 && { showCursor: options.showCursor }
|
|
1850
1857
|
};
|
|
1851
1858
|
const response = await this.post("/api/desktop/screenshot/region", data);
|
|
1852
|
-
if (wantsBytes) {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
return {
|
|
1857
|
-
...response,
|
|
1858
|
-
data: bytes
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1859
|
+
if (wantsBytes) return {
|
|
1860
|
+
...response,
|
|
1861
|
+
data: base64ToBytes(response.data)
|
|
1862
|
+
};
|
|
1861
1863
|
return response;
|
|
1862
1864
|
}
|
|
1863
1865
|
/**
|
|
@@ -2010,7 +2012,7 @@ var DesktopClient = class extends BaseHttpClient {
|
|
|
2010
2012
|
* Get health status for a specific desktop process.
|
|
2011
2013
|
*/
|
|
2012
2014
|
async getProcessStatus(name) {
|
|
2013
|
-
return
|
|
2015
|
+
return this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
|
|
2014
2016
|
}
|
|
2015
2017
|
};
|
|
2016
2018
|
|
|
@@ -2523,6 +2525,9 @@ var UtilityClient = class extends BaseHttpClient {
|
|
|
2523
2525
|
return "unknown";
|
|
2524
2526
|
}
|
|
2525
2527
|
}
|
|
2528
|
+
listSessions() {
|
|
2529
|
+
throw new Error("listSessions requires the RPC transport. Set SANDBOX_TRANSPORT=rpc.");
|
|
2530
|
+
}
|
|
2526
2531
|
};
|
|
2527
2532
|
|
|
2528
2533
|
//#endregion
|
|
@@ -2646,6 +2651,13 @@ var SandboxClient = class {
|
|
|
2646
2651
|
utils;
|
|
2647
2652
|
desktop;
|
|
2648
2653
|
watch;
|
|
2654
|
+
/**
|
|
2655
|
+
* Tunnels are RPC-only — the route-based transport does not implement them.
|
|
2656
|
+
* This getter exists so the `PublicKeys<SandboxClient> satisfies
|
|
2657
|
+
* PublicKeys<SandboxAPI>` compile-time check holds. Calling any method on
|
|
2658
|
+
* the returned proxy throws a clear `RPC transport required` error.
|
|
2659
|
+
*/
|
|
2660
|
+
tunnels = createTunnelsNotImplemented();
|
|
2649
2661
|
transport = null;
|
|
2650
2662
|
constructor(options) {
|
|
2651
2663
|
if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
|
|
@@ -2723,6 +2735,14 @@ var SandboxClient = class {
|
|
|
2723
2735
|
if (this.transport) this.transport.disconnect();
|
|
2724
2736
|
}
|
|
2725
2737
|
};
|
|
2738
|
+
function createTunnelsNotImplemented() {
|
|
2739
|
+
const message = "sandbox.tunnels.* requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.";
|
|
2740
|
+
return new Proxy({}, { get() {
|
|
2741
|
+
return () => {
|
|
2742
|
+
throw new Error(message);
|
|
2743
|
+
};
|
|
2744
|
+
} });
|
|
2745
|
+
}
|
|
2726
2746
|
|
|
2727
2747
|
//#endregion
|
|
2728
2748
|
//#region ../shared/src/backup.ts
|
|
@@ -2769,13 +2789,15 @@ var ContainerControlConnection = class {
|
|
|
2769
2789
|
port;
|
|
2770
2790
|
logger;
|
|
2771
2791
|
retryTimeoutMs;
|
|
2792
|
+
onClose;
|
|
2772
2793
|
constructor(options) {
|
|
2773
2794
|
this.containerStub = options.stub;
|
|
2774
2795
|
this.port = options.port ?? 3e3;
|
|
2775
2796
|
this.logger = options.logger ?? createNoOpLogger();
|
|
2776
2797
|
this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
|
|
2798
|
+
this.onClose = options.onClose;
|
|
2777
2799
|
this.transport = new DeferredTransport();
|
|
2778
|
-
this.session = new RpcSession(this.transport);
|
|
2800
|
+
this.session = new RpcSession(this.transport, options.localMain);
|
|
2779
2801
|
this.stub = this.session.getRemoteMain();
|
|
2780
2802
|
}
|
|
2781
2803
|
/**
|
|
@@ -2815,6 +2837,8 @@ var ContainerControlConnection = class {
|
|
|
2815
2837
|
this.stub[Symbol.dispose]?.();
|
|
2816
2838
|
} catch {}
|
|
2817
2839
|
if (this.ws) {
|
|
2840
|
+
this.ws.removeEventListener("close", this.onWebSocketClose);
|
|
2841
|
+
this.ws.removeEventListener("error", this.onWebSocketError);
|
|
2818
2842
|
try {
|
|
2819
2843
|
this.ws.close();
|
|
2820
2844
|
} catch {}
|
|
@@ -2831,6 +2855,42 @@ var ContainerControlConnection = class {
|
|
|
2831
2855
|
setRetryTimeoutMs(ms) {
|
|
2832
2856
|
this.retryTimeoutMs = ms;
|
|
2833
2857
|
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Run the owner-provided `onClose` callback exactly once per call,
|
|
2860
|
+
* swallowing any errors so a buggy listener can't keep the connection
|
|
2861
|
+
* object in a half-torn-down state.
|
|
2862
|
+
*/
|
|
2863
|
+
fireOnClose() {
|
|
2864
|
+
if (!this.onClose) return;
|
|
2865
|
+
try {
|
|
2866
|
+
this.onClose();
|
|
2867
|
+
} catch (err) {
|
|
2868
|
+
this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* WebSocket `close` listener. Defined as a bound arrow field so the
|
|
2873
|
+
* same reference can be passed to both `addEventListener` and
|
|
2874
|
+
* `removeEventListener` — a fresh anonymous lambda would silently
|
|
2875
|
+
* fail to unbind.
|
|
2876
|
+
*/
|
|
2877
|
+
onWebSocketClose = () => {
|
|
2878
|
+
const wasConnected = this.connected;
|
|
2879
|
+
this.connected = false;
|
|
2880
|
+
this.ws = null;
|
|
2881
|
+
this.logger.debug("ContainerControlConnection WebSocket closed");
|
|
2882
|
+
if (wasConnected) this.fireOnClose();
|
|
2883
|
+
};
|
|
2884
|
+
/**
|
|
2885
|
+
* WebSocket `error` listener. Same field-form rationale as
|
|
2886
|
+
* {@link onWebSocketClose}.
|
|
2887
|
+
*/
|
|
2888
|
+
onWebSocketError = () => {
|
|
2889
|
+
const wasConnected = this.connected;
|
|
2890
|
+
this.connected = false;
|
|
2891
|
+
this.ws = null;
|
|
2892
|
+
if (wasConnected) this.fireOnClose();
|
|
2893
|
+
};
|
|
2834
2894
|
async doConnect() {
|
|
2835
2895
|
try {
|
|
2836
2896
|
const response = await this.fetchUpgradeWithRetry();
|
|
@@ -2838,15 +2898,8 @@ var ContainerControlConnection = class {
|
|
|
2838
2898
|
const ws = response.webSocket;
|
|
2839
2899
|
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
2840
2900
|
ws.accept();
|
|
2841
|
-
ws.addEventListener("close",
|
|
2842
|
-
|
|
2843
|
-
this.ws = null;
|
|
2844
|
-
this.logger.debug("ContainerControlConnection WebSocket closed");
|
|
2845
|
-
});
|
|
2846
|
-
ws.addEventListener("error", () => {
|
|
2847
|
-
this.connected = false;
|
|
2848
|
-
this.ws = null;
|
|
2849
|
-
});
|
|
2901
|
+
ws.addEventListener("close", this.onWebSocketClose);
|
|
2902
|
+
ws.addEventListener("error", this.onWebSocketError);
|
|
2850
2903
|
this.ws = ws;
|
|
2851
2904
|
this.transport.activate(ws);
|
|
2852
2905
|
this.connected = true;
|
|
@@ -3126,19 +3179,16 @@ var ContainerControlClient = class {
|
|
|
3126
3179
|
busyPollTimer = null;
|
|
3127
3180
|
/** Tracks whether we currently believe the session is busy. */
|
|
3128
3181
|
busy = false;
|
|
3129
|
-
/**
|
|
3130
|
-
* Set the first time the poller observes `conn.isConnected() === true`,
|
|
3131
|
-
* cleared in `destroyConnection()`. Lets us distinguish "the WebSocket
|
|
3132
|
-
* upgrade is still in progress" (don't tear down) from "we were
|
|
3133
|
-
* connected and the peer went away" (do tear down).
|
|
3134
|
-
*/
|
|
3135
|
-
wasEverConnected = false;
|
|
3136
3182
|
constructor(options) {
|
|
3137
3183
|
this.connOptions = {
|
|
3138
3184
|
stub: options.stub,
|
|
3139
3185
|
port: options.port,
|
|
3186
|
+
localMain: options.localMain,
|
|
3140
3187
|
logger: options.logger,
|
|
3141
|
-
retryTimeoutMs: options.retryTimeoutMs
|
|
3188
|
+
retryTimeoutMs: options.retryTimeoutMs,
|
|
3189
|
+
onClose: () => {
|
|
3190
|
+
if (this.conn) this.destroyConnection();
|
|
3191
|
+
}
|
|
3142
3192
|
};
|
|
3143
3193
|
this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
|
|
3144
3194
|
this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
|
|
@@ -3180,11 +3230,7 @@ var ContainerControlClient = class {
|
|
|
3180
3230
|
pollBusyState = () => {
|
|
3181
3231
|
const conn = this.conn;
|
|
3182
3232
|
if (!conn) return;
|
|
3183
|
-
if (!conn.isConnected())
|
|
3184
|
-
if (this.wasEverConnected) this.destroyConnection();
|
|
3185
|
-
return;
|
|
3186
|
-
}
|
|
3187
|
-
this.wasEverConnected = true;
|
|
3233
|
+
if (!conn.isConnected()) return;
|
|
3188
3234
|
const { imports, exports } = conn.getStats();
|
|
3189
3235
|
if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
|
|
3190
3236
|
if (!this.busy) {
|
|
@@ -3217,7 +3263,7 @@ var ContainerControlClient = class {
|
|
|
3217
3263
|
if (!conn || !conn.isConnected()) return;
|
|
3218
3264
|
const { imports, exports } = conn.getStats();
|
|
3219
3265
|
if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
|
|
3220
|
-
this.logger.debug("Disconnecting idle
|
|
3266
|
+
this.logger.debug("Disconnecting idle RPC connection");
|
|
3221
3267
|
this.destroyConnection();
|
|
3222
3268
|
}
|
|
3223
3269
|
}, this.idleDisconnectMs);
|
|
@@ -3239,7 +3285,6 @@ var ContainerControlClient = class {
|
|
|
3239
3285
|
this.conn.disconnect();
|
|
3240
3286
|
this.conn = null;
|
|
3241
3287
|
}
|
|
3242
|
-
this.wasEverConnected = false;
|
|
3243
3288
|
}
|
|
3244
3289
|
get commands() {
|
|
3245
3290
|
return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
|
|
@@ -3263,11 +3308,37 @@ var ContainerControlClient = class {
|
|
|
3263
3308
|
return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
|
|
3264
3309
|
}
|
|
3265
3310
|
get desktop() {
|
|
3266
|
-
|
|
3311
|
+
const stub = wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
|
|
3312
|
+
const wire = stub;
|
|
3313
|
+
return new Proxy(stub, { get(target, prop, receiver) {
|
|
3314
|
+
if (prop === "screenshot") return async (options) => {
|
|
3315
|
+
const { format, ...rest } = options ?? {};
|
|
3316
|
+
const result = await wire.screenshot(rest);
|
|
3317
|
+
return format === "bytes" ? {
|
|
3318
|
+
...result,
|
|
3319
|
+
data: base64ToBytes(result.data)
|
|
3320
|
+
} : result;
|
|
3321
|
+
};
|
|
3322
|
+
if (prop === "screenshotRegion") return async (region, options) => {
|
|
3323
|
+
const { format, ...rest } = options ?? {};
|
|
3324
|
+
const result = await wire.screenshotRegion({
|
|
3325
|
+
region,
|
|
3326
|
+
...rest
|
|
3327
|
+
});
|
|
3328
|
+
return format === "bytes" ? {
|
|
3329
|
+
...result,
|
|
3330
|
+
data: base64ToBytes(result.data)
|
|
3331
|
+
} : result;
|
|
3332
|
+
};
|
|
3333
|
+
return Reflect.get(target, prop, receiver);
|
|
3334
|
+
} });
|
|
3267
3335
|
}
|
|
3268
3336
|
get watch() {
|
|
3269
3337
|
return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
|
|
3270
3338
|
}
|
|
3339
|
+
get tunnels() {
|
|
3340
|
+
return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
|
|
3341
|
+
}
|
|
3271
3342
|
get interpreter() {
|
|
3272
3343
|
return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
|
|
3273
3344
|
}
|
|
@@ -3808,6 +3879,13 @@ function resolveS3fsOptions(provider, userOptions) {
|
|
|
3808
3879
|
|
|
3809
3880
|
//#endregion
|
|
3810
3881
|
//#region src/storage-mount/validation.ts
|
|
3882
|
+
/**
|
|
3883
|
+
* Type guard for R2Bucket binding.
|
|
3884
|
+
* Checks for the minimal R2Bucket interface methods we use.
|
|
3885
|
+
*/
|
|
3886
|
+
function isR2Bucket(value) {
|
|
3887
|
+
return typeof value === "object" && value !== null && "put" in value && typeof value.put === "function" && "get" in value && typeof value.get === "function" && "head" in value && typeof value.head === "function" && "delete" in value && typeof value.delete === "function" && "list" in value && typeof value.list === "function";
|
|
3888
|
+
}
|
|
3811
3889
|
function validatePrefix(prefix) {
|
|
3812
3890
|
if (!prefix.startsWith("/")) throw new InvalidMountConfigError(`Prefix must start with '/': "${prefix}"`);
|
|
3813
3891
|
}
|
|
@@ -3818,6 +3896,13 @@ function validateBucketName(bucket, mountPath) {
|
|
|
3818
3896
|
}
|
|
3819
3897
|
if (!/^[a-z0-9]([a-z0-9.-]{0,61}[a-z0-9])?$/.test(bucket)) throw new InvalidMountConfigError(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, dots, or hyphens, and cannot start/end with dots or hyphens.`);
|
|
3820
3898
|
}
|
|
3899
|
+
function validateBucketBindingName(bucketBinding, mountPath) {
|
|
3900
|
+
if (bucketBinding.includes(":")) {
|
|
3901
|
+
const [bucketName, prefixPart] = bucketBinding.split(":");
|
|
3902
|
+
throw new InvalidMountConfigError(`Bucket name cannot contain ':'. To mount a prefix, use the 'prefix' option:\n mountBucket('${bucketName}', '${mountPath}', { ...options, prefix: '${prefixPart}' })`);
|
|
3903
|
+
}
|
|
3904
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(bucketBinding)) throw new InvalidMountConfigError(`Invalid R2 binding name: "${bucketBinding}". Binding names must start with a letter or underscore and contain only letters, numbers, or underscores.`);
|
|
3905
|
+
}
|
|
3821
3906
|
/**
|
|
3822
3907
|
* Builds the s3fs source string from bucket name and optional prefix.
|
|
3823
3908
|
* Format: "bucket" or "bucket:/prefix/" for subdirectory mounts.
|
|
@@ -4142,7 +4227,7 @@ async function proxyTerminal(stub, sessionId, request, options) {
|
|
|
4142
4227
|
|
|
4143
4228
|
//#endregion
|
|
4144
4229
|
//#region src/request-handler.ts
|
|
4145
|
-
async function proxyToSandbox(request, env) {
|
|
4230
|
+
async function proxyToSandbox(request, env$1) {
|
|
4146
4231
|
const logger = createLogger({
|
|
4147
4232
|
component: "sandbox-do",
|
|
4148
4233
|
traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
|
|
@@ -4153,7 +4238,7 @@ async function proxyToSandbox(request, env) {
|
|
|
4153
4238
|
const routeInfo = extractSandboxRoute(url);
|
|
4154
4239
|
if (!routeInfo) return null;
|
|
4155
4240
|
const { sandboxId, port, path: path$1, token } = routeInfo;
|
|
4156
|
-
const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
|
|
4241
|
+
const sandbox = getSandbox(env$1.Sandbox, sandboxId, { normalizeId: true });
|
|
4157
4242
|
if (port !== 3e3) {
|
|
4158
4243
|
if (!await sandbox.validatePortToken(port, token)) {
|
|
4159
4244
|
logger.warn("Invalid token access blocked", {
|
|
@@ -4239,6 +4324,513 @@ function isLocalhostPattern(hostname) {
|
|
|
4239
4324
|
return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
|
|
4240
4325
|
}
|
|
4241
4326
|
|
|
4327
|
+
//#endregion
|
|
4328
|
+
//#region src/storage-mount/r2-egress-handler.ts
|
|
4329
|
+
const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
|
|
4330
|
+
const XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
|
4331
|
+
function escapeXML(s) {
|
|
4332
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4333
|
+
}
|
|
4334
|
+
function xmlResponse(body, status = 200) {
|
|
4335
|
+
return new Response(XML_DECL + body, {
|
|
4336
|
+
status,
|
|
4337
|
+
headers: { "Content-Type": "application/xml" }
|
|
4338
|
+
});
|
|
4339
|
+
}
|
|
4340
|
+
function normalizeObjectKey(value) {
|
|
4341
|
+
return value.replace(/^\/+/, "");
|
|
4342
|
+
}
|
|
4343
|
+
function trimTrailingSlashes(s) {
|
|
4344
|
+
let end = s.length;
|
|
4345
|
+
while (end > 0 && s[end - 1] === "/") end--;
|
|
4346
|
+
return s.slice(0, end);
|
|
4347
|
+
}
|
|
4348
|
+
function parsePath(pathname) {
|
|
4349
|
+
const stripped = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
4350
|
+
if (!stripped) return null;
|
|
4351
|
+
const slash = stripped.indexOf("/");
|
|
4352
|
+
if (slash === -1) return {
|
|
4353
|
+
bucket: stripped,
|
|
4354
|
+
key: ""
|
|
4355
|
+
};
|
|
4356
|
+
return {
|
|
4357
|
+
bucket: stripped.slice(0, slash),
|
|
4358
|
+
key: normalizeObjectKey(stripped.slice(slash + 1))
|
|
4359
|
+
};
|
|
4360
|
+
}
|
|
4361
|
+
function resolveR2Bucket(env$1, name) {
|
|
4362
|
+
if (typeof env$1 !== "object" || env$1 === null) return null;
|
|
4363
|
+
const val = env$1[name];
|
|
4364
|
+
return isR2Bucket(val) ? val : null;
|
|
4365
|
+
}
|
|
4366
|
+
function parseRange(header) {
|
|
4367
|
+
if (!header) return void 0;
|
|
4368
|
+
const m = header.match(/^bytes=(\d*)-(\d*)$/);
|
|
4369
|
+
if (!m) return void 0;
|
|
4370
|
+
const start = m[1] ? parseInt(m[1], 10) : void 0;
|
|
4371
|
+
const end = m[2] ? parseInt(m[2], 10) : void 0;
|
|
4372
|
+
if (start === void 0 && end !== void 0) return { suffix: end };
|
|
4373
|
+
if (start !== void 0 && end !== void 0) return {
|
|
4374
|
+
offset: start,
|
|
4375
|
+
length: end - start + 1
|
|
4376
|
+
};
|
|
4377
|
+
if (start !== void 0) return { offset: start };
|
|
4378
|
+
}
|
|
4379
|
+
function buildListObjectsV2Xml(bucketName, prefix, delimiter, maxKeys, result) {
|
|
4380
|
+
const contents = result.objects.map((obj) => `<Contents><Key>${escapeXML(obj.key)}</Key><LastModified>${obj.uploaded.toISOString()}</LastModified><ETag>${escapeXML(obj.httpEtag)}</ETag><Size>${obj.size}</Size><StorageClass>STANDARD</StorageClass></Contents>`).join("");
|
|
4381
|
+
const commonPrefixes = result.delimitedPrefixes.map((p) => `<CommonPrefixes><Prefix>${escapeXML(p)}</Prefix></CommonPrefixes>`).join("");
|
|
4382
|
+
const nextToken = result.truncated && result.cursor ? `<NextContinuationToken>${escapeXML(result.cursor)}</NextContinuationToken>` : "";
|
|
4383
|
+
const keyCount = result.objects.length + result.delimitedPrefixes.length;
|
|
4384
|
+
return `<ListBucketResult ${XML_NS}><Name>${escapeXML(bucketName)}</Name><Prefix>${escapeXML(prefix)}</Prefix><KeyCount>${keyCount}</KeyCount><MaxKeys>${maxKeys}</MaxKeys>` + (delimiter ? `<Delimiter>${escapeXML(delimiter)}</Delimiter>` : "") + `<IsTruncated>${result.truncated}</IsTruncated>` + nextToken + contents + commonPrefixes + `</ListBucketResult>`;
|
|
4385
|
+
}
|
|
4386
|
+
function buildLocationXml() {
|
|
4387
|
+
return `<LocationConstraint ${XML_NS}/>`;
|
|
4388
|
+
}
|
|
4389
|
+
function buildInitiateMultipartUploadXml(bucketName, key, uploadId) {
|
|
4390
|
+
return `<InitiateMultipartUploadResult ${XML_NS}><Bucket>${escapeXML(bucketName)}</Bucket><Key>${escapeXML(key)}</Key><UploadId>${escapeXML(uploadId)}</UploadId></InitiateMultipartUploadResult>`;
|
|
4391
|
+
}
|
|
4392
|
+
function buildCompleteMultipartUploadXml(bucketName, key, etag) {
|
|
4393
|
+
return `<CompleteMultipartUploadResult ${XML_NS}><Location>http://r2.internal/${escapeXML(bucketName)}/${escapeXML(key)}</Location><Bucket>${escapeXML(bucketName)}</Bucket><Key>${escapeXML(key)}</Key><ETag>${escapeXML(etag)}</ETag></CompleteMultipartUploadResult>`;
|
|
4394
|
+
}
|
|
4395
|
+
function buildCopyObjectXml(etag, uploaded) {
|
|
4396
|
+
return `<CopyObjectResult ${XML_NS}><LastModified>${uploaded.toISOString()}</LastModified><ETag>${escapeXML(etag)}</ETag></CopyObjectResult>`;
|
|
4397
|
+
}
|
|
4398
|
+
function extractXmlTagContent(segment, tagName) {
|
|
4399
|
+
const openTag = `<${tagName}>`;
|
|
4400
|
+
const closeTag = `</${tagName}>`;
|
|
4401
|
+
const start = segment.indexOf(openTag);
|
|
4402
|
+
if (start === -1) return null;
|
|
4403
|
+
const contentStart = start + openTag.length;
|
|
4404
|
+
const end = segment.indexOf(closeTag, contentStart);
|
|
4405
|
+
if (end === -1) return null;
|
|
4406
|
+
return segment.slice(contentStart, end);
|
|
4407
|
+
}
|
|
4408
|
+
function parseCompleteMultipartUploadBody(body) {
|
|
4409
|
+
const parts = [];
|
|
4410
|
+
let pos = 0;
|
|
4411
|
+
while (pos < body.length) {
|
|
4412
|
+
const start = body.indexOf("<Part>", pos);
|
|
4413
|
+
if (start === -1) break;
|
|
4414
|
+
const end = body.indexOf("</Part>", start + 6);
|
|
4415
|
+
if (end === -1) break;
|
|
4416
|
+
const segment = body.slice(start, end + 7);
|
|
4417
|
+
pos = end + 7;
|
|
4418
|
+
const partNumberText = extractXmlTagContent(segment, "PartNumber");
|
|
4419
|
+
const etagText = extractXmlTagContent(segment, "ETag");
|
|
4420
|
+
const partNumber = partNumberText ? parseInt(partNumberText, 10) : NaN;
|
|
4421
|
+
if (Number.isFinite(partNumber) && etagText) parts.push({
|
|
4422
|
+
partNumber,
|
|
4423
|
+
etag: etagText.replace(/^"|"$/g, "")
|
|
4424
|
+
});
|
|
4425
|
+
}
|
|
4426
|
+
return parts;
|
|
4427
|
+
}
|
|
4428
|
+
function buildResponseHeaders(obj) {
|
|
4429
|
+
const headers = new Headers();
|
|
4430
|
+
headers.set("ETag", obj.httpEtag);
|
|
4431
|
+
headers.set("Content-Length", String(obj.size));
|
|
4432
|
+
headers.set("Last-Modified", obj.uploaded.toUTCString());
|
|
4433
|
+
headers.set("Accept-Ranges", "bytes");
|
|
4434
|
+
if (obj.httpMetadata?.contentType) headers.set("Content-Type", obj.httpMetadata.contentType);
|
|
4435
|
+
if (obj.httpMetadata?.contentDisposition) headers.set("Content-Disposition", obj.httpMetadata.contentDisposition);
|
|
4436
|
+
if (obj.httpMetadata?.contentEncoding) headers.set("Content-Encoding", obj.httpMetadata.contentEncoding);
|
|
4437
|
+
if (obj.httpMetadata?.contentLanguage) headers.set("Content-Language", obj.httpMetadata.contentLanguage);
|
|
4438
|
+
if (obj.httpMetadata?.cacheControl) headers.set("Cache-Control", obj.httpMetadata.cacheControl);
|
|
4439
|
+
return headers;
|
|
4440
|
+
}
|
|
4441
|
+
function buildContentRange(range, totalSize) {
|
|
4442
|
+
if ("suffix" in range) return `bytes ${Math.max(0, totalSize - range.suffix)}-${totalSize - 1}/${totalSize}`;
|
|
4443
|
+
const start = range.offset ?? 0;
|
|
4444
|
+
return `bytes ${start}-${range.length !== void 0 ? start + range.length - 1 : totalSize - 1}/${totalSize}`;
|
|
4445
|
+
}
|
|
4446
|
+
function getRangeContentLength(range, totalSize) {
|
|
4447
|
+
if ("suffix" in range) return Math.min(range.suffix, totalSize);
|
|
4448
|
+
const start = range.offset ?? 0;
|
|
4449
|
+
if (start >= totalSize) return 0;
|
|
4450
|
+
const requestedLength = range.length !== void 0 ? range.length : totalSize - start;
|
|
4451
|
+
return Math.min(requestedLength, totalSize - start);
|
|
4452
|
+
}
|
|
4453
|
+
function extractHttpMetadata(request) {
|
|
4454
|
+
const meta = {};
|
|
4455
|
+
const ct = request.headers.get("Content-Type");
|
|
4456
|
+
if (ct) meta.contentType = ct;
|
|
4457
|
+
const cd = request.headers.get("Content-Disposition");
|
|
4458
|
+
if (cd) meta.contentDisposition = cd;
|
|
4459
|
+
const ce = request.headers.get("Content-Encoding");
|
|
4460
|
+
if (ce) meta.contentEncoding = ce;
|
|
4461
|
+
const cl = request.headers.get("Content-Language");
|
|
4462
|
+
if (cl) meta.contentLanguage = cl;
|
|
4463
|
+
const cc = request.headers.get("Cache-Control");
|
|
4464
|
+
if (cc) meta.cacheControl = cc;
|
|
4465
|
+
return meta;
|
|
4466
|
+
}
|
|
4467
|
+
function parseCopySource(header) {
|
|
4468
|
+
const sourcePath = header.split("?")[0] ?? "";
|
|
4469
|
+
if (!sourcePath) return null;
|
|
4470
|
+
const decoded = decodeURIComponent(sourcePath);
|
|
4471
|
+
const parsed = parsePath(decoded.startsWith("/") ? decoded : `/${decoded}`);
|
|
4472
|
+
return parsed ? {
|
|
4473
|
+
bucket: parsed.bucket,
|
|
4474
|
+
key: normalizeObjectKey(parsed.key)
|
|
4475
|
+
} : null;
|
|
4476
|
+
}
|
|
4477
|
+
function normalizeStorageClass(storageClass) {
|
|
4478
|
+
if (storageClass === "Standard" || storageClass === "InfrequentAccess") return storageClass;
|
|
4479
|
+
}
|
|
4480
|
+
async function putRequestBody(r2, key, request, options) {
|
|
4481
|
+
const contentLength = request.headers.get("Content-Length");
|
|
4482
|
+
const length = contentLength ? Number.parseInt(contentLength, 10) : NaN;
|
|
4483
|
+
if (!Number.isFinite(length) || length < 0) return new Response("Bad Request: missing or invalid Content-Length", { status: 400 });
|
|
4484
|
+
if (length === 0) return r2.put(key, new Uint8Array(0), options);
|
|
4485
|
+
if (!request.body) return new Response("Bad Request: missing request body", { status: 400 });
|
|
4486
|
+
const { readable, writable } = new FixedLengthStream(length);
|
|
4487
|
+
const pipe = request.body.pipeTo(writable);
|
|
4488
|
+
const result = await r2.put(key, readable, options);
|
|
4489
|
+
await pipe;
|
|
4490
|
+
return result;
|
|
4491
|
+
}
|
|
4492
|
+
async function handleListObjects(r2, bucketName, url, mountPrefix) {
|
|
4493
|
+
const queryPrefix = normalizeObjectKey(url.searchParams.get("prefix") ?? "");
|
|
4494
|
+
const delimiter = url.searchParams.get("delimiter") ?? "";
|
|
4495
|
+
const maxKeys = Math.min(parseInt(url.searchParams.get("max-keys") ?? "1000", 10) || 1e3, 1e3);
|
|
4496
|
+
const continuationToken = url.searchParams.get("continuation-token") ?? void 0;
|
|
4497
|
+
const listOpts = {
|
|
4498
|
+
prefix: (mountPrefix ? `${mountPrefix}/${queryPrefix}` : queryPrefix) || void 0,
|
|
4499
|
+
delimiter: delimiter || void 0,
|
|
4500
|
+
limit: maxKeys,
|
|
4501
|
+
cursor: continuationToken
|
|
4502
|
+
};
|
|
4503
|
+
const result = await r2.list(listOpts);
|
|
4504
|
+
const stripKey = mountPrefix ? (k) => k.startsWith(`${mountPrefix}/`) ? k.slice(mountPrefix.length + 1) : k : (k) => k;
|
|
4505
|
+
return xmlResponse(buildListObjectsV2Xml(bucketName, queryPrefix, delimiter, maxKeys, {
|
|
4506
|
+
objects: result.objects.map((obj) => ({
|
|
4507
|
+
key: stripKey(obj.key),
|
|
4508
|
+
uploaded: obj.uploaded,
|
|
4509
|
+
httpEtag: obj.httpEtag,
|
|
4510
|
+
size: obj.size
|
|
4511
|
+
})),
|
|
4512
|
+
delimitedPrefixes: result.delimitedPrefixes.map(stripKey),
|
|
4513
|
+
truncated: result.truncated,
|
|
4514
|
+
cursor: result.truncated ? result.cursor : void 0
|
|
4515
|
+
}));
|
|
4516
|
+
}
|
|
4517
|
+
async function handleHeadObject(r2, key) {
|
|
4518
|
+
const obj = await r2.head(key);
|
|
4519
|
+
if (!obj) return new Response(null, { status: 404 });
|
|
4520
|
+
return new Response(null, {
|
|
4521
|
+
status: 200,
|
|
4522
|
+
headers: buildResponseHeaders(obj)
|
|
4523
|
+
});
|
|
4524
|
+
}
|
|
4525
|
+
async function handleGetObject(r2, key, request) {
|
|
4526
|
+
const range = parseRange(request.headers.get("Range"));
|
|
4527
|
+
if (!range) {
|
|
4528
|
+
const obj = await r2.get(key);
|
|
4529
|
+
if (!obj) return new Response(null, { status: 404 });
|
|
4530
|
+
return new Response(obj.body, {
|
|
4531
|
+
status: 200,
|
|
4532
|
+
headers: buildResponseHeaders(obj)
|
|
4533
|
+
});
|
|
4534
|
+
}
|
|
4535
|
+
const [headObj, rangeObj] = await Promise.all([r2.head(key), r2.get(key, { range })]);
|
|
4536
|
+
if (!headObj || !rangeObj) return new Response(null, { status: 404 });
|
|
4537
|
+
const headers = buildResponseHeaders(rangeObj);
|
|
4538
|
+
headers.set("Content-Range", buildContentRange(range, headObj.size));
|
|
4539
|
+
headers.set("Content-Length", String(getRangeContentLength(range, headObj.size)));
|
|
4540
|
+
return new Response(rangeObj.body, {
|
|
4541
|
+
status: 206,
|
|
4542
|
+
headers
|
|
4543
|
+
});
|
|
4544
|
+
}
|
|
4545
|
+
async function handlePutObject(r2, bucketName, key, request, env$1, permitted, mountPrefix) {
|
|
4546
|
+
const copySourceHeader = request.headers.get("x-amz-copy-source");
|
|
4547
|
+
if (copySourceHeader) {
|
|
4548
|
+
const copySource = parseCopySource(copySourceHeader);
|
|
4549
|
+
if (!copySource || !copySource.key) return new Response("Bad Request: invalid x-amz-copy-source", { status: 400 });
|
|
4550
|
+
if (!permitted.has(copySource.bucket)) return new Response(`Access to R2 bucket "${copySource.bucket}" is not permitted. Call mountBucket() with this bucket before accessing it.`, { status: 403 });
|
|
4551
|
+
const sourceBucket = copySource.bucket === bucketName ? r2 : resolveR2Bucket(env$1, copySource.bucket);
|
|
4552
|
+
if (!sourceBucket) return new Response(`R2 binding "${copySource.bucket}" not found in Worker env. Ensure the binding name matches the bucket name passed to mountBucket().`, { status: 500 });
|
|
4553
|
+
const sourceKey = mountPrefix && copySource.bucket === bucketName ? `${mountPrefix}/${copySource.key}` : copySource.key;
|
|
4554
|
+
const sourceObject = await sourceBucket.get(sourceKey);
|
|
4555
|
+
if (!sourceObject) return new Response(null, { status: 404 });
|
|
4556
|
+
const httpMetadata = request.headers.get("x-amz-metadata-directive")?.toUpperCase() === "REPLACE" ? extractHttpMetadata(request) : sourceObject.httpMetadata;
|
|
4557
|
+
const result$1 = await r2.put(key, sourceObject.body, {
|
|
4558
|
+
httpMetadata,
|
|
4559
|
+
customMetadata: sourceObject.customMetadata,
|
|
4560
|
+
storageClass: normalizeStorageClass(sourceObject.storageClass)
|
|
4561
|
+
});
|
|
4562
|
+
return xmlResponse(buildCopyObjectXml(result$1.httpEtag, result$1.uploaded));
|
|
4563
|
+
}
|
|
4564
|
+
const result = await putRequestBody(r2, key, request, { httpMetadata: extractHttpMetadata(request) });
|
|
4565
|
+
if (result instanceof Response) return result;
|
|
4566
|
+
const headers = new Headers();
|
|
4567
|
+
headers.set("ETag", result.httpEtag);
|
|
4568
|
+
return new Response(null, {
|
|
4569
|
+
status: 200,
|
|
4570
|
+
headers
|
|
4571
|
+
});
|
|
4572
|
+
}
|
|
4573
|
+
async function handleDeleteObject(r2, key) {
|
|
4574
|
+
await r2.delete(key);
|
|
4575
|
+
return new Response(null, { status: 204 });
|
|
4576
|
+
}
|
|
4577
|
+
async function handleCreateMultipartUpload(r2, bucketName, key, request) {
|
|
4578
|
+
const httpMetadata = extractHttpMetadata(request);
|
|
4579
|
+
return xmlResponse(buildInitiateMultipartUploadXml(bucketName, key, (await r2.createMultipartUpload(key, { httpMetadata })).uploadId));
|
|
4580
|
+
}
|
|
4581
|
+
async function handleUploadPart(r2, key, url, request) {
|
|
4582
|
+
const uploadId = url.searchParams.get("uploadId") ?? "";
|
|
4583
|
+
const partNumber = parseInt(url.searchParams.get("partNumber") ?? "0", 10);
|
|
4584
|
+
if (!uploadId || !partNumber) return new Response("Bad Request: missing uploadId or partNumber", { status: 400 });
|
|
4585
|
+
if (!request.body) return new Response("Bad Request: missing request body", { status: 400 });
|
|
4586
|
+
const contentLength = request.headers.get("Content-Length");
|
|
4587
|
+
const partLength = contentLength ? Number.parseInt(contentLength, 10) : NaN;
|
|
4588
|
+
if (!Number.isFinite(partLength) || partLength < 0) return new Response("Bad Request: missing or invalid Content-Length", { status: 400 });
|
|
4589
|
+
const upload = r2.resumeMultipartUpload(key, uploadId);
|
|
4590
|
+
let part;
|
|
4591
|
+
if (partLength === 0) part = await upload.uploadPart(partNumber, new Uint8Array(0));
|
|
4592
|
+
else {
|
|
4593
|
+
const { readable, writable } = new FixedLengthStream(partLength);
|
|
4594
|
+
const pipe = request.body.pipeTo(writable);
|
|
4595
|
+
part = await upload.uploadPart(partNumber, readable);
|
|
4596
|
+
await pipe;
|
|
4597
|
+
}
|
|
4598
|
+
const headers = new Headers();
|
|
4599
|
+
headers.set("ETag", `"${part.etag}"`);
|
|
4600
|
+
return new Response(null, {
|
|
4601
|
+
status: 200,
|
|
4602
|
+
headers
|
|
4603
|
+
});
|
|
4604
|
+
}
|
|
4605
|
+
async function handleCompleteMultipartUpload(r2, bucketName, key, url, request) {
|
|
4606
|
+
const uploadId = url.searchParams.get("uploadId") ?? "";
|
|
4607
|
+
if (!uploadId) return new Response("Bad Request: missing uploadId", { status: 400 });
|
|
4608
|
+
const r2Parts = parseCompleteMultipartUploadBody(await request.text()).map((p) => ({
|
|
4609
|
+
partNumber: p.partNumber,
|
|
4610
|
+
etag: p.etag
|
|
4611
|
+
}));
|
|
4612
|
+
return xmlResponse(buildCompleteMultipartUploadXml(bucketName, key, (await r2.resumeMultipartUpload(key, uploadId).complete(r2Parts)).httpEtag));
|
|
4613
|
+
}
|
|
4614
|
+
async function handleAbortMultipartUpload(r2, key, url) {
|
|
4615
|
+
const uploadId = url.searchParams.get("uploadId") ?? "";
|
|
4616
|
+
if (!uploadId) return new Response("Bad Request: missing uploadId", { status: 400 });
|
|
4617
|
+
await r2.resumeMultipartUpload(key, uploadId).abort();
|
|
4618
|
+
return new Response(null, { status: 204 });
|
|
4619
|
+
}
|
|
4620
|
+
const r2EgressHandler = async (request, env$1, ctx) => {
|
|
4621
|
+
const url = new URL(request.url);
|
|
4622
|
+
const parsed = parsePath(url.pathname);
|
|
4623
|
+
if (!parsed) return new Response("Bad Request: empty path", { status: 400 });
|
|
4624
|
+
const { bucket: bucketName, key } = parsed;
|
|
4625
|
+
if (!ctx.params?.buckets || !(bucketName in ctx.params.buckets)) return new Response(`Access to R2 bucket "${bucketName}" is not permitted. Call mountBucket() with this bucket before accessing it.`, { status: 403 });
|
|
4626
|
+
const bucketParams = ctx.params.buckets[bucketName];
|
|
4627
|
+
const rawPrefix = bucketParams.prefix;
|
|
4628
|
+
const mountPrefix = rawPrefix ? trimTrailingSlashes(normalizeObjectKey(rawPrefix)) : void 0;
|
|
4629
|
+
const readOnly = bucketParams.readOnly ?? false;
|
|
4630
|
+
const r2 = resolveR2Bucket(env$1, bucketName);
|
|
4631
|
+
if (!r2) return new Response(`R2 binding "${bucketName}" not found in Worker env. Ensure the binding name matches the bucket name passed to mountBucket().`, { status: 500 });
|
|
4632
|
+
const { method } = request;
|
|
4633
|
+
if (!key) {
|
|
4634
|
+
if (method === "GET" && url.searchParams.has("location")) return xmlResponse(buildLocationXml());
|
|
4635
|
+
if (method === "GET" && url.searchParams.get("list-type") === "2") return handleListObjects(r2, bucketName, url, mountPrefix);
|
|
4636
|
+
if (method === "GET") return handleListObjects(r2, bucketName, url, mountPrefix);
|
|
4637
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
4638
|
+
}
|
|
4639
|
+
const fullKey = mountPrefix ? `${mountPrefix}/${key}` : key;
|
|
4640
|
+
const permitted = new Set(Object.keys(ctx.params.buckets));
|
|
4641
|
+
if (readOnly && (method === "PUT" || method === "DELETE" || method === "POST" && (url.searchParams.has("uploads") || url.searchParams.has("uploadId")))) return new Response("Forbidden: bucket mount is read-only", { status: 403 });
|
|
4642
|
+
if (method === "POST" && url.searchParams.has("uploads")) return handleCreateMultipartUpload(r2, bucketName, fullKey, request);
|
|
4643
|
+
if (method === "POST" && url.searchParams.has("uploadId")) return handleCompleteMultipartUpload(r2, bucketName, fullKey, url, request);
|
|
4644
|
+
if (method === "PUT" && url.searchParams.has("partNumber") && url.searchParams.has("uploadId")) return handleUploadPart(r2, fullKey, url, request);
|
|
4645
|
+
if (method === "DELETE" && url.searchParams.has("uploadId")) return handleAbortMultipartUpload(r2, fullKey, url);
|
|
4646
|
+
switch (method) {
|
|
4647
|
+
case "HEAD": return handleHeadObject(r2, fullKey);
|
|
4648
|
+
case "GET": return handleGetObject(r2, fullKey, request);
|
|
4649
|
+
case "PUT": return handlePutObject(r2, bucketName, fullKey, request, env$1, permitted, mountPrefix);
|
|
4650
|
+
case "DELETE": return handleDeleteObject(r2, fullKey);
|
|
4651
|
+
default: return new Response("Method Not Allowed", { status: 405 });
|
|
4652
|
+
}
|
|
4653
|
+
};
|
|
4654
|
+
|
|
4655
|
+
//#endregion
|
|
4656
|
+
//#region src/tunnels/sandbox-control-callback.ts
|
|
4657
|
+
var SandboxControlCallbackImpl = class extends RpcTarget {
|
|
4658
|
+
constructor(getHandler, logger) {
|
|
4659
|
+
super();
|
|
4660
|
+
this.getHandler = getHandler;
|
|
4661
|
+
this.logger = logger;
|
|
4662
|
+
}
|
|
4663
|
+
async onTunnelExit(id, port, exitCode) {
|
|
4664
|
+
const handler = this.getHandler();
|
|
4665
|
+
if (!handler) {
|
|
4666
|
+
this.logger.debug("onTunnelExit: no handler bound; ignoring", {
|
|
4667
|
+
id,
|
|
4668
|
+
port,
|
|
4669
|
+
exitCode
|
|
4670
|
+
});
|
|
4671
|
+
return;
|
|
4672
|
+
}
|
|
4673
|
+
await handler(id, port, exitCode);
|
|
4674
|
+
}
|
|
4675
|
+
};
|
|
4676
|
+
|
|
4677
|
+
//#endregion
|
|
4678
|
+
//#region src/tunnels/tunnels-handler.ts
|
|
4679
|
+
/**
|
|
4680
|
+
* Tunnels namespace handler. Created once per Sandbox DO instance via
|
|
4681
|
+
* `createTunnelsHandler(host)` and exposed as `sandbox.tunnels`.
|
|
4682
|
+
*
|
|
4683
|
+
* Storage is the source of truth. The DO holds a `Record<portString, TunnelInfo>`
|
|
4684
|
+
* under the `tunnels` storage key. `Sandbox.onStart()` clears the key on every
|
|
4685
|
+
* container restart so any record in storage is by construction backed by a
|
|
4686
|
+
* running `cloudflared` process; the handler never needs to verify that
|
|
4687
|
+
* separately against the container.
|
|
4688
|
+
*/
|
|
4689
|
+
/** DO storage key for the `port → TunnelInfo` map. */
|
|
4690
|
+
const STORAGE_KEY = "tunnels";
|
|
4691
|
+
function validateTunnelPort(port) {
|
|
4692
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
|
|
4693
|
+
}
|
|
4694
|
+
/** 8-char hex id derived from `crypto.getRandomValues`. Unique per sandbox. */
|
|
4695
|
+
function shortId() {
|
|
4696
|
+
const buf = new Uint8Array(4);
|
|
4697
|
+
crypto.getRandomValues(buf);
|
|
4698
|
+
return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
4699
|
+
}
|
|
4700
|
+
function isTunnelNotFoundError(error) {
|
|
4701
|
+
return (error instanceof Error ? error.message : String(error)).includes("TUNNEL_NOT_FOUND");
|
|
4702
|
+
}
|
|
4703
|
+
async function readMap(storage) {
|
|
4704
|
+
return await storage.get(STORAGE_KEY) ?? {};
|
|
4705
|
+
}
|
|
4706
|
+
/**
|
|
4707
|
+
* Concrete `TunnelsHandler` implementation. Extends `RpcTarget` so it
|
|
4708
|
+
* can cross the Workers RPC boundary: the Sandbox DO is reachable from
|
|
4709
|
+
* Workers via Workers RPC (`stub.tunnels.get(port)`), and only
|
|
4710
|
+
* `RpcTarget` instances are passed by reference across that boundary.
|
|
4711
|
+
*/
|
|
4712
|
+
var TunnelsRpcTarget = class extends RpcTarget$1 {
|
|
4713
|
+
#host;
|
|
4714
|
+
#withPortLock;
|
|
4715
|
+
constructor(host, withPortLock) {
|
|
4716
|
+
super();
|
|
4717
|
+
this.#host = host;
|
|
4718
|
+
this.#withPortLock = withPortLock;
|
|
4719
|
+
}
|
|
4720
|
+
async get(port) {
|
|
4721
|
+
const startTime = Date.now();
|
|
4722
|
+
let outcome = "error";
|
|
4723
|
+
let cacheState = "miss";
|
|
4724
|
+
let caughtError;
|
|
4725
|
+
try {
|
|
4726
|
+
validateTunnelPort(port);
|
|
4727
|
+
const info = await this.#withPortLock(port, async () => {
|
|
4728
|
+
const existing = (await readMap(this.#host.storage))[port.toString()];
|
|
4729
|
+
if (existing) {
|
|
4730
|
+
cacheState = "hit";
|
|
4731
|
+
return existing;
|
|
4732
|
+
}
|
|
4733
|
+
const id = `quick-${shortId()}`;
|
|
4734
|
+
const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
|
|
4735
|
+
await this.#host.storage.transaction(async (txn) => {
|
|
4736
|
+
const nextMap = await readMap(txn);
|
|
4737
|
+
nextMap[port.toString()] = spawned;
|
|
4738
|
+
await txn.put(STORAGE_KEY, nextMap);
|
|
4739
|
+
});
|
|
4740
|
+
return spawned;
|
|
4741
|
+
});
|
|
4742
|
+
outcome = "success";
|
|
4743
|
+
return info;
|
|
4744
|
+
} catch (error) {
|
|
4745
|
+
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
4746
|
+
throw error;
|
|
4747
|
+
} finally {
|
|
4748
|
+
logCanonicalEvent(this.#host.logger, {
|
|
4749
|
+
event: "tunnel.get",
|
|
4750
|
+
outcome,
|
|
4751
|
+
port,
|
|
4752
|
+
cacheState,
|
|
4753
|
+
durationMs: Date.now() - startTime,
|
|
4754
|
+
error: caughtError
|
|
4755
|
+
});
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
async destroy(portOrInfo) {
|
|
4759
|
+
const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
|
|
4760
|
+
const startTime = Date.now();
|
|
4761
|
+
let outcome = "error";
|
|
4762
|
+
let caughtError;
|
|
4763
|
+
let tunnelId;
|
|
4764
|
+
try {
|
|
4765
|
+
await this.#withPortLock(port, async () => {
|
|
4766
|
+
const existing = (await readMap(this.#host.storage))[port.toString()];
|
|
4767
|
+
if (!existing) return;
|
|
4768
|
+
tunnelId = existing.id;
|
|
4769
|
+
await this.#host.storage.transaction(async (txn) => {
|
|
4770
|
+
const current = await readMap(txn);
|
|
4771
|
+
delete current[port.toString()];
|
|
4772
|
+
await txn.put(STORAGE_KEY, current);
|
|
4773
|
+
});
|
|
4774
|
+
try {
|
|
4775
|
+
await this.#host.client.tunnels.destroyTunnel(existing.id);
|
|
4776
|
+
} catch (error) {
|
|
4777
|
+
if (!isTunnelNotFoundError(error)) throw error;
|
|
4778
|
+
}
|
|
4779
|
+
});
|
|
4780
|
+
outcome = "success";
|
|
4781
|
+
} catch (error) {
|
|
4782
|
+
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
4783
|
+
throw error;
|
|
4784
|
+
} finally {
|
|
4785
|
+
logCanonicalEvent(this.#host.logger, {
|
|
4786
|
+
event: "tunnel.destroy",
|
|
4787
|
+
outcome,
|
|
4788
|
+
port,
|
|
4789
|
+
tunnelId,
|
|
4790
|
+
durationMs: Date.now() - startTime,
|
|
4791
|
+
error: caughtError
|
|
4792
|
+
});
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
async list() {
|
|
4796
|
+
const map = await readMap(this.#host.storage);
|
|
4797
|
+
return Object.values(map);
|
|
4798
|
+
}
|
|
4799
|
+
};
|
|
4800
|
+
function createTunnelsHandler(host) {
|
|
4801
|
+
const portLocks = /* @__PURE__ */ new Map();
|
|
4802
|
+
const withPortLock = (port, fn) => {
|
|
4803
|
+
const next = (portLocks.get(port) ?? Promise.resolve()).then(fn, fn);
|
|
4804
|
+
portLocks.set(port, next.catch(() => void 0));
|
|
4805
|
+
return next;
|
|
4806
|
+
};
|
|
4807
|
+
const tunnels = new TunnelsRpcTarget(host, withPortLock);
|
|
4808
|
+
const handleTunnelExit = async (id, port, exitCode) => {
|
|
4809
|
+
const startTime = Date.now();
|
|
4810
|
+
await withPortLock(port, async () => {
|
|
4811
|
+
await host.storage.transaction(async (txn) => {
|
|
4812
|
+
const map = await readMap(txn);
|
|
4813
|
+
if (map[port.toString()]?.id === id) {
|
|
4814
|
+
delete map[port.toString()];
|
|
4815
|
+
await txn.put(STORAGE_KEY, map);
|
|
4816
|
+
}
|
|
4817
|
+
});
|
|
4818
|
+
logCanonicalEvent(host.logger, {
|
|
4819
|
+
event: "tunnel.exit",
|
|
4820
|
+
outcome: "success",
|
|
4821
|
+
port,
|
|
4822
|
+
tunnelId: id,
|
|
4823
|
+
exitCode: exitCode ?? void 0,
|
|
4824
|
+
durationMs: Date.now() - startTime
|
|
4825
|
+
});
|
|
4826
|
+
});
|
|
4827
|
+
};
|
|
4828
|
+
return {
|
|
4829
|
+
tunnels,
|
|
4830
|
+
handleTunnelExit
|
|
4831
|
+
};
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4242
4834
|
//#endregion
|
|
4243
4835
|
//#region src/version.ts
|
|
4244
4836
|
/**
|
|
@@ -4246,11 +4838,23 @@ function isLocalhostPattern(hostname) {
|
|
|
4246
4838
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
4247
4839
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
4248
4840
|
*/
|
|
4249
|
-
const SDK_VERSION = "0.10.
|
|
4841
|
+
const SDK_VERSION = "0.10.2";
|
|
4250
4842
|
|
|
4251
4843
|
//#endregion
|
|
4252
4844
|
//#region src/sandbox.ts
|
|
4845
|
+
const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
|
|
4846
|
+
var R2EgressProxyTarget = class extends Container {};
|
|
4847
|
+
Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
|
|
4848
|
+
R2EgressProxyTarget.outboundHandlers = { r2EgressMount: r2EgressHandler };
|
|
4849
|
+
function isFetcher(value) {
|
|
4850
|
+
return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
|
|
4851
|
+
}
|
|
4253
4852
|
const sandboxConfigurationCache = /* @__PURE__ */ new WeakMap();
|
|
4853
|
+
const R2_DEFAULT_S3FS_OPTIONS = {
|
|
4854
|
+
stat_cache_expire: "60",
|
|
4855
|
+
enable_noobj_cache: true,
|
|
4856
|
+
multipart_size: "5"
|
|
4857
|
+
};
|
|
4254
4858
|
const BACKUP_DEFAULT_TTL_SECONDS = 259200;
|
|
4255
4859
|
const BACKUP_MAX_NAME_LENGTH = 256;
|
|
4256
4860
|
const BACKUP_CONTAINER_DIR = "/var/backups";
|
|
@@ -4396,6 +5000,10 @@ function getSandbox(ns, id, options) {
|
|
|
4396
5000
|
desktop: new Proxy({}, { get(_, method) {
|
|
4397
5001
|
if (typeof method !== "string" || method === "then") return void 0;
|
|
4398
5002
|
return (...args) => stub.callDesktop(method, args);
|
|
5003
|
+
} }),
|
|
5004
|
+
tunnels: new Proxy({}, { get: (_, method) => {
|
|
5005
|
+
if (typeof method !== "string" || method === "then") return void 0;
|
|
5006
|
+
return (...args) => stub.callTunnels(method, args);
|
|
4399
5007
|
} })
|
|
4400
5008
|
};
|
|
4401
5009
|
return new Proxy(stub, { get(target, prop) {
|
|
@@ -4416,19 +5024,15 @@ function connect(stub) {
|
|
|
4416
5024
|
return await stub.fetch(portSwitchedRequest);
|
|
4417
5025
|
};
|
|
4418
5026
|
}
|
|
4419
|
-
/**
|
|
4420
|
-
* Type guard for R2Bucket binding.
|
|
4421
|
-
* Checks for the minimal R2Bucket interface methods we use.
|
|
4422
|
-
*/
|
|
4423
|
-
function isR2Bucket(value) {
|
|
4424
|
-
return typeof value === "object" && value !== null && "put" in value && typeof value.put === "function" && "get" in value && typeof value.get === "function" && "head" in value && typeof value.head === "function" && "delete" in value && typeof value.delete === "function";
|
|
4425
|
-
}
|
|
4426
5027
|
var Sandbox = class Sandbox extends Container {
|
|
4427
5028
|
defaultPort = 3e3;
|
|
4428
5029
|
sleepAfter = "10m";
|
|
4429
5030
|
client;
|
|
4430
5031
|
codeInterpreter;
|
|
4431
5032
|
sandboxName = null;
|
|
5033
|
+
tunnelsHandler = null;
|
|
5034
|
+
tunnelExitHandler = null;
|
|
5035
|
+
controlCallback;
|
|
4432
5036
|
normalizeId = false;
|
|
4433
5037
|
defaultSession = null;
|
|
4434
5038
|
containerGeneration = 0;
|
|
@@ -4527,13 +5131,30 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4527
5131
|
* Dispatch method for desktop operations.
|
|
4528
5132
|
* Called by the client-side proxy created in getSandbox() to provide
|
|
4529
5133
|
* the `sandbox.desktop.status()` API without relying on RPC pipelining
|
|
4530
|
-
* through property getters.
|
|
5134
|
+
* through property getters which is broken when using vite-plugin.
|
|
4531
5135
|
*/
|
|
4532
5136
|
async callDesktop(method, args) {
|
|
4533
5137
|
if (!Sandbox.DESKTOP_METHODS.has(method)) throw new Error(`Unknown desktop method: ${method}`);
|
|
4534
5138
|
const client = this.client.desktop;
|
|
4535
5139
|
const fn = client[method];
|
|
4536
|
-
if (typeof fn !== "function") throw new Error(`
|
|
5140
|
+
if (typeof fn !== "function") throw new Error(`sandbox.desktop missing method: ${method}`);
|
|
5141
|
+
return fn.apply(client, args);
|
|
5142
|
+
}
|
|
5143
|
+
/**
|
|
5144
|
+
* Dispatch method for tunnel operations.
|
|
5145
|
+
* Called by the client-side proxy created in getSandbox() to provide
|
|
5146
|
+
* the `sandbox.tunnels` API without relying on RPC pipelining
|
|
5147
|
+
* through property getters which is broken when using vite-plugin.
|
|
5148
|
+
*/
|
|
5149
|
+
async callTunnels(method, args) {
|
|
5150
|
+
if (![
|
|
5151
|
+
"get",
|
|
5152
|
+
"list",
|
|
5153
|
+
"destroy"
|
|
5154
|
+
].includes(method)) throw new Error(`Unknown tunnels method: ${method}`);
|
|
5155
|
+
const client = this.tunnels;
|
|
5156
|
+
const fn = client[method];
|
|
5157
|
+
if (typeof fn !== "function") throw new Error(`sandbox.tunnels missing method: ${method}`);
|
|
4537
5158
|
return fn.apply(client, args);
|
|
4538
5159
|
}
|
|
4539
5160
|
/**
|
|
@@ -4579,6 +5200,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4579
5200
|
port: 3e3,
|
|
4580
5201
|
logger: this.logger,
|
|
4581
5202
|
retryTimeoutMs: this.computeRetryTimeoutMs(),
|
|
5203
|
+
localMain: this.controlCallback,
|
|
4582
5204
|
onActivity: () => {
|
|
4583
5205
|
this.renewActivityTimeout();
|
|
4584
5206
|
},
|
|
@@ -4593,9 +5215,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4593
5215
|
}
|
|
4594
5216
|
return this.createSandboxClient();
|
|
4595
5217
|
}
|
|
4596
|
-
constructor(ctx, env) {
|
|
4597
|
-
super(ctx, env);
|
|
4598
|
-
const envObj = env;
|
|
5218
|
+
constructor(ctx, env$1) {
|
|
5219
|
+
super(ctx, env$1);
|
|
5220
|
+
const envObj = env$1;
|
|
4599
5221
|
["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
|
|
4600
5222
|
if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
|
|
4601
5223
|
});
|
|
@@ -4618,6 +5240,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4618
5240
|
accessKeyId: this.r2AccessKeyId,
|
|
4619
5241
|
secretAccessKey: this.r2SecretAccessKey
|
|
4620
5242
|
});
|
|
5243
|
+
this.controlCallback = new SandboxControlCallbackImpl(() => this.tunnelExitHandler, this.logger);
|
|
4621
5244
|
this.client = this.createClientForTransport(this.transport);
|
|
4622
5245
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
4623
5246
|
this.ctx.blockConcurrencyWhile(async () => {
|
|
@@ -4645,6 +5268,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4645
5268
|
const previousClient = this.client;
|
|
4646
5269
|
this.client = this.createClientForTransport(storedTransport);
|
|
4647
5270
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
5271
|
+
this.tunnelsHandler = null;
|
|
5272
|
+
this.tunnelExitHandler = null;
|
|
4648
5273
|
previousClient.disconnect();
|
|
4649
5274
|
}
|
|
4650
5275
|
if (storedTransport) this.hasStoredTransport = true;
|
|
@@ -4736,6 +5361,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4736
5361
|
this.hasStoredTransport = true;
|
|
4737
5362
|
this.client = this.createClientForTransport(transport);
|
|
4738
5363
|
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
5364
|
+
this.tunnelsHandler = null;
|
|
5365
|
+
this.tunnelExitHandler = null;
|
|
4739
5366
|
previousClient.disconnect();
|
|
4740
5367
|
this.renewActivityTimeout();
|
|
4741
5368
|
this.logger.debug("Transport updated", { transport });
|
|
@@ -4753,7 +5380,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4753
5380
|
* Get default timeouts with env var fallbacks and validation
|
|
4754
5381
|
* Precedence: SDK defaults < Env vars < User config
|
|
4755
5382
|
*/
|
|
4756
|
-
getDefaultTimeouts(env) {
|
|
5383
|
+
getDefaultTimeouts(env$1) {
|
|
4757
5384
|
const parseAndValidate = (envVar, name, min, max) => {
|
|
4758
5385
|
const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
|
|
4759
5386
|
if (envVar === void 0) return defaultValue;
|
|
@@ -4769,9 +5396,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4769
5396
|
return parsed;
|
|
4770
5397
|
};
|
|
4771
5398
|
return {
|
|
4772
|
-
instanceGetTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
|
|
4773
|
-
portReadyTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
|
|
4774
|
-
waitIntervalMS: parseAndValidate(getEnvString(env, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
|
|
5399
|
+
instanceGetTimeoutMS: parseAndValidate(getEnvString(env$1, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
|
|
5400
|
+
portReadyTimeoutMS: parseAndValidate(getEnvString(env$1, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
|
|
5401
|
+
waitIntervalMS: parseAndValidate(getEnvString(env$1, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
|
|
4775
5402
|
};
|
|
4776
5403
|
}
|
|
4777
5404
|
/**
|
|
@@ -4793,7 +5420,16 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4793
5420
|
await this.mountBucketLocal(bucket, mountPath, options);
|
|
4794
5421
|
return;
|
|
4795
5422
|
}
|
|
4796
|
-
|
|
5423
|
+
const remoteOptions = options;
|
|
5424
|
+
if (remoteOptions.endpoint === void 0) {
|
|
5425
|
+
const binding = this.env[bucket];
|
|
5426
|
+
if (isR2Bucket(binding)) {
|
|
5427
|
+
await this.mountBucketR2Egress(bucket, mountPath, options);
|
|
5428
|
+
return;
|
|
5429
|
+
}
|
|
5430
|
+
throw new InvalidMountConfigError(`R2 binding "${bucket}" not found in Worker env. Ensure the binding name matches the bucket binding configured in wrangler.jsonc.`);
|
|
5431
|
+
}
|
|
5432
|
+
await this.mountBucketFuse(bucket, mountPath, remoteOptions);
|
|
4797
5433
|
}
|
|
4798
5434
|
/**
|
|
4799
5435
|
* Local dev mount: bidirectional sync via R2 binding + file/watch APIs
|
|
@@ -4850,12 +5486,109 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4850
5486
|
});
|
|
4851
5487
|
}
|
|
4852
5488
|
}
|
|
5489
|
+
getR2EgressParams() {
|
|
5490
|
+
const buckets = {};
|
|
5491
|
+
for (const [, m] of this.activeMounts) if (m.mountType === "r2-egress") buckets[m.bucket] = {
|
|
5492
|
+
prefix: m.prefix,
|
|
5493
|
+
readOnly: m.readOnly
|
|
5494
|
+
};
|
|
5495
|
+
return { buckets };
|
|
5496
|
+
}
|
|
5497
|
+
validateR2EgressS3fsOptions(options) {
|
|
5498
|
+
if (!options) return;
|
|
5499
|
+
const protectedOptions = new Set(["passwd_file", "url"]);
|
|
5500
|
+
for (const option of options) {
|
|
5501
|
+
const [key] = option.split("=");
|
|
5502
|
+
if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for R2 binding mounts`);
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
/**
|
|
5506
|
+
* Credential-less R2 mount: egress interception routes s3fs requests to the
|
|
5507
|
+
* R2 binding. No S3 credentials are needed in the container or Worker env.
|
|
5508
|
+
*/
|
|
5509
|
+
async mountBucketR2Egress(bucket, mountPath, options) {
|
|
5510
|
+
const mountStartTime = Date.now();
|
|
5511
|
+
const prefix = options.prefix;
|
|
5512
|
+
let mountOutcome = "error";
|
|
5513
|
+
let mountError;
|
|
5514
|
+
try {
|
|
5515
|
+
validateBucketBindingName(bucket, mountPath);
|
|
5516
|
+
this.validateMountPath(mountPath);
|
|
5517
|
+
this.validateR2EgressS3fsOptions(options.s3fsOptions);
|
|
5518
|
+
for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
|
|
5519
|
+
if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.prefix !== prefix) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different prefix. Mount the same binding only once, or use the same prefix for additional mounts.`);
|
|
5520
|
+
if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.readOnly !== (options.readOnly ?? false)) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different readOnly setting. Mount the same binding only once, or use the same readOnly value for additional mounts.`);
|
|
5521
|
+
}
|
|
5522
|
+
const passwordFilePath = this.generatePasswordFilePath();
|
|
5523
|
+
await this.createPasswordFile(passwordFilePath, bucket, {
|
|
5524
|
+
accessKeyId: "x",
|
|
5525
|
+
secretAccessKey: "x"
|
|
5526
|
+
});
|
|
5527
|
+
const mountInfo = {
|
|
5528
|
+
mountType: "r2-egress",
|
|
5529
|
+
bucket,
|
|
5530
|
+
mountPath,
|
|
5531
|
+
passwordFilePath,
|
|
5532
|
+
mounted: false,
|
|
5533
|
+
prefix,
|
|
5534
|
+
readOnly: options.readOnly ?? false
|
|
5535
|
+
};
|
|
5536
|
+
this.activeMounts.set(mountPath, mountInfo);
|
|
5537
|
+
await this.configureR2EgressOutbound(this.getR2EgressParams());
|
|
5538
|
+
await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
|
|
5539
|
+
const s3fsSource = bucket;
|
|
5540
|
+
const optionsStr = shellEscape(serializeS3fsOptions({
|
|
5541
|
+
passwd_file: passwordFilePath,
|
|
5542
|
+
...R2_DEFAULT_S3FS_OPTIONS,
|
|
5543
|
+
...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
|
|
5544
|
+
use_path_request_style: true,
|
|
5545
|
+
url: "http://r2.internal",
|
|
5546
|
+
...options.readOnly ? { ro: true } : {}
|
|
5547
|
+
}));
|
|
5548
|
+
const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
|
|
5549
|
+
this.logger.debug("r2-egress: running s3fs", { mountCmd });
|
|
5550
|
+
const result = await this.execInternal(mountCmd);
|
|
5551
|
+
this.logger.debug("r2-egress: s3fs exited", {
|
|
5552
|
+
exitCode: result.exitCode,
|
|
5553
|
+
stdout: result.stdout,
|
|
5554
|
+
stderr: result.stderr
|
|
5555
|
+
});
|
|
5556
|
+
if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
|
|
5557
|
+
const mountpointCheck = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && echo 'FUSE_MOUNTED' || echo 'NOT_FUSE_MOUNTED'`);
|
|
5558
|
+
this.logger.debug("r2-egress: mountpoint check", {
|
|
5559
|
+
stdout: mountpointCheck.stdout.trim(),
|
|
5560
|
+
exitCode: mountpointCheck.exitCode
|
|
5561
|
+
});
|
|
5562
|
+
if (mountpointCheck.stdout.trim() !== "FUSE_MOUNTED") throw new S3FSMountError(`s3fs exited 0 but mount was not established at ${mountPath}`);
|
|
5563
|
+
mountInfo.mounted = true;
|
|
5564
|
+
mountOutcome = "success";
|
|
5565
|
+
} catch (error) {
|
|
5566
|
+
mountError = error instanceof Error ? error : new Error(String(error));
|
|
5567
|
+
const failedMount = this.activeMounts.get(mountPath);
|
|
5568
|
+
this.activeMounts.delete(mountPath);
|
|
5569
|
+
if (failedMount?.mountType === "r2-egress") await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
|
|
5570
|
+
const remainingParams = this.getR2EgressParams();
|
|
5571
|
+
await this.configureR2EgressOutbound(remainingParams).catch(() => {});
|
|
5572
|
+
throw error;
|
|
5573
|
+
} finally {
|
|
5574
|
+
logCanonicalEvent(this.logger, {
|
|
5575
|
+
event: "bucket.mount",
|
|
5576
|
+
outcome: mountOutcome,
|
|
5577
|
+
durationMs: Date.now() - mountStartTime,
|
|
5578
|
+
bucket,
|
|
5579
|
+
mountPath,
|
|
5580
|
+
provider: "r2",
|
|
5581
|
+
prefix,
|
|
5582
|
+
error: mountError
|
|
5583
|
+
});
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
4853
5586
|
/**
|
|
4854
5587
|
* Production mount: S3FS-FUSE inside the container
|
|
4855
5588
|
*/
|
|
4856
5589
|
async mountBucketFuse(bucket, mountPath, options) {
|
|
4857
5590
|
const mountStartTime = Date.now();
|
|
4858
|
-
const prefix = options.prefix
|
|
5591
|
+
const prefix = options.prefix;
|
|
4859
5592
|
let mountOutcome = "error";
|
|
4860
5593
|
let mountError;
|
|
4861
5594
|
let passwordFilePath;
|
|
@@ -4946,6 +5679,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4946
5679
|
}
|
|
4947
5680
|
mountInfo.mounted = false;
|
|
4948
5681
|
this.activeMounts.delete(mountPath);
|
|
5682
|
+
if (mountInfo.mountType === "r2-egress") await this.configureR2EgressOutbound(this.getR2EgressParams());
|
|
4949
5683
|
try {
|
|
4950
5684
|
const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
|
|
4951
5685
|
if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
|
|
@@ -4978,7 +5712,14 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4978
5712
|
}
|
|
4979
5713
|
}
|
|
4980
5714
|
/**
|
|
4981
|
-
*
|
|
5715
|
+
* Shared validation for mount path (absolute, not already in use).
|
|
5716
|
+
*/
|
|
5717
|
+
validateMountPath(mountPath) {
|
|
5718
|
+
if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
|
|
5719
|
+
if (this.activeMounts.has(mountPath)) throw new InvalidMountConfigError(`Mount path "${mountPath}" is already in use by bucket "${this.activeMounts.get(mountPath)?.bucket}". Unmount the existing bucket first or use a different mount path.`);
|
|
5720
|
+
}
|
|
5721
|
+
/**
|
|
5722
|
+
* Validate mount options for remote (FUSE) mounts
|
|
4982
5723
|
*/
|
|
4983
5724
|
validateMountOptions(bucket, mountPath, options) {
|
|
4984
5725
|
try {
|
|
@@ -4987,8 +5728,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4987
5728
|
throw new InvalidMountConfigError(`Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.`);
|
|
4988
5729
|
}
|
|
4989
5730
|
validateBucketName(bucket, mountPath);
|
|
4990
|
-
|
|
4991
|
-
if (this.activeMounts.has(mountPath)) throw new InvalidMountConfigError(`Mount path "${mountPath}" is already in use by bucket "${this.activeMounts.get(mountPath)?.bucket}". Unmount the existing bucket first or use a different mount path.`);
|
|
5731
|
+
this.validateMountPath(mountPath);
|
|
4992
5732
|
}
|
|
4993
5733
|
/**
|
|
4994
5734
|
* Generate unique password file path for s3fs credentials
|
|
@@ -5048,6 +5788,13 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5048
5788
|
if (result.exitCode === 2) throw new S3FSMountError(`S3FS mount failed: ${detail || "Unknown error"}`);
|
|
5049
5789
|
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."}`);
|
|
5050
5790
|
}
|
|
5791
|
+
async unmountTrackedFuseMount(mountPath, mountInfo) {
|
|
5792
|
+
if (!mountInfo.mounted) return;
|
|
5793
|
+
this.logger.debug(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
|
|
5794
|
+
const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
|
|
5795
|
+
if (result.exitCode !== 0) throw new Error(`fusermount -u failed (exit ${result.exitCode}): ${result.stderr || "unknown error"}`);
|
|
5796
|
+
mountInfo.mounted = false;
|
|
5797
|
+
}
|
|
5051
5798
|
/**
|
|
5052
5799
|
* In-flight `destroy()` promise. While set, concurrent callers coalesce
|
|
5053
5800
|
* onto the same teardown instead of triggering a second one. Cleared when
|
|
@@ -5109,10 +5856,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5109
5856
|
this.logger.warn(`Failed to stop local sync for ${mountPath}: ${errorMsg}`);
|
|
5110
5857
|
}
|
|
5111
5858
|
else {
|
|
5112
|
-
|
|
5113
|
-
this.
|
|
5114
|
-
await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
|
|
5115
|
-
mountInfo.mounted = false;
|
|
5859
|
+
try {
|
|
5860
|
+
await this.unmountTrackedFuseMount(mountPath, mountInfo);
|
|
5116
5861
|
} catch (error) {
|
|
5117
5862
|
mountFailures++;
|
|
5118
5863
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
@@ -5122,6 +5867,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5122
5867
|
}
|
|
5123
5868
|
}
|
|
5124
5869
|
await this.ctx.storage.delete("portTokens");
|
|
5870
|
+
await this.ctx.storage.delete("tunnels");
|
|
5125
5871
|
this.client.disconnect();
|
|
5126
5872
|
outcome = "success";
|
|
5127
5873
|
await super.destroy();
|
|
@@ -5149,6 +5895,11 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5149
5895
|
} catch (error) {
|
|
5150
5896
|
this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
|
|
5151
5897
|
}
|
|
5898
|
+
try {
|
|
5899
|
+
await this.ctx.storage.delete("tunnels");
|
|
5900
|
+
} catch (error) {
|
|
5901
|
+
this.logger.error("Failed to clear tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
|
|
5902
|
+
}
|
|
5152
5903
|
}
|
|
5153
5904
|
/**
|
|
5154
5905
|
* Re-expose ports on the container runtime using tokens persisted in DO
|
|
@@ -5251,7 +6002,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5251
6002
|
this.defaultSession = null;
|
|
5252
6003
|
this.defaultSessionInit = null;
|
|
5253
6004
|
this.client.disconnect();
|
|
6005
|
+
let hadR2EgressMount = false;
|
|
5254
6006
|
for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
|
|
6007
|
+
else if (m.mountType === "r2-egress") hadR2EgressMount = true;
|
|
6008
|
+
if (hadR2EgressMount) await this.configureR2EgressOutbound({ buckets: {} }).catch(() => {});
|
|
5255
6009
|
this.activeMounts.clear();
|
|
5256
6010
|
await this.ctx.storage.delete("defaultSession");
|
|
5257
6011
|
}
|
|
@@ -5998,8 +6752,11 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5998
6752
|
}
|
|
5999
6753
|
async getProcess(id, sessionId) {
|
|
6000
6754
|
const session = sessionId ?? await this.ensureDefaultSession();
|
|
6001
|
-
const response = await this.client.processes.getProcess(id)
|
|
6002
|
-
|
|
6755
|
+
const response = await this.client.processes.getProcess(id).catch((e) => {
|
|
6756
|
+
if (e instanceof ProcessNotFoundError) return null;
|
|
6757
|
+
throw e;
|
|
6758
|
+
});
|
|
6759
|
+
if (!response?.process) return null;
|
|
6003
6760
|
const processData = response.process;
|
|
6004
6761
|
return this.createProcessFromDTO({
|
|
6005
6762
|
id: processData.id,
|
|
@@ -6316,6 +7073,45 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6316
7073
|
}];
|
|
6317
7074
|
});
|
|
6318
7075
|
}
|
|
7076
|
+
/**
|
|
7077
|
+
* Namespaced tunnel API. Quick tunnels are zero-config preview URLs
|
|
7078
|
+
* backed by Cloudflare's trycloudflare service.
|
|
7079
|
+
*
|
|
7080
|
+
* - `tunnels.get(port)` — idempotent. Returns the cached tunnel for
|
|
7081
|
+
* `port` if one exists in DO storage, otherwise spawns a fresh
|
|
7082
|
+
* cloudflared process and persists the record.
|
|
7083
|
+
* - `tunnels.list()` — records currently known to this sandbox, from
|
|
7084
|
+
* DO storage.
|
|
7085
|
+
* - `tunnels.destroy(portOrInfo)` — tear down by port number or by
|
|
7086
|
+
* the record returned from `get()`.
|
|
7087
|
+
*
|
|
7088
|
+
* Storage is cleared on container restart (`onStart`), so URLs do
|
|
7089
|
+
* not survive a container restart — the next `get(port)` call will
|
|
7090
|
+
* spawn a fresh tunnel with a new URL.
|
|
7091
|
+
*
|
|
7092
|
+
* Requires the RPC transport. Calling this on a route-based transport
|
|
7093
|
+
* throws "RPC transport required".
|
|
7094
|
+
*/
|
|
7095
|
+
get tunnels() {
|
|
7096
|
+
this.ensureTunnelsBuilt();
|
|
7097
|
+
return this.tunnelsHandler;
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
7100
|
+
* Lazily construct both the public tunnels handler and its sibling
|
|
7101
|
+
* exit-handler callback. Called from the `tunnels` getter on first
|
|
7102
|
+
* access and on every access after a transport swap clears both
|
|
7103
|
+
* fields.
|
|
7104
|
+
*/
|
|
7105
|
+
ensureTunnelsBuilt() {
|
|
7106
|
+
if (this.tunnelsHandler) return;
|
|
7107
|
+
const built = createTunnelsHandler({
|
|
7108
|
+
client: this.client,
|
|
7109
|
+
storage: this.ctx.storage,
|
|
7110
|
+
logger: this.logger
|
|
7111
|
+
});
|
|
7112
|
+
this.tunnelsHandler = built.tunnels;
|
|
7113
|
+
this.tunnelExitHandler = built.handleTunnelExit;
|
|
7114
|
+
}
|
|
6319
7115
|
async isPortExposed(port) {
|
|
6320
7116
|
try {
|
|
6321
7117
|
const sessionId = await this.ensureDefaultSession();
|
|
@@ -7585,8 +8381,24 @@ var Sandbox = class Sandbox extends Container {
|
|
|
7585
8381
|
});
|
|
7586
8382
|
}
|
|
7587
8383
|
}
|
|
8384
|
+
async configureR2EgressOutbound(params) {
|
|
8385
|
+
const ctx = this.ctx;
|
|
8386
|
+
if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
|
|
8387
|
+
if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
|
|
8388
|
+
const fetcher = ctx.exports.ContainerProxy({ props: {
|
|
8389
|
+
enableInternet: this.enableInternet,
|
|
8390
|
+
containerId: this.ctx.id.toString(),
|
|
8391
|
+
className: R2_EGRESS_PROXY_TARGET_CLASS_NAME,
|
|
8392
|
+
outboundByHostOverrides: { "r2.internal": {
|
|
8393
|
+
method: "r2EgressMount",
|
|
8394
|
+
params
|
|
8395
|
+
} }
|
|
8396
|
+
} });
|
|
8397
|
+
if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
|
|
8398
|
+
await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
|
|
8399
|
+
}
|
|
7588
8400
|
};
|
|
7589
8401
|
|
|
7590
8402
|
//#endregion
|
|
7591
8403
|
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 };
|
|
7592
|
-
//# sourceMappingURL=sandbox-
|
|
8404
|
+
//# sourceMappingURL=sandbox-BcEq4aUF.js.map
|