@cloudflare/sandbox 0.4.17 → 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.
@@ -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
@@ -3,4 +3,4 @@
3
3
  * This file is auto-updated by .github/changeset-version.ts during releases
4
4
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5
5
  */
6
- export const SDK_VERSION = '0.4.17';
6
+ export const SDK_VERSION = '0.5.1';
@@ -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('test-sandbox');
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
  });
@@ -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
  });