@cloudflare/sandbox 0.4.18 → 0.5.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/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +52 -0
- package/Dockerfile +5 -1
- package/LICENSE +176 -0
- package/README.md +1 -1
- package/dist/index.d.ts +296 -312
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +525 -55
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/clients/base-client.ts +107 -46
- package/src/index.ts +19 -2
- package/src/request-handler.ts +2 -1
- package/src/sandbox.ts +637 -24
- package/src/storage-mount/credential-detection.ts +41 -0
- package/src/storage-mount/errors.ts +51 -0
- package/src/storage-mount/index.ts +17 -0
- package/src/storage-mount/provider-detection.ts +93 -0
- package/src/storage-mount/types.ts +17 -0
- package/src/version.ts +1 -1
- package/tests/base-client.test.ts +218 -0
- package/tests/get-sandbox.test.ts +24 -1
- package/tests/sandbox.test.ts +121 -0
- package/tests/storage-mount/credential-detection.test.ts +119 -0
- package/tests/storage-mount/provider-detection.test.ts +77 -0
- package/tsdown.config.ts +2 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { Container, getContainer, switchPort } from "@cloudflare/containers";
|
|
3
3
|
|
|
4
|
+
//#region ../shared/dist/env.js
|
|
5
|
+
/**
|
|
6
|
+
* Safely extract a string value from an environment object
|
|
7
|
+
*
|
|
8
|
+
* @param env - Environment object with dynamic keys
|
|
9
|
+
* @param key - The environment variable key to access
|
|
10
|
+
* @returns The string value if present and is a string, undefined otherwise
|
|
11
|
+
*/
|
|
12
|
+
function getEnvString(env, key) {
|
|
13
|
+
const value = env?.[key];
|
|
14
|
+
return typeof value === "string" ? value : void 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
4
18
|
//#region ../shared/dist/git.js
|
|
5
19
|
/**
|
|
6
20
|
* Redact credentials from URLs for secure logging
|
|
@@ -225,6 +239,9 @@ var LogLevel;
|
|
|
225
239
|
//#endregion
|
|
226
240
|
//#region ../shared/dist/logger/logger.js
|
|
227
241
|
/**
|
|
242
|
+
* Logger implementation
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
228
245
|
* ANSI color codes for terminal output
|
|
229
246
|
*/
|
|
230
247
|
const COLORS = {
|
|
@@ -344,7 +361,7 @@ var CloudflareLogger = class CloudflareLogger {
|
|
|
344
361
|
* Example: INFO [sandbox-do] Command started (trace: tr_7f3a9b2c) {commandId: "cmd-123"}
|
|
345
362
|
*/
|
|
346
363
|
outputPretty(consoleFn, data) {
|
|
347
|
-
const { level, msg, timestamp, traceId, component, sandboxId, sessionId, processId, commandId, operation, duration, error
|
|
364
|
+
const { level, msg, timestamp, traceId, component, sandboxId, sessionId, processId, commandId, operation, duration, error, ...rest } = data;
|
|
348
365
|
const levelStr = String(level || "INFO").toUpperCase();
|
|
349
366
|
const levelColor = this.getLevelColor(levelStr);
|
|
350
367
|
const componentBadge = component ? `[${component}]` : "";
|
|
@@ -441,6 +458,39 @@ var TraceContext = class TraceContext {
|
|
|
441
458
|
//#endregion
|
|
442
459
|
//#region ../shared/dist/logger/index.js
|
|
443
460
|
/**
|
|
461
|
+
* Logger module
|
|
462
|
+
*
|
|
463
|
+
* Provides structured, trace-aware logging with:
|
|
464
|
+
* - AsyncLocalStorage for implicit context propagation
|
|
465
|
+
* - Pretty printing for local development
|
|
466
|
+
* - JSON output for production
|
|
467
|
+
* - Environment auto-detection
|
|
468
|
+
* - Log level configuration
|
|
469
|
+
*
|
|
470
|
+
* Usage:
|
|
471
|
+
*
|
|
472
|
+
* ```typescript
|
|
473
|
+
* // Create a logger at entry point
|
|
474
|
+
* const logger = createLogger({ component: 'sandbox-do', traceId: 'tr_abc123' });
|
|
475
|
+
*
|
|
476
|
+
* // Store in AsyncLocalStorage for entire request
|
|
477
|
+
* await runWithLogger(logger, async () => {
|
|
478
|
+
* await handleRequest();
|
|
479
|
+
* });
|
|
480
|
+
*
|
|
481
|
+
* // Retrieve logger anywhere in call stack
|
|
482
|
+
* const logger = getLogger();
|
|
483
|
+
* logger.info('Operation started');
|
|
484
|
+
*
|
|
485
|
+
* // Add operation-specific context
|
|
486
|
+
* const execLogger = logger.child({ operation: 'exec', commandId: 'cmd-456' });
|
|
487
|
+
* await runWithLogger(execLogger, async () => {
|
|
488
|
+
* // All nested calls automatically get execLogger
|
|
489
|
+
* await executeCommand();
|
|
490
|
+
* });
|
|
491
|
+
* ```
|
|
492
|
+
*/
|
|
493
|
+
/**
|
|
444
494
|
* Create a no-op logger for testing
|
|
445
495
|
*
|
|
446
496
|
* Returns a logger that implements the Logger interface but does nothing.
|
|
@@ -474,25 +524,6 @@ function createNoOpLogger() {
|
|
|
474
524
|
*/
|
|
475
525
|
const loggerStorage = new AsyncLocalStorage();
|
|
476
526
|
/**
|
|
477
|
-
* Get the current logger from AsyncLocalStorage
|
|
478
|
-
*
|
|
479
|
-
* @throws Error if no logger is initialized in the current async context
|
|
480
|
-
* @returns Current logger instance
|
|
481
|
-
*
|
|
482
|
-
* @example
|
|
483
|
-
* ```typescript
|
|
484
|
-
* function someHelperFunction() {
|
|
485
|
-
* const logger = getLogger(); // Automatically has all context!
|
|
486
|
-
* logger.info('Helper called');
|
|
487
|
-
* }
|
|
488
|
-
* ```
|
|
489
|
-
*/
|
|
490
|
-
function getLogger() {
|
|
491
|
-
const logger = loggerStorage.getStore();
|
|
492
|
-
if (!logger) throw new Error("Logger not initialized in async context. Ensure runWithLogger() is called at the entry point (e.g., fetch handler).");
|
|
493
|
-
return logger;
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
527
|
* Run a function with a logger stored in AsyncLocalStorage
|
|
497
528
|
*
|
|
498
529
|
* The logger is available to all code within the function via getLogger().
|
|
@@ -600,6 +631,17 @@ function getEnvVar(name) {
|
|
|
600
631
|
}
|
|
601
632
|
}
|
|
602
633
|
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region ../shared/dist/shell-escape.js
|
|
636
|
+
/**
|
|
637
|
+
* Escapes a string for safe use in shell commands using POSIX single-quote escaping.
|
|
638
|
+
* Prevents command injection by wrapping the string in single quotes and escaping
|
|
639
|
+
* any single quotes within the string.
|
|
640
|
+
*/
|
|
641
|
+
function shellEscape(str) {
|
|
642
|
+
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
643
|
+
}
|
|
644
|
+
|
|
603
645
|
//#endregion
|
|
604
646
|
//#region ../shared/dist/types.js
|
|
605
647
|
function isExecResult(value) {
|
|
@@ -662,6 +704,10 @@ const ErrorCode = {
|
|
|
662
704
|
GIT_CLONE_FAILED: "GIT_CLONE_FAILED",
|
|
663
705
|
GIT_CHECKOUT_FAILED: "GIT_CHECKOUT_FAILED",
|
|
664
706
|
GIT_OPERATION_FAILED: "GIT_OPERATION_FAILED",
|
|
707
|
+
BUCKET_MOUNT_ERROR: "BUCKET_MOUNT_ERROR",
|
|
708
|
+
S3FS_MOUNT_ERROR: "S3FS_MOUNT_ERROR",
|
|
709
|
+
MISSING_CREDENTIALS: "MISSING_CREDENTIALS",
|
|
710
|
+
INVALID_MOUNT_CONFIG: "INVALID_MOUNT_CONFIG",
|
|
665
711
|
INTERPRETER_NOT_READY: "INTERPRETER_NOT_READY",
|
|
666
712
|
CONTEXT_NOT_FOUND: "CONTEXT_NOT_FOUND",
|
|
667
713
|
CODE_EXECUTION_ERROR: "CODE_EXECUTION_ERROR",
|
|
@@ -695,6 +741,8 @@ const ERROR_STATUS_MAP = {
|
|
|
695
741
|
[ErrorCode.INVALID_JSON_RESPONSE]: 400,
|
|
696
742
|
[ErrorCode.NAME_TOO_LONG]: 400,
|
|
697
743
|
[ErrorCode.VALIDATION_FAILED]: 400,
|
|
744
|
+
[ErrorCode.MISSING_CREDENTIALS]: 400,
|
|
745
|
+
[ErrorCode.INVALID_MOUNT_CONFIG]: 400,
|
|
698
746
|
[ErrorCode.GIT_AUTH_FAILED]: 401,
|
|
699
747
|
[ErrorCode.PERMISSION_DENIED]: 403,
|
|
700
748
|
[ErrorCode.COMMAND_PERMISSION_DENIED]: 403,
|
|
@@ -719,6 +767,8 @@ const ERROR_STATUS_MAP = {
|
|
|
719
767
|
[ErrorCode.GIT_CHECKOUT_FAILED]: 500,
|
|
720
768
|
[ErrorCode.GIT_OPERATION_FAILED]: 500,
|
|
721
769
|
[ErrorCode.CODE_EXECUTION_ERROR]: 500,
|
|
770
|
+
[ErrorCode.BUCKET_MOUNT_ERROR]: 500,
|
|
771
|
+
[ErrorCode.S3FS_MOUNT_ERROR]: 500,
|
|
722
772
|
[ErrorCode.UNKNOWN_ERROR]: 500,
|
|
723
773
|
[ErrorCode.INTERNAL_ERROR]: 500
|
|
724
774
|
};
|
|
@@ -1237,8 +1287,8 @@ function createErrorFromResponse(errorResponse) {
|
|
|
1237
1287
|
|
|
1238
1288
|
//#endregion
|
|
1239
1289
|
//#region src/clients/base-client.ts
|
|
1240
|
-
const TIMEOUT_MS =
|
|
1241
|
-
const MIN_TIME_FOR_RETRY_MS =
|
|
1290
|
+
const TIMEOUT_MS = 12e4;
|
|
1291
|
+
const MIN_TIME_FOR_RETRY_MS = 15e3;
|
|
1242
1292
|
/**
|
|
1243
1293
|
* Abstract base class providing common HTTP functionality for all domain clients
|
|
1244
1294
|
*/
|
|
@@ -1252,31 +1302,31 @@ var BaseHttpClient = class {
|
|
|
1252
1302
|
this.baseUrl = this.options.baseUrl;
|
|
1253
1303
|
}
|
|
1254
1304
|
/**
|
|
1255
|
-
* Core HTTP request method with automatic retry for container
|
|
1305
|
+
* Core HTTP request method with automatic retry for container startup delays
|
|
1306
|
+
* Retries both 503 (provisioning) and 500 (startup failure) errors when they're container-related
|
|
1256
1307
|
*/
|
|
1257
1308
|
async doFetch(path, options) {
|
|
1258
1309
|
const startTime = Date.now();
|
|
1259
1310
|
let attempt = 0;
|
|
1260
1311
|
while (true) {
|
|
1261
1312
|
const response = await this.executeFetch(path, options);
|
|
1262
|
-
if (response
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
this.logger.error("Container failed to provision after multiple attempts", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over 60s`));
|
|
1277
|
-
return response;
|
|
1278
|
-
}
|
|
1313
|
+
if (await this.isRetryableContainerError(response)) {
|
|
1314
|
+
const elapsed = Date.now() - startTime;
|
|
1315
|
+
const remaining = TIMEOUT_MS - elapsed;
|
|
1316
|
+
if (remaining > MIN_TIME_FOR_RETRY_MS) {
|
|
1317
|
+
const delay = Math.min(3e3 * 2 ** attempt, 3e4);
|
|
1318
|
+
this.logger.info("Container not ready, retrying", {
|
|
1319
|
+
status: response.status,
|
|
1320
|
+
attempt: attempt + 1,
|
|
1321
|
+
delayMs: delay,
|
|
1322
|
+
remainingSec: Math.floor(remaining / 1e3)
|
|
1323
|
+
});
|
|
1324
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1325
|
+
attempt++;
|
|
1326
|
+
continue;
|
|
1279
1327
|
}
|
|
1328
|
+
this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1e3)}s`));
|
|
1329
|
+
return response;
|
|
1280
1330
|
}
|
|
1281
1331
|
return response;
|
|
1282
1332
|
}
|
|
@@ -1372,14 +1422,50 @@ var BaseHttpClient = class {
|
|
|
1372
1422
|
} else this.logger.error(`Error in ${operation}`, error instanceof Error ? error : new Error(String(error)));
|
|
1373
1423
|
}
|
|
1374
1424
|
/**
|
|
1375
|
-
* Check if
|
|
1376
|
-
*
|
|
1425
|
+
* Check if response indicates a retryable container error
|
|
1426
|
+
* Uses fail-safe strategy: only retry known transient errors
|
|
1427
|
+
*
|
|
1428
|
+
* TODO: This relies on string matching error messages, which is brittle.
|
|
1429
|
+
* Ideally, the container API should return structured errors with a
|
|
1430
|
+
* `retryable: boolean` field to avoid coupling to error message format.
|
|
1431
|
+
*
|
|
1432
|
+
* @param response - HTTP response to check
|
|
1433
|
+
* @returns true if error is retryable container error, false otherwise
|
|
1377
1434
|
*/
|
|
1378
|
-
async
|
|
1435
|
+
async isRetryableContainerError(response) {
|
|
1436
|
+
if (response.status !== 500 && response.status !== 503) return false;
|
|
1379
1437
|
try {
|
|
1380
|
-
|
|
1438
|
+
const text = await response.clone().text();
|
|
1439
|
+
const textLower = text.toLowerCase();
|
|
1440
|
+
if ([
|
|
1441
|
+
"no such image",
|
|
1442
|
+
"container already exists",
|
|
1443
|
+
"malformed containerinspect"
|
|
1444
|
+
].some((err) => textLower.includes(err))) {
|
|
1445
|
+
this.logger.debug("Detected permanent error, not retrying", { text });
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
const shouldRetry = [
|
|
1449
|
+
"no container instance available",
|
|
1450
|
+
"currently provisioning",
|
|
1451
|
+
"container port not found",
|
|
1452
|
+
"connection refused: container port",
|
|
1453
|
+
"the container is not listening",
|
|
1454
|
+
"failed to verify port",
|
|
1455
|
+
"container did not start",
|
|
1456
|
+
"network connection lost",
|
|
1457
|
+
"container suddenly disconnected",
|
|
1458
|
+
"monitor failed to find container",
|
|
1459
|
+
"timed out",
|
|
1460
|
+
"timeout"
|
|
1461
|
+
].some((err) => textLower.includes(err));
|
|
1462
|
+
if (!shouldRetry) this.logger.debug("Unknown error pattern, not retrying", {
|
|
1463
|
+
status: response.status,
|
|
1464
|
+
text: text.substring(0, 200)
|
|
1465
|
+
});
|
|
1466
|
+
return shouldRetry;
|
|
1381
1467
|
} catch (error) {
|
|
1382
|
-
this.logger.error("Error checking response
|
|
1468
|
+
this.logger.error("Error checking if response is retryable", error instanceof Error ? error : new Error(String(error)));
|
|
1383
1469
|
return false;
|
|
1384
1470
|
}
|
|
1385
1471
|
}
|
|
@@ -2333,7 +2419,7 @@ async function proxyToSandbox(request, env) {
|
|
|
2333
2419
|
const routeInfo = extractSandboxRoute(url);
|
|
2334
2420
|
if (!routeInfo) return null;
|
|
2335
2421
|
const { sandboxId, port, path, token } = routeInfo;
|
|
2336
|
-
const sandbox = getSandbox(env.Sandbox, sandboxId);
|
|
2422
|
+
const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
|
|
2337
2423
|
if (port !== 3e3) {
|
|
2338
2424
|
if (!await sandbox.validatePortToken(port, token)) {
|
|
2339
2425
|
logger.warn("Invalid token access blocked", {
|
|
@@ -2492,6 +2578,124 @@ function asyncIterableToSSEStream(events, options) {
|
|
|
2492
2578
|
});
|
|
2493
2579
|
}
|
|
2494
2580
|
|
|
2581
|
+
//#endregion
|
|
2582
|
+
//#region src/storage-mount/errors.ts
|
|
2583
|
+
/**
|
|
2584
|
+
* Bucket mounting error classes
|
|
2585
|
+
*
|
|
2586
|
+
* These are SDK-side validation errors that follow the same pattern as SecurityError.
|
|
2587
|
+
* They are thrown before any container interaction occurs.
|
|
2588
|
+
*/
|
|
2589
|
+
/**
|
|
2590
|
+
* Base error for bucket mounting operations
|
|
2591
|
+
*/
|
|
2592
|
+
var BucketMountError = class extends Error {
|
|
2593
|
+
code;
|
|
2594
|
+
constructor(message, code = ErrorCode.BUCKET_MOUNT_ERROR) {
|
|
2595
|
+
super(message);
|
|
2596
|
+
this.name = "BucketMountError";
|
|
2597
|
+
this.code = code;
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
/**
|
|
2601
|
+
* Thrown when S3FS mount command fails
|
|
2602
|
+
*/
|
|
2603
|
+
var S3FSMountError = class extends BucketMountError {
|
|
2604
|
+
constructor(message) {
|
|
2605
|
+
super(message, ErrorCode.S3FS_MOUNT_ERROR);
|
|
2606
|
+
this.name = "S3FSMountError";
|
|
2607
|
+
}
|
|
2608
|
+
};
|
|
2609
|
+
/**
|
|
2610
|
+
* Thrown when no credentials found in environment
|
|
2611
|
+
*/
|
|
2612
|
+
var MissingCredentialsError = class extends BucketMountError {
|
|
2613
|
+
constructor(message) {
|
|
2614
|
+
super(message, ErrorCode.MISSING_CREDENTIALS);
|
|
2615
|
+
this.name = "MissingCredentialsError";
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
/**
|
|
2619
|
+
* Thrown when bucket name, mount path, or options are invalid
|
|
2620
|
+
*/
|
|
2621
|
+
var InvalidMountConfigError = class extends BucketMountError {
|
|
2622
|
+
constructor(message) {
|
|
2623
|
+
super(message, ErrorCode.INVALID_MOUNT_CONFIG);
|
|
2624
|
+
this.name = "InvalidMountConfigError";
|
|
2625
|
+
}
|
|
2626
|
+
};
|
|
2627
|
+
|
|
2628
|
+
//#endregion
|
|
2629
|
+
//#region src/storage-mount/credential-detection.ts
|
|
2630
|
+
/**
|
|
2631
|
+
* Detect credentials for bucket mounting from environment variables
|
|
2632
|
+
* Priority order:
|
|
2633
|
+
* 1. Explicit options.credentials
|
|
2634
|
+
* 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
|
2635
|
+
* 3. Error: no credentials found
|
|
2636
|
+
*
|
|
2637
|
+
* @param options - Mount options
|
|
2638
|
+
* @param envVars - Environment variables
|
|
2639
|
+
* @returns Detected credentials
|
|
2640
|
+
* @throws MissingCredentialsError if no credentials found
|
|
2641
|
+
*/
|
|
2642
|
+
function detectCredentials(options, envVars) {
|
|
2643
|
+
if (options.credentials) return options.credentials;
|
|
2644
|
+
const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
|
|
2645
|
+
const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
|
|
2646
|
+
if (awsAccessKeyId && awsSecretAccessKey) return {
|
|
2647
|
+
accessKeyId: awsAccessKeyId,
|
|
2648
|
+
secretAccessKey: awsSecretAccessKey
|
|
2649
|
+
};
|
|
2650
|
+
throw new MissingCredentialsError("No credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or pass explicit credentials in options.");
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
//#endregion
|
|
2654
|
+
//#region src/storage-mount/provider-detection.ts
|
|
2655
|
+
/**
|
|
2656
|
+
* Detect provider from endpoint URL using pattern matching
|
|
2657
|
+
*/
|
|
2658
|
+
function detectProviderFromUrl(endpoint) {
|
|
2659
|
+
try {
|
|
2660
|
+
const hostname = new URL(endpoint).hostname.toLowerCase();
|
|
2661
|
+
if (hostname.endsWith(".r2.cloudflarestorage.com")) return "r2";
|
|
2662
|
+
if (hostname.endsWith(".amazonaws.com") || hostname === "s3.amazonaws.com") return "s3";
|
|
2663
|
+
if (hostname === "storage.googleapis.com") return "gcs";
|
|
2664
|
+
return null;
|
|
2665
|
+
} catch {
|
|
2666
|
+
return null;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Get s3fs flags for a given provider
|
|
2671
|
+
*
|
|
2672
|
+
* Based on s3fs-fuse wiki recommendations:
|
|
2673
|
+
* https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
|
|
2674
|
+
*/
|
|
2675
|
+
function getProviderFlags(provider) {
|
|
2676
|
+
if (!provider) return ["use_path_request_style"];
|
|
2677
|
+
switch (provider) {
|
|
2678
|
+
case "r2": return ["nomixupload"];
|
|
2679
|
+
case "s3": return [];
|
|
2680
|
+
case "gcs": return [];
|
|
2681
|
+
default: return ["use_path_request_style"];
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* Resolve s3fs options by combining provider defaults with user overrides
|
|
2686
|
+
*/
|
|
2687
|
+
function resolveS3fsOptions(provider, userOptions) {
|
|
2688
|
+
const providerFlags = getProviderFlags(provider);
|
|
2689
|
+
if (!userOptions || userOptions.length === 0) return providerFlags;
|
|
2690
|
+
const allFlags = [...providerFlags, ...userOptions];
|
|
2691
|
+
const flagMap = /* @__PURE__ */ new Map();
|
|
2692
|
+
for (const flag of allFlags) {
|
|
2693
|
+
const [flagName] = flag.split("=");
|
|
2694
|
+
flagMap.set(flagName, flag);
|
|
2695
|
+
}
|
|
2696
|
+
return Array.from(flagMap.values());
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2495
2699
|
//#endregion
|
|
2496
2700
|
//#region src/version.ts
|
|
2497
2701
|
/**
|
|
@@ -2499,16 +2703,21 @@ function asyncIterableToSSEStream(events, options) {
|
|
|
2499
2703
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
2500
2704
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
2501
2705
|
*/
|
|
2502
|
-
const SDK_VERSION = "0.
|
|
2706
|
+
const SDK_VERSION = "0.5.1";
|
|
2503
2707
|
|
|
2504
2708
|
//#endregion
|
|
2505
2709
|
//#region src/sandbox.ts
|
|
2506
2710
|
function getSandbox(ns, id, options) {
|
|
2507
|
-
const
|
|
2508
|
-
|
|
2711
|
+
const sanitizedId = sanitizeSandboxId(id);
|
|
2712
|
+
const effectiveId = options?.normalizeId ? sanitizedId.toLowerCase() : sanitizedId;
|
|
2713
|
+
const hasUppercase = /[A-Z]/.test(sanitizedId);
|
|
2714
|
+
if (!options?.normalizeId && hasUppercase) createLogger({ component: "sandbox-do" }).warn(`Sandbox ID "${sanitizedId}" contains uppercase letters, which causes issues with preview URLs (hostnames are case-insensitive). normalizeId will default to true in a future version to prevent this. Use lowercase IDs or pass { normalizeId: true } to prepare.`);
|
|
2715
|
+
const stub = getContainer(ns, effectiveId);
|
|
2716
|
+
stub.setSandboxName?.(effectiveId, options?.normalizeId);
|
|
2509
2717
|
if (options?.baseUrl) stub.setBaseUrl(options.baseUrl);
|
|
2510
2718
|
if (options?.sleepAfter !== void 0) stub.setSleepAfter(options.sleepAfter);
|
|
2511
2719
|
if (options?.keepAlive !== void 0) stub.setKeepAlive(options.keepAlive);
|
|
2720
|
+
if (options?.containerTimeouts) stub.setContainerTimeouts(options.containerTimeouts);
|
|
2512
2721
|
return Object.assign(stub, { wsConnect: connect(stub) });
|
|
2513
2722
|
}
|
|
2514
2723
|
function connect(stub) {
|
|
@@ -2524,18 +2733,35 @@ var Sandbox = class extends Container {
|
|
|
2524
2733
|
client;
|
|
2525
2734
|
codeInterpreter;
|
|
2526
2735
|
sandboxName = null;
|
|
2736
|
+
normalizeId = false;
|
|
2527
2737
|
baseUrl = null;
|
|
2528
2738
|
portTokens = /* @__PURE__ */ new Map();
|
|
2529
2739
|
defaultSession = null;
|
|
2530
2740
|
envVars = {};
|
|
2531
2741
|
logger;
|
|
2532
2742
|
keepAliveEnabled = false;
|
|
2743
|
+
activeMounts = /* @__PURE__ */ new Map();
|
|
2744
|
+
/**
|
|
2745
|
+
* Default container startup timeouts (conservative for production)
|
|
2746
|
+
* Based on Cloudflare docs: "Containers take several minutes to provision"
|
|
2747
|
+
*/
|
|
2748
|
+
DEFAULT_CONTAINER_TIMEOUTS = {
|
|
2749
|
+
instanceGetTimeoutMS: 3e4,
|
|
2750
|
+
portReadyTimeoutMS: 9e4,
|
|
2751
|
+
waitIntervalMS: 1e3
|
|
2752
|
+
};
|
|
2753
|
+
/**
|
|
2754
|
+
* Active container timeout configuration
|
|
2755
|
+
* Can be set via options, env vars, or defaults
|
|
2756
|
+
*/
|
|
2757
|
+
containerTimeouts = { ...this.DEFAULT_CONTAINER_TIMEOUTS };
|
|
2533
2758
|
constructor(ctx, env) {
|
|
2534
2759
|
super(ctx, env);
|
|
2535
2760
|
const envObj = env;
|
|
2536
2761
|
["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
|
|
2537
|
-
if (envObj?.[key]) this.envVars[key] = envObj[key];
|
|
2762
|
+
if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
|
|
2538
2763
|
});
|
|
2764
|
+
this.containerTimeouts = this.getDefaultTimeouts(envObj);
|
|
2539
2765
|
this.logger = createLogger({
|
|
2540
2766
|
component: "sandbox-do",
|
|
2541
2767
|
sandboxId: this.ctx.id.toString()
|
|
@@ -2548,16 +2774,24 @@ var Sandbox = class extends Container {
|
|
|
2548
2774
|
this.codeInterpreter = new CodeInterpreter(this);
|
|
2549
2775
|
this.ctx.blockConcurrencyWhile(async () => {
|
|
2550
2776
|
this.sandboxName = await this.ctx.storage.get("sandboxName") || null;
|
|
2777
|
+
this.normalizeId = await this.ctx.storage.get("normalizeId") || false;
|
|
2551
2778
|
this.defaultSession = await this.ctx.storage.get("defaultSession") || null;
|
|
2552
2779
|
const storedTokens = await this.ctx.storage.get("portTokens") || {};
|
|
2553
2780
|
this.portTokens = /* @__PURE__ */ new Map();
|
|
2554
2781
|
for (const [portStr, token] of Object.entries(storedTokens)) this.portTokens.set(parseInt(portStr, 10), token);
|
|
2782
|
+
const storedTimeouts = await this.ctx.storage.get("containerTimeouts");
|
|
2783
|
+
if (storedTimeouts) this.containerTimeouts = {
|
|
2784
|
+
...this.containerTimeouts,
|
|
2785
|
+
...storedTimeouts
|
|
2786
|
+
};
|
|
2555
2787
|
});
|
|
2556
2788
|
}
|
|
2557
|
-
async setSandboxName(name) {
|
|
2789
|
+
async setSandboxName(name, normalizeId) {
|
|
2558
2790
|
if (!this.sandboxName) {
|
|
2559
2791
|
this.sandboxName = name;
|
|
2792
|
+
this.normalizeId = normalizeId || false;
|
|
2560
2793
|
await this.ctx.storage.put("sandboxName", name);
|
|
2794
|
+
await this.ctx.storage.put("normalizeId", this.normalizeId);
|
|
2561
2795
|
}
|
|
2562
2796
|
}
|
|
2563
2797
|
async setBaseUrl(baseUrl) {
|
|
@@ -2586,10 +2820,183 @@ var Sandbox = class extends Container {
|
|
|
2586
2820
|
}
|
|
2587
2821
|
}
|
|
2588
2822
|
/**
|
|
2823
|
+
* RPC method to configure container startup timeouts
|
|
2824
|
+
*/
|
|
2825
|
+
async setContainerTimeouts(timeouts) {
|
|
2826
|
+
const validated = { ...this.containerTimeouts };
|
|
2827
|
+
if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
|
|
2828
|
+
if (timeouts.portReadyTimeoutMS !== void 0) validated.portReadyTimeoutMS = this.validateTimeout(timeouts.portReadyTimeoutMS, "portReadyTimeoutMS", 1e4, 6e5);
|
|
2829
|
+
if (timeouts.waitIntervalMS !== void 0) validated.waitIntervalMS = this.validateTimeout(timeouts.waitIntervalMS, "waitIntervalMS", 100, 5e3);
|
|
2830
|
+
this.containerTimeouts = validated;
|
|
2831
|
+
await this.ctx.storage.put("containerTimeouts", this.containerTimeouts);
|
|
2832
|
+
this.logger.debug("Container timeouts updated", this.containerTimeouts);
|
|
2833
|
+
}
|
|
2834
|
+
/**
|
|
2835
|
+
* Validate a timeout value is within acceptable range
|
|
2836
|
+
* Throws error if invalid - used for user-provided values
|
|
2837
|
+
*/
|
|
2838
|
+
validateTimeout(value, name, min, max) {
|
|
2839
|
+
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
|
|
2840
|
+
if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
|
|
2841
|
+
return value;
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Get default timeouts with env var fallbacks and validation
|
|
2845
|
+
* Precedence: SDK defaults < Env vars < User config
|
|
2846
|
+
*/
|
|
2847
|
+
getDefaultTimeouts(env) {
|
|
2848
|
+
const parseAndValidate = (envVar, name, min, max) => {
|
|
2849
|
+
const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
|
|
2850
|
+
if (envVar === void 0) return defaultValue;
|
|
2851
|
+
const parsed = parseInt(envVar, 10);
|
|
2852
|
+
if (Number.isNaN(parsed)) {
|
|
2853
|
+
this.logger.warn(`Invalid ${name}: "${envVar}" is not a number. Using default: ${defaultValue}ms`);
|
|
2854
|
+
return defaultValue;
|
|
2855
|
+
}
|
|
2856
|
+
if (parsed < min || parsed > max) {
|
|
2857
|
+
this.logger.warn(`Invalid ${name}: ${parsed}ms. Must be ${min}-${max}ms. Using default: ${defaultValue}ms`);
|
|
2858
|
+
return defaultValue;
|
|
2859
|
+
}
|
|
2860
|
+
return parsed;
|
|
2861
|
+
};
|
|
2862
|
+
return {
|
|
2863
|
+
instanceGetTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
|
|
2864
|
+
portReadyTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
|
|
2865
|
+
waitIntervalMS: parseAndValidate(getEnvString(env, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
async mountBucket(bucket, mountPath, options) {
|
|
2869
|
+
this.logger.info(`Mounting bucket ${bucket} to ${mountPath}`);
|
|
2870
|
+
this.validateMountOptions(bucket, mountPath, options);
|
|
2871
|
+
const provider = options.provider || detectProviderFromUrl(options.endpoint);
|
|
2872
|
+
this.logger.debug(`Detected provider: ${provider || "unknown"}`, { explicitProvider: options.provider });
|
|
2873
|
+
const credentials = detectCredentials(options, this.envVars);
|
|
2874
|
+
const passwordFilePath = this.generatePasswordFilePath();
|
|
2875
|
+
this.activeMounts.set(mountPath, {
|
|
2876
|
+
bucket,
|
|
2877
|
+
mountPath,
|
|
2878
|
+
endpoint: options.endpoint,
|
|
2879
|
+
provider,
|
|
2880
|
+
passwordFilePath,
|
|
2881
|
+
mounted: false
|
|
2882
|
+
});
|
|
2883
|
+
try {
|
|
2884
|
+
await this.createPasswordFile(passwordFilePath, bucket, credentials);
|
|
2885
|
+
await this.exec(`mkdir -p ${shellEscape(mountPath)}`);
|
|
2886
|
+
await this.executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath);
|
|
2887
|
+
this.activeMounts.set(mountPath, {
|
|
2888
|
+
bucket,
|
|
2889
|
+
mountPath,
|
|
2890
|
+
endpoint: options.endpoint,
|
|
2891
|
+
provider,
|
|
2892
|
+
passwordFilePath,
|
|
2893
|
+
mounted: true
|
|
2894
|
+
});
|
|
2895
|
+
this.logger.info(`Successfully mounted bucket ${bucket} to ${mountPath}`);
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
await this.deletePasswordFile(passwordFilePath);
|
|
2898
|
+
this.activeMounts.delete(mountPath);
|
|
2899
|
+
throw error;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Manually unmount a bucket filesystem
|
|
2904
|
+
*
|
|
2905
|
+
* @param mountPath - Absolute path where the bucket is mounted
|
|
2906
|
+
* @throws InvalidMountConfigError if mount path doesn't exist or isn't mounted
|
|
2907
|
+
*/
|
|
2908
|
+
async unmountBucket(mountPath) {
|
|
2909
|
+
this.logger.info(`Unmounting bucket from ${mountPath}`);
|
|
2910
|
+
const mountInfo = this.activeMounts.get(mountPath);
|
|
2911
|
+
if (!mountInfo) throw new InvalidMountConfigError(`No active mount found at path: ${mountPath}`);
|
|
2912
|
+
try {
|
|
2913
|
+
await this.exec(`fusermount -u ${shellEscape(mountPath)}`);
|
|
2914
|
+
mountInfo.mounted = false;
|
|
2915
|
+
this.activeMounts.delete(mountPath);
|
|
2916
|
+
} finally {
|
|
2917
|
+
await this.deletePasswordFile(mountInfo.passwordFilePath);
|
|
2918
|
+
}
|
|
2919
|
+
this.logger.info(`Successfully unmounted bucket from ${mountPath}`);
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Validate mount options
|
|
2923
|
+
*/
|
|
2924
|
+
validateMountOptions(bucket, mountPath, options) {
|
|
2925
|
+
if (!options.endpoint) throw new InvalidMountConfigError("Endpoint is required. Provide the full S3-compatible endpoint URL.");
|
|
2926
|
+
try {
|
|
2927
|
+
new URL(options.endpoint);
|
|
2928
|
+
} catch (error) {
|
|
2929
|
+
throw new InvalidMountConfigError(`Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.`);
|
|
2930
|
+
}
|
|
2931
|
+
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.`);
|
|
2932
|
+
if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
|
|
2933
|
+
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.`);
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Generate unique password file path for s3fs credentials
|
|
2937
|
+
*/
|
|
2938
|
+
generatePasswordFilePath() {
|
|
2939
|
+
return `/tmp/.passwd-s3fs-${crypto.randomUUID()}`;
|
|
2940
|
+
}
|
|
2941
|
+
/**
|
|
2942
|
+
* Create password file with s3fs credentials
|
|
2943
|
+
* Format: bucket:accessKeyId:secretAccessKey
|
|
2944
|
+
*/
|
|
2945
|
+
async createPasswordFile(passwordFilePath, bucket, credentials) {
|
|
2946
|
+
const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
|
|
2947
|
+
await this.writeFile(passwordFilePath, content);
|
|
2948
|
+
await this.exec(`chmod 0600 ${shellEscape(passwordFilePath)}`);
|
|
2949
|
+
this.logger.debug(`Created password file: ${passwordFilePath}`);
|
|
2950
|
+
}
|
|
2951
|
+
/**
|
|
2952
|
+
* Delete password file
|
|
2953
|
+
*/
|
|
2954
|
+
async deletePasswordFile(passwordFilePath) {
|
|
2955
|
+
try {
|
|
2956
|
+
await this.exec(`rm -f ${shellEscape(passwordFilePath)}`);
|
|
2957
|
+
this.logger.debug(`Deleted password file: ${passwordFilePath}`);
|
|
2958
|
+
} catch (error) {
|
|
2959
|
+
this.logger.warn(`Failed to delete password file ${passwordFilePath}`, { error: error instanceof Error ? error.message : String(error) });
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Execute S3FS mount command
|
|
2964
|
+
*/
|
|
2965
|
+
async executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath) {
|
|
2966
|
+
const resolvedOptions = resolveS3fsOptions(provider, options.s3fsOptions);
|
|
2967
|
+
const s3fsArgs = [];
|
|
2968
|
+
s3fsArgs.push(`passwd_file=${passwordFilePath}`);
|
|
2969
|
+
s3fsArgs.push(...resolvedOptions);
|
|
2970
|
+
if (options.readOnly) s3fsArgs.push("ro");
|
|
2971
|
+
s3fsArgs.push(`url=${options.endpoint}`);
|
|
2972
|
+
const optionsStr = shellEscape(s3fsArgs.join(","));
|
|
2973
|
+
const mountCmd = `s3fs ${shellEscape(bucket)} ${shellEscape(mountPath)} -o ${optionsStr}`;
|
|
2974
|
+
this.logger.debug("Executing s3fs mount", {
|
|
2975
|
+
bucket,
|
|
2976
|
+
mountPath,
|
|
2977
|
+
provider,
|
|
2978
|
+
resolvedOptions
|
|
2979
|
+
});
|
|
2980
|
+
const result = await this.exec(mountCmd);
|
|
2981
|
+
if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
|
|
2982
|
+
this.logger.debug("Mount command executed successfully");
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2589
2985
|
* Cleanup and destroy the sandbox container
|
|
2590
2986
|
*/
|
|
2591
2987
|
async destroy() {
|
|
2592
2988
|
this.logger.info("Destroying sandbox container");
|
|
2989
|
+
for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
|
|
2990
|
+
if (mountInfo.mounted) try {
|
|
2991
|
+
this.logger.info(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
|
|
2992
|
+
await this.exec(`fusermount -u ${shellEscape(mountPath)}`);
|
|
2993
|
+
mountInfo.mounted = false;
|
|
2994
|
+
} catch (error) {
|
|
2995
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2996
|
+
this.logger.warn(`Failed to unmount bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}`);
|
|
2997
|
+
}
|
|
2998
|
+
await this.deletePasswordFile(mountInfo.passwordFilePath);
|
|
2999
|
+
}
|
|
2593
3000
|
await super.destroy();
|
|
2594
3001
|
}
|
|
2595
3002
|
onStart() {
|
|
@@ -2628,6 +3035,64 @@ var Sandbox = class extends Container {
|
|
|
2628
3035
|
this.logger.error("Sandbox error", error instanceof Error ? error : new Error(String(error)));
|
|
2629
3036
|
}
|
|
2630
3037
|
/**
|
|
3038
|
+
* Override Container.containerFetch to use production-friendly timeouts
|
|
3039
|
+
* Automatically starts container with longer timeouts if not running
|
|
3040
|
+
*/
|
|
3041
|
+
async containerFetch(requestOrUrl, portOrInit, portParam) {
|
|
3042
|
+
const { request, port } = this.parseContainerFetchArgs(requestOrUrl, portOrInit, portParam);
|
|
3043
|
+
if ((await this.getState()).status !== "healthy") try {
|
|
3044
|
+
this.logger.debug("Starting container with configured timeouts", {
|
|
3045
|
+
instanceTimeout: this.containerTimeouts.instanceGetTimeoutMS,
|
|
3046
|
+
portTimeout: this.containerTimeouts.portReadyTimeoutMS
|
|
3047
|
+
});
|
|
3048
|
+
await this.startAndWaitForPorts({
|
|
3049
|
+
ports: port,
|
|
3050
|
+
cancellationOptions: {
|
|
3051
|
+
instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS,
|
|
3052
|
+
portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS,
|
|
3053
|
+
waitInterval: this.containerTimeouts.waitIntervalMS,
|
|
3054
|
+
abort: request.signal
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
} catch (e) {
|
|
3058
|
+
if (this.isNoInstanceError(e)) return new Response("Container is currently provisioning. This can take several minutes on first deployment. Please retry in a moment.", {
|
|
3059
|
+
status: 503,
|
|
3060
|
+
headers: { "Retry-After": "10" }
|
|
3061
|
+
});
|
|
3062
|
+
this.logger.error("Container startup failed", e instanceof Error ? e : new Error(String(e)));
|
|
3063
|
+
return new Response(`Failed to start container: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
3064
|
+
}
|
|
3065
|
+
return await super.containerFetch(requestOrUrl, portOrInit, portParam);
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Helper: Check if error is "no container instance available"
|
|
3069
|
+
*/
|
|
3070
|
+
isNoInstanceError(error) {
|
|
3071
|
+
return error instanceof Error && error.message.toLowerCase().includes("no container instance");
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Helper: Parse containerFetch arguments (supports multiple signatures)
|
|
3075
|
+
*/
|
|
3076
|
+
parseContainerFetchArgs(requestOrUrl, portOrInit, portParam) {
|
|
3077
|
+
let request;
|
|
3078
|
+
let port;
|
|
3079
|
+
if (requestOrUrl instanceof Request) {
|
|
3080
|
+
request = requestOrUrl;
|
|
3081
|
+
port = typeof portOrInit === "number" ? portOrInit : void 0;
|
|
3082
|
+
} else {
|
|
3083
|
+
const url = typeof requestOrUrl === "string" ? requestOrUrl : requestOrUrl.toString();
|
|
3084
|
+
const init = typeof portOrInit === "number" ? {} : portOrInit || {};
|
|
3085
|
+
port = typeof portOrInit === "number" ? portOrInit : typeof portParam === "number" ? portParam : void 0;
|
|
3086
|
+
request = new Request(url, init);
|
|
3087
|
+
}
|
|
3088
|
+
port ??= this.defaultPort;
|
|
3089
|
+
if (port === void 0) throw new Error("No port specified for container fetch");
|
|
3090
|
+
return {
|
|
3091
|
+
request,
|
|
3092
|
+
port
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
2631
3096
|
* Override onActivityExpired to prevent automatic shutdown when keepAlive is enabled
|
|
2632
3097
|
* When keepAlive is disabled, calls parent implementation which stops the container
|
|
2633
3098
|
*/
|
|
@@ -2696,7 +3161,7 @@ var Sandbox = class extends Container {
|
|
|
2696
3161
|
await this.ctx.storage.put("defaultSession", sessionId);
|
|
2697
3162
|
this.logger.debug("Default session initialized", { sessionId });
|
|
2698
3163
|
} catch (error) {
|
|
2699
|
-
if (error
|
|
3164
|
+
if (error instanceof Error && error.message.includes("already exists")) {
|
|
2700
3165
|
this.logger.debug("Reusing existing session after reload", { sessionId });
|
|
2701
3166
|
this.defaultSession = sessionId;
|
|
2702
3167
|
await this.ctx.storage.put("defaultSession", sessionId);
|
|
@@ -3017,7 +3482,10 @@ var Sandbox = class extends Container {
|
|
|
3017
3482
|
}
|
|
3018
3483
|
constructPreviewUrl(port, sandboxId, hostname, token) {
|
|
3019
3484
|
if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
|
|
3020
|
-
const
|
|
3485
|
+
const effectiveId = this.sandboxName || sandboxId;
|
|
3486
|
+
const hasUppercase = /[A-Z]/.test(effectiveId);
|
|
3487
|
+
if (!this.normalizeId && hasUppercase) throw new SecurityError(`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.`);
|
|
3488
|
+
const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
|
|
3021
3489
|
if (isLocalhostPattern(hostname)) {
|
|
3022
3490
|
const [host, portStr] = hostname.split(":");
|
|
3023
3491
|
const mainPort = portStr || "80";
|
|
@@ -3139,7 +3607,9 @@ var Sandbox = class extends Container {
|
|
|
3139
3607
|
},
|
|
3140
3608
|
runCodeStream: (code, options) => this.codeInterpreter.runCodeStream(code, options),
|
|
3141
3609
|
listCodeContexts: () => this.codeInterpreter.listCodeContexts(),
|
|
3142
|
-
deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId)
|
|
3610
|
+
deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId),
|
|
3611
|
+
mountBucket: (bucket, mountPath, options) => this.mountBucket(bucket, mountPath, options),
|
|
3612
|
+
unmountBucket: (mountPath) => this.unmountBucket(mountPath)
|
|
3143
3613
|
};
|
|
3144
3614
|
}
|
|
3145
3615
|
async createCodeContext(options) {
|
|
@@ -3276,5 +3746,5 @@ async function collectFile(stream) {
|
|
|
3276
3746
|
}
|
|
3277
3747
|
|
|
3278
3748
|
//#endregion
|
|
3279
|
-
export { CodeInterpreter, CommandClient,
|
|
3749
|
+
export { BucketMountError, CodeInterpreter, CommandClient, FileClient, GitClient, InvalidMountConfigError, MissingCredentialsError, PortClient, ProcessClient, S3FSMountError, Sandbox, SandboxClient, UtilityClient, asyncIterableToSSEStream, collectFile, getSandbox, isExecResult, isProcess, isProcessStatus, parseSSEStream, proxyToSandbox, responseToAsyncIterable, streamFile };
|
|
3280
3750
|
//# sourceMappingURL=index.js.map
|