@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { BucketCredentials, MountBucketOptions } from '@repo/shared';
|
|
2
|
+
import { MissingCredentialsError } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect credentials for bucket mounting from environment variables
|
|
6
|
+
* Priority order:
|
|
7
|
+
* 1. Explicit options.credentials
|
|
8
|
+
* 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
|
9
|
+
* 3. Error: no credentials found
|
|
10
|
+
*
|
|
11
|
+
* @param options - Mount options
|
|
12
|
+
* @param envVars - Environment variables
|
|
13
|
+
* @returns Detected credentials
|
|
14
|
+
* @throws MissingCredentialsError if no credentials found
|
|
15
|
+
*/
|
|
16
|
+
export function detectCredentials(
|
|
17
|
+
options: MountBucketOptions,
|
|
18
|
+
envVars: Record<string, string | undefined>
|
|
19
|
+
): BucketCredentials {
|
|
20
|
+
// Priority 1: Explicit credentials in options
|
|
21
|
+
if (options.credentials) {
|
|
22
|
+
return options.credentials;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Priority 2: Standard AWS env vars
|
|
26
|
+
const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
|
|
27
|
+
const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
|
|
28
|
+
|
|
29
|
+
if (awsAccessKeyId && awsSecretAccessKey) {
|
|
30
|
+
return {
|
|
31
|
+
accessKeyId: awsAccessKeyId,
|
|
32
|
+
secretAccessKey: awsSecretAccessKey
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// No credentials found - throw error with helpful message
|
|
37
|
+
throw new MissingCredentialsError(
|
|
38
|
+
`No credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY ` +
|
|
39
|
+
`environment variables, or pass explicit credentials in options.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket mounting error classes
|
|
3
|
+
*
|
|
4
|
+
* These are SDK-side validation errors that follow the same pattern as SecurityError.
|
|
5
|
+
* They are thrown before any container interaction occurs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ErrorCode } from '@repo/shared/errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base error for bucket mounting operations
|
|
12
|
+
*/
|
|
13
|
+
export class BucketMountError extends Error {
|
|
14
|
+
public readonly code: ErrorCode;
|
|
15
|
+
|
|
16
|
+
constructor(message: string, code: ErrorCode = ErrorCode.BUCKET_MOUNT_ERROR) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'BucketMountError';
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when S3FS mount command fails
|
|
25
|
+
*/
|
|
26
|
+
export class S3FSMountError extends BucketMountError {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message, ErrorCode.S3FS_MOUNT_ERROR);
|
|
29
|
+
this.name = 'S3FSMountError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when no credentials found in environment
|
|
35
|
+
*/
|
|
36
|
+
export class MissingCredentialsError extends BucketMountError {
|
|
37
|
+
constructor(message: string) {
|
|
38
|
+
super(message, ErrorCode.MISSING_CREDENTIALS);
|
|
39
|
+
this.name = 'MissingCredentialsError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when bucket name, mount path, or options are invalid
|
|
45
|
+
*/
|
|
46
|
+
export class InvalidMountConfigError extends BucketMountError {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message, ErrorCode.INVALID_MOUNT_CONFIG);
|
|
49
|
+
this.name = 'InvalidMountConfigError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket mounting functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { detectCredentials } from './credential-detection';
|
|
6
|
+
export {
|
|
7
|
+
BucketMountError,
|
|
8
|
+
InvalidMountConfigError,
|
|
9
|
+
MissingCredentialsError,
|
|
10
|
+
S3FSMountError
|
|
11
|
+
} from './errors';
|
|
12
|
+
export {
|
|
13
|
+
detectProviderFromUrl,
|
|
14
|
+
getProviderFlags,
|
|
15
|
+
resolveS3fsOptions
|
|
16
|
+
} from './provider-detection';
|
|
17
|
+
export type { MountInfo } from './types';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection and s3fs flag configuration
|
|
3
|
+
*
|
|
4
|
+
* Based on s3fs-fuse documentation:
|
|
5
|
+
* https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BucketProvider } from '@repo/shared';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect provider from endpoint URL using pattern matching
|
|
12
|
+
*/
|
|
13
|
+
export function detectProviderFromUrl(endpoint: string): BucketProvider | null {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(endpoint);
|
|
16
|
+
const hostname = url.hostname.toLowerCase();
|
|
17
|
+
|
|
18
|
+
if (hostname.endsWith('.r2.cloudflarestorage.com')) {
|
|
19
|
+
return 'r2';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Match AWS S3: *.amazonaws.com or s3.amazonaws.com
|
|
23
|
+
if (
|
|
24
|
+
hostname.endsWith('.amazonaws.com') ||
|
|
25
|
+
hostname === 's3.amazonaws.com'
|
|
26
|
+
) {
|
|
27
|
+
return 's3';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (hostname === 'storage.googleapis.com') {
|
|
31
|
+
return 'gcs';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get s3fs flags for a given provider
|
|
42
|
+
*
|
|
43
|
+
* Based on s3fs-fuse wiki recommendations:
|
|
44
|
+
* https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
|
|
45
|
+
*/
|
|
46
|
+
export function getProviderFlags(provider: BucketProvider | null): string[] {
|
|
47
|
+
if (!provider) {
|
|
48
|
+
return ['use_path_request_style'];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch (provider) {
|
|
52
|
+
case 'r2':
|
|
53
|
+
return ['nomixupload'];
|
|
54
|
+
|
|
55
|
+
case 's3':
|
|
56
|
+
return [];
|
|
57
|
+
|
|
58
|
+
case 'gcs':
|
|
59
|
+
return [];
|
|
60
|
+
|
|
61
|
+
default:
|
|
62
|
+
return ['use_path_request_style'];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve s3fs options by combining provider defaults with user overrides
|
|
68
|
+
*/
|
|
69
|
+
export function resolveS3fsOptions(
|
|
70
|
+
provider: BucketProvider | null,
|
|
71
|
+
userOptions?: string[]
|
|
72
|
+
): string[] {
|
|
73
|
+
const providerFlags = getProviderFlags(provider);
|
|
74
|
+
|
|
75
|
+
if (!userOptions || userOptions.length === 0) {
|
|
76
|
+
return providerFlags;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Merge provider flags with user options
|
|
80
|
+
// User options take precedence (come last in the array)
|
|
81
|
+
const allFlags = [...providerFlags, ...userOptions];
|
|
82
|
+
|
|
83
|
+
// Deduplicate flags (keep last occurrence)
|
|
84
|
+
const flagMap = new Map<string, string>();
|
|
85
|
+
|
|
86
|
+
for (const flag of allFlags) {
|
|
87
|
+
// Split on '=' to get the flag name
|
|
88
|
+
const [flagName] = flag.split('=');
|
|
89
|
+
flagMap.set(flagName, flag);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Array.from(flagMap.values());
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal bucket mounting types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { BucketProvider } from '@repo/shared';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal tracking information for active mounts
|
|
9
|
+
*/
|
|
10
|
+
export interface MountInfo {
|
|
11
|
+
bucket: string;
|
|
12
|
+
mountPath: string;
|
|
13
|
+
endpoint: string;
|
|
14
|
+
provider: BucketProvider | null;
|
|
15
|
+
passwordFilePath: string;
|
|
16
|
+
mounted: boolean;
|
|
17
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -361,4 +361,222 @@ describe('BaseHttpClient', () => {
|
|
|
361
361
|
}
|
|
362
362
|
});
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
describe('container startup retry logic', () => {
|
|
366
|
+
it('should retry 503 errors with "no container instance available"', async () => {
|
|
367
|
+
vi.useFakeTimers();
|
|
368
|
+
|
|
369
|
+
mockFetch
|
|
370
|
+
.mockResolvedValueOnce(
|
|
371
|
+
new Response('Error: There is no container instance available', {
|
|
372
|
+
status: 503
|
|
373
|
+
})
|
|
374
|
+
)
|
|
375
|
+
.mockResolvedValueOnce(
|
|
376
|
+
new Response(JSON.stringify({ success: true, data: 'recovered' }), {
|
|
377
|
+
status: 200
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const promise = client.testRequest<TestDataResponse>('/api/test');
|
|
382
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
383
|
+
const result = await promise;
|
|
384
|
+
|
|
385
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
386
|
+
expect(result.success).toBe(true);
|
|
387
|
+
expect(result.data).toBe('recovered');
|
|
388
|
+
|
|
389
|
+
vi.useRealTimers();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should retry 500 errors with "container port not found"', async () => {
|
|
393
|
+
vi.useFakeTimers();
|
|
394
|
+
|
|
395
|
+
mockFetch
|
|
396
|
+
.mockResolvedValueOnce(
|
|
397
|
+
new Response('Connection refused: container port not found', {
|
|
398
|
+
status: 500
|
|
399
|
+
})
|
|
400
|
+
)
|
|
401
|
+
.mockResolvedValueOnce(
|
|
402
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const promise = client.testRequest('/api/test');
|
|
406
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
407
|
+
const result = await promise;
|
|
408
|
+
|
|
409
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
410
|
+
expect(result.success).toBe(true);
|
|
411
|
+
|
|
412
|
+
vi.useRealTimers();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should retry 500 errors with "the container is not listening"', async () => {
|
|
416
|
+
vi.useFakeTimers();
|
|
417
|
+
|
|
418
|
+
mockFetch
|
|
419
|
+
.mockResolvedValueOnce(
|
|
420
|
+
new Response('Error: the container is not listening on port 3000', {
|
|
421
|
+
status: 500
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
.mockResolvedValueOnce(
|
|
425
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const promise = client.testRequest('/api/test');
|
|
429
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
430
|
+
const result = await promise;
|
|
431
|
+
|
|
432
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
433
|
+
expect(result.success).toBe(true);
|
|
434
|
+
|
|
435
|
+
vi.useRealTimers();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should NOT retry 500 errors with "no such image"', async () => {
|
|
439
|
+
mockFetch.mockResolvedValueOnce(
|
|
440
|
+
new Response('Error: no such image: my-container:latest', {
|
|
441
|
+
status: 500
|
|
442
|
+
})
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
446
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should NOT retry 500 errors with "container already exists"', async () => {
|
|
450
|
+
mockFetch.mockResolvedValueOnce(
|
|
451
|
+
new Response('Error: container already exists', { status: 500 })
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
455
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should NOT retry 500 errors with unknown patterns', async () => {
|
|
459
|
+
mockFetch.mockResolvedValueOnce(
|
|
460
|
+
new Response('Internal server error: database connection failed', {
|
|
461
|
+
status: 500
|
|
462
|
+
})
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
466
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // Fail-safe: don't retry
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should NOT retry 404 or other non-500/503 errors', async () => {
|
|
470
|
+
mockFetch.mockResolvedValueOnce(
|
|
471
|
+
new Response('Not found', { status: 404 })
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
475
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should respect MIN_TIME_FOR_RETRY_MS and stop retrying', async () => {
|
|
479
|
+
vi.useFakeTimers();
|
|
480
|
+
|
|
481
|
+
// Mock responses that would trigger retry
|
|
482
|
+
mockFetch.mockResolvedValue(
|
|
483
|
+
new Response('No container instance available', { status: 503 })
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const promise = client.testRequest('/api/test');
|
|
487
|
+
|
|
488
|
+
// Fast-forward past retry budget (120s)
|
|
489
|
+
await vi.advanceTimersByTimeAsync(125_000);
|
|
490
|
+
|
|
491
|
+
// Should eventually give up and throw the 503 error
|
|
492
|
+
await expect(promise).rejects.toThrow();
|
|
493
|
+
|
|
494
|
+
vi.useRealTimers();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should use exponential backoff: 3s, 6s, 12s, 24s, 30s', async () => {
|
|
498
|
+
vi.useFakeTimers();
|
|
499
|
+
const delays: number[] = [];
|
|
500
|
+
let callCount = 0;
|
|
501
|
+
|
|
502
|
+
mockFetch.mockImplementation(async () => {
|
|
503
|
+
delays.push(Date.now());
|
|
504
|
+
callCount++;
|
|
505
|
+
// After 5 attempts, return success to avoid timeout
|
|
506
|
+
if (callCount >= 5) {
|
|
507
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
508
|
+
status: 200
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return new Response('No container instance available', { status: 503 });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const promise = client.testRequest('/api/test');
|
|
515
|
+
|
|
516
|
+
// Advance time to allow all retries
|
|
517
|
+
await vi.advanceTimersByTimeAsync(80_000);
|
|
518
|
+
|
|
519
|
+
await promise;
|
|
520
|
+
|
|
521
|
+
// Check delays between attempts (approximately)
|
|
522
|
+
// Attempt 1 at 0ms, Attempt 2 at ~3000ms, Attempt 3 at ~9000ms, etc.
|
|
523
|
+
expect(delays.length).toBeGreaterThanOrEqual(4);
|
|
524
|
+
|
|
525
|
+
vi.useRealTimers();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('should retry multiple transient errors in sequence', async () => {
|
|
529
|
+
vi.useFakeTimers();
|
|
530
|
+
|
|
531
|
+
mockFetch
|
|
532
|
+
.mockResolvedValueOnce(
|
|
533
|
+
new Response('No container instance available', { status: 503 })
|
|
534
|
+
)
|
|
535
|
+
.mockResolvedValueOnce(
|
|
536
|
+
new Response('Container port not found', { status: 500 })
|
|
537
|
+
)
|
|
538
|
+
.mockResolvedValueOnce(
|
|
539
|
+
new Response('The container is not listening', { status: 500 })
|
|
540
|
+
)
|
|
541
|
+
.mockResolvedValueOnce(
|
|
542
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const promise = client.testRequest('/api/test');
|
|
546
|
+
|
|
547
|
+
// Advance time to allow all retries (3s + 6s + 12s = 21s)
|
|
548
|
+
await vi.advanceTimersByTimeAsync(25_000);
|
|
549
|
+
|
|
550
|
+
const result = await promise;
|
|
551
|
+
|
|
552
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
553
|
+
expect(result.success).toBe(true);
|
|
554
|
+
|
|
555
|
+
vi.useRealTimers();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('should handle case-insensitive error matching', async () => {
|
|
559
|
+
vi.useFakeTimers();
|
|
560
|
+
|
|
561
|
+
mockFetch
|
|
562
|
+
.mockResolvedValueOnce(
|
|
563
|
+
new Response('ERROR: CONTAINER PORT NOT FOUND', { status: 500 })
|
|
564
|
+
)
|
|
565
|
+
.mockResolvedValueOnce(
|
|
566
|
+
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const promise = client.testRequest('/api/test');
|
|
570
|
+
|
|
571
|
+
// Advance time for first retry (3s)
|
|
572
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
573
|
+
|
|
574
|
+
const result = await promise;
|
|
575
|
+
|
|
576
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
577
|
+
expect(result.success).toBe(true);
|
|
578
|
+
|
|
579
|
+
vi.useRealTimers();
|
|
580
|
+
});
|
|
581
|
+
});
|
|
364
582
|
});
|
|
@@ -44,7 +44,10 @@ describe('getSandbox', () => {
|
|
|
44
44
|
const sandbox = getSandbox(mockNamespace, 'test-sandbox');
|
|
45
45
|
|
|
46
46
|
expect(sandbox).toBeDefined();
|
|
47
|
-
expect(sandbox.setSandboxName).toHaveBeenCalledWith(
|
|
47
|
+
expect(sandbox.setSandboxName).toHaveBeenCalledWith(
|
|
48
|
+
'test-sandbox',
|
|
49
|
+
undefined
|
|
50
|
+
);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it('should apply sleepAfter option when provided as string', () => {
|
|
@@ -146,4 +149,24 @@ describe('getSandbox', () => {
|
|
|
146
149
|
expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
|
|
147
150
|
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
|
|
148
151
|
});
|
|
152
|
+
|
|
153
|
+
it('should preserve sandbox ID case by default', () => {
|
|
154
|
+
const mockNamespace = {} as any;
|
|
155
|
+
getSandbox(mockNamespace, 'MyProject-ABC123');
|
|
156
|
+
|
|
157
|
+
expect(mockGetContainer).toHaveBeenCalledWith(
|
|
158
|
+
mockNamespace,
|
|
159
|
+
'MyProject-ABC123'
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should normalize sandbox ID to lowercase when normalizeId option is true', () => {
|
|
164
|
+
const mockNamespace = {} as any;
|
|
165
|
+
getSandbox(mockNamespace, 'MyProject-ABC123', { normalizeId: true });
|
|
166
|
+
|
|
167
|
+
expect(mockGetContainer).toHaveBeenCalledWith(
|
|
168
|
+
mockNamespace,
|
|
169
|
+
'myproject-abc123'
|
|
170
|
+
);
|
|
171
|
+
});
|
|
149
172
|
});
|
package/tests/sandbox.test.ts
CHANGED
|
@@ -42,6 +42,10 @@ vi.mock('@cloudflare/containers', () => {
|
|
|
42
42
|
// Mock implementation for HTTP path
|
|
43
43
|
return new Response('Mock Container HTTP fetch');
|
|
44
44
|
}
|
|
45
|
+
async getState() {
|
|
46
|
+
// Mock implementation - return healthy state
|
|
47
|
+
return { status: 'healthy' };
|
|
48
|
+
}
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
return {
|
|
@@ -736,4 +740,121 @@ describe('Sandbox - Automatic Session Management', () => {
|
|
|
736
740
|
expect(result.sessionId).toBe('custom-session');
|
|
737
741
|
});
|
|
738
742
|
});
|
|
743
|
+
|
|
744
|
+
describe('constructPreviewUrl validation', () => {
|
|
745
|
+
it('should throw clear error for ID with uppercase letters without normalizeId', async () => {
|
|
746
|
+
await sandbox.setSandboxName('MyProject-123', false);
|
|
747
|
+
|
|
748
|
+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
749
|
+
port: 8080,
|
|
750
|
+
token: 'test-token-1234',
|
|
751
|
+
previewUrl: ''
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
await expect(
|
|
755
|
+
sandbox.exposePort(8080, { hostname: 'example.com' })
|
|
756
|
+
).rejects.toThrow(/Preview URLs require lowercase sandbox IDs/);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('should construct valid URL for lowercase ID', async () => {
|
|
760
|
+
await sandbox.setSandboxName('my-project', false);
|
|
761
|
+
|
|
762
|
+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
763
|
+
port: 8080,
|
|
764
|
+
token: 'mock-token',
|
|
765
|
+
previewUrl: ''
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const result = await sandbox.exposePort(8080, {
|
|
769
|
+
hostname: 'example.com'
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(result.url).toMatch(
|
|
773
|
+
/^https:\/\/8080-my-project-[a-z0-9_-]{16}\.example\.com\/?$/
|
|
774
|
+
);
|
|
775
|
+
expect(result.port).toBe(8080);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should construct valid URL with normalized ID', async () => {
|
|
779
|
+
await sandbox.setSandboxName('myproject-123', true);
|
|
780
|
+
|
|
781
|
+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
782
|
+
port: 4000,
|
|
783
|
+
token: 'mock-token',
|
|
784
|
+
previewUrl: ''
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const result = await sandbox.exposePort(4000, { hostname: 'my-app.dev' });
|
|
788
|
+
|
|
789
|
+
expect(result.url).toMatch(
|
|
790
|
+
/^https:\/\/4000-myproject-123-[a-z0-9_-]{16}\.my-app\.dev\/?$/
|
|
791
|
+
);
|
|
792
|
+
expect(result.port).toBe(4000);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('should construct valid localhost URL', async () => {
|
|
796
|
+
await sandbox.setSandboxName('test-sandbox', false);
|
|
797
|
+
|
|
798
|
+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
799
|
+
port: 8080,
|
|
800
|
+
token: 'mock-token',
|
|
801
|
+
previewUrl: ''
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const result = await sandbox.exposePort(8080, {
|
|
805
|
+
hostname: 'localhost:3000'
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
expect(result.url).toMatch(
|
|
809
|
+
/^http:\/\/8080-test-sandbox-[a-z0-9_-]{16}\.localhost:3000\/?$/
|
|
810
|
+
);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('should include helpful guidance in error message', async () => {
|
|
814
|
+
await sandbox.setSandboxName('MyProject-ABC', false);
|
|
815
|
+
|
|
816
|
+
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
817
|
+
port: 8080,
|
|
818
|
+
token: 'test-token-1234',
|
|
819
|
+
previewUrl: ''
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
await expect(
|
|
823
|
+
sandbox.exposePort(8080, { hostname: 'example.com' })
|
|
824
|
+
).rejects.toThrow(
|
|
825
|
+
/getSandbox\(ns, "MyProject-ABC", \{ normalizeId: true \}\)/
|
|
826
|
+
);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
describe('timeout configuration validation', () => {
|
|
831
|
+
it('should reject invalid timeout values', async () => {
|
|
832
|
+
// NaN, Infinity, and out-of-range values should all be rejected
|
|
833
|
+
await expect(
|
|
834
|
+
sandbox.setContainerTimeouts({ instanceGetTimeoutMS: NaN })
|
|
835
|
+
).rejects.toThrow();
|
|
836
|
+
|
|
837
|
+
await expect(
|
|
838
|
+
sandbox.setContainerTimeouts({ portReadyTimeoutMS: Infinity })
|
|
839
|
+
).rejects.toThrow();
|
|
840
|
+
|
|
841
|
+
await expect(
|
|
842
|
+
sandbox.setContainerTimeouts({ instanceGetTimeoutMS: -1 })
|
|
843
|
+
).rejects.toThrow();
|
|
844
|
+
|
|
845
|
+
await expect(
|
|
846
|
+
sandbox.setContainerTimeouts({ waitIntervalMS: 999_999 })
|
|
847
|
+
).rejects.toThrow();
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('should accept valid timeout values', async () => {
|
|
851
|
+
await expect(
|
|
852
|
+
sandbox.setContainerTimeouts({
|
|
853
|
+
instanceGetTimeoutMS: 30_000,
|
|
854
|
+
portReadyTimeoutMS: 90_000,
|
|
855
|
+
waitIntervalMS: 1000
|
|
856
|
+
})
|
|
857
|
+
).resolves.toBeUndefined();
|
|
858
|
+
});
|
|
859
|
+
});
|
|
739
860
|
});
|