@cloudflare/sandbox 0.0.0-104f455 → 0.0.0-153e416

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.
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  DeleteFileResult,
3
+ FileExistsResult,
3
4
  ListFilesOptions,
4
5
  ListFilesResult,
5
6
  MkdirResult,
@@ -266,4 +267,29 @@ export class FileClient extends BaseHttpClient {
266
267
  throw error;
267
268
  }
268
269
  }
270
+
271
+ /**
272
+ * Check if a file or directory exists
273
+ * @param path - Path to check
274
+ * @param sessionId - The session ID for this operation
275
+ */
276
+ async exists(
277
+ path: string,
278
+ sessionId: string
279
+ ): Promise<FileExistsResult> {
280
+ try {
281
+ const data = {
282
+ path,
283
+ sessionId,
284
+ };
285
+
286
+ const response = await this.post<FileExistsResult>('/api/exists', data);
287
+
288
+ this.logSuccess('Path existence checked', `${path} (exists: ${response.exists})`);
289
+ return response;
290
+ } catch (error) {
291
+ this.logError('exists', error);
292
+ throw error;
293
+ }
294
+ }
269
295
  }
@@ -59,5 +59,6 @@ export type {
59
59
  export type {
60
60
  CommandsResponse,
61
61
  PingResponse,
62
+ VersionResponse,
62
63
  } from './utility-client';
63
64
  export { UtilityClient } from './utility-client';
@@ -17,6 +17,13 @@ export interface CommandsResponse extends BaseApiResponse {
17
17
  count: number;
18
18
  }
19
19
 
20
+ /**
21
+ * Response interface for getting container version
22
+ */
23
+ export interface VersionResponse extends BaseApiResponse {
24
+ version: string;
25
+ }
26
+
20
27
  /**
21
28
  * Request interface for creating sessions
22
29
  */
@@ -91,4 +98,22 @@ export class UtilityClient extends BaseHttpClient {
91
98
  throw error;
92
99
  }
93
100
  }
101
+
102
+ /**
103
+ * Get the container version
104
+ * Returns the version embedded in the Docker image during build
105
+ */
106
+ async getVersion(): Promise<string> {
107
+ try {
108
+ const response = await this.get<VersionResponse>('/api/version');
109
+
110
+ this.logSuccess('Version retrieved', response.version);
111
+ return response.version;
112
+ } catch (error) {
113
+ // If version endpoint doesn't exist (old container), return 'unknown'
114
+ // This allows for backward compatibility
115
+ this.logger.debug('Failed to get container version (may be old container)', { error });
116
+ return 'unknown';
117
+ }
118
+ }
94
119
  }
@@ -1,4 +1,5 @@
1
1
  import { createLogger, type LogContext, TraceContext } from "@repo/shared";
2
+ import { switchPort } from "@cloudflare/containers";
2
3
  import { getSandbox, type Sandbox } from "./sandbox";
3
4
  import {
4
5
  sanitizeSandboxId,
@@ -70,6 +71,14 @@ export async function proxyToSandbox<E extends SandboxEnv>(
70
71
  }
71
72
  }
72
73
 
74
+ // Detect WebSocket upgrade request
75
+ const upgradeHeader = request.headers.get('Upgrade');
76
+ if (upgradeHeader?.toLowerCase() === 'websocket') {
77
+ // WebSocket path: Must use fetch() not containerFetch()
78
+ // This bypasses JSRPC serialization boundary which cannot handle WebSocket upgrades
79
+ return await sandbox.fetch(switchPort(request, port));
80
+ }
81
+
73
82
  // Build proxy request with proper headers
74
83
  let proxyUrl: string;
75
84
 
@@ -96,7 +105,7 @@ export async function proxyToSandbox<E extends SandboxEnv>(
96
105
  duplex: 'half',
97
106
  });
98
107
 
99
- return sandbox.containerFetch(proxyRequest, port);
108
+ return await sandbox.containerFetch(proxyRequest, port);
100
109
  } catch (error) {
101
110
  logger.error('Proxy routing error', error instanceof Error ? error : new Error(String(error)));
102
111
  return new Response('Proxy routing error', { status: 500 });
@@ -105,7 +114,8 @@ export async function proxyToSandbox<E extends SandboxEnv>(
105
114
 
106
115
  function extractSandboxRoute(url: URL): RouteInfo | null {
107
116
  // Parse subdomain pattern: port-sandboxId-token.domain (tokens mandatory)
108
- const subdomainMatch = url.hostname.match(/^(\d{4,5})-([^.-][^.]*[^.-]|[^.-])-([a-zA-Z0-9_-]{12,20})\.(.+)$/);
117
+ // Token is always exactly 16 chars (generated by generatePortToken)
118
+ const subdomainMatch = url.hostname.match(/^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]{16})\.(.+)$/);
109
119
 
110
120
  if (!subdomainMatch) {
111
121
  return null;
package/src/sandbox.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  ProcessOptions,
14
14
  ProcessStatus,
15
15
  RunCodeOptions,
16
+ SandboxOptions,
16
17
  SessionOptions,
17
18
  StreamOptions
18
19
  } from "@repo/shared";
@@ -28,25 +29,32 @@ import {
28
29
  validatePort
29
30
  } from "./security";
30
31
  import { parseSSEStream } from "./sse-parser";
32
+ import { SDK_VERSION } from "./version";
31
33
 
32
- export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string, options?: {
33
- baseUrl: string
34
- }) {
34
+ export function getSandbox(
35
+ ns: DurableObjectNamespace<Sandbox>,
36
+ id: string,
37
+ options?: SandboxOptions
38
+ ) {
35
39
  const stub = getContainer(ns, id);
36
40
 
37
41
  // Store the name on first access
38
42
  stub.setSandboxName?.(id);
39
43
 
40
- if(options?.baseUrl) {
44
+ if (options?.baseUrl) {
41
45
  stub.setBaseUrl(options.baseUrl);
42
46
  }
43
47
 
48
+ if (options?.sleepAfter !== undefined) {
49
+ stub.setSleepAfter(options.sleepAfter);
50
+ }
51
+
44
52
  return stub;
45
53
  }
46
54
 
47
55
  export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
48
56
  defaultPort = 3000; // Default port for the container's Bun server
49
- sleepAfter = "3m"; // Sleep the sandbox if no requests are made in this timeframe
57
+ sleepAfter: string | number = "10m"; // Sleep the sandbox if no requests are made in this timeframe
50
58
 
51
59
  client: SandboxClient;
52
60
  private codeInterpreter: CodeInterpreter;
@@ -118,6 +126,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
118
126
  }
119
127
  }
120
128
 
129
+ // RPC method to set the sleep timeout
130
+ async setSleepAfter(sleepAfter: string | number): Promise<void> {
131
+ this.sleepAfter = sleepAfter;
132
+ }
133
+
121
134
  // RPC method to set environment variables
122
135
  async setEnvVars(envVars: Record<string, string>): Promise<void> {
123
136
  // Update local state for new sessions
@@ -149,6 +162,54 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
149
162
 
150
163
  override onStart() {
151
164
  this.logger.debug('Sandbox started');
165
+
166
+ // Check version compatibility asynchronously (don't block startup)
167
+ this.checkVersionCompatibility().catch(error => {
168
+ this.logger.error('Version compatibility check failed', error instanceof Error ? error : new Error(String(error)));
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Check if the container version matches the SDK version
174
+ * Logs a warning if there's a mismatch
175
+ */
176
+ private async checkVersionCompatibility(): Promise<void> {
177
+ try {
178
+ // Get the SDK version (imported from version.ts)
179
+ const sdkVersion = SDK_VERSION;
180
+
181
+ // Get container version
182
+ const containerVersion = await this.client.utils.getVersion();
183
+
184
+ // If container version is unknown, it's likely an old container without the endpoint
185
+ if (containerVersion === 'unknown') {
186
+ this.logger.warn(
187
+ 'Container version check: Container version could not be determined. ' +
188
+ 'This may indicate an outdated container image. ' +
189
+ 'Please update your container to match SDK version ' + sdkVersion
190
+ );
191
+ return;
192
+ }
193
+
194
+ // Check if versions match
195
+ if (containerVersion !== sdkVersion) {
196
+ const message =
197
+ `Version mismatch detected! SDK version (${sdkVersion}) does not match ` +
198
+ `container version (${containerVersion}). This may cause compatibility issues. ` +
199
+ `Please update your container image to version ${sdkVersion}`;
200
+
201
+ // Log warning - we can't reliably detect dev vs prod environment in Durable Objects
202
+ // so we always use warning level as requested by the user
203
+ this.logger.warn(message);
204
+ } else {
205
+ this.logger.debug('Version check passed', { sdkVersion, containerVersion });
206
+ }
207
+ } catch (error) {
208
+ // Don't fail the sandbox initialization if version check fails
209
+ this.logger.debug('Version compatibility check encountered an error', {
210
+ error: error instanceof Error ? error.message : String(error)
211
+ });
212
+ }
152
213
  }
153
214
 
154
215
  override onStop() {
@@ -177,7 +238,17 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
177
238
  await this.ctx.storage.put('sandboxName', name);
178
239
  }
179
240
 
180
- // Determine which port to route to
241
+ // Detect WebSocket upgrade request
242
+ const upgradeHeader = request.headers.get('Upgrade');
243
+ const isWebSocket = upgradeHeader?.toLowerCase() === 'websocket';
244
+
245
+ if (isWebSocket) {
246
+ // WebSocket path: Let parent Container class handle WebSocket proxying
247
+ // This bypasses containerFetch() which uses JSRPC and cannot handle WebSocket upgrades
248
+ return await super.fetch(request);
249
+ }
250
+
251
+ // Non-WebSocket: Use existing port determination and HTTP routing logic
181
252
  const port = this.determinePort(url);
182
253
 
183
254
  // Route to the appropriate port
@@ -223,7 +294,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
223
294
  this.logger.debug('Default session initialized', { sessionId });
224
295
  } catch (error: any) {
225
296
  // If session already exists (e.g., after hot reload), reuse it
226
- if (error?.message?.includes('already exists') || error?.message?.includes('Session')) {
297
+ if (error?.message?.includes('already exists')) {
227
298
  this.logger.debug('Reusing existing session after reload', { sessionId });
228
299
  this.defaultSession = sessionId;
229
300
  // Persist to storage in case it wasn't saved before
@@ -636,6 +707,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
636
707
  return this.client.files.listFiles(path, session, options);
637
708
  }
638
709
 
710
+ async exists(path: string, sessionId?: string) {
711
+ const session = sessionId ?? await this.ensureDefaultSession();
712
+ return this.client.files.exists(path, session);
713
+ }
714
+
639
715
  async exposePort(port: number, options: { name?: string; hostname: string }) {
640
716
  // Check if hostname is workers.dev domain (doesn't support wildcard subdomains)
641
717
  if (options.hostname.endsWith('.workers.dev')) {
@@ -873,6 +949,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
873
949
  renameFile: (oldPath, newPath) => this.renameFile(oldPath, newPath, sessionId),
874
950
  moveFile: (sourcePath, destPath) => this.moveFile(sourcePath, destPath, sessionId),
875
951
  listFiles: (path, options) => this.client.files.listFiles(path, sessionId, options),
952
+ exists: (path) => this.exists(path, sessionId),
876
953
 
877
954
  // Git operations
878
955
  gitCheckout: (repoUrl, options) => this.gitCheckout(repoUrl, { ...options, sessionId }),
package/src/version.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * SDK version - automatically synchronized with package.json by Changesets
3
+ * This file is auto-updated by .github/changeset-version.ts during releases
4
+ * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5
+ */
6
+ export const SDK_VERSION = '0.4.11';
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  DeleteFileResult,
3
+ FileExistsResult,
3
4
  ListFilesResult,
4
5
  MkdirResult,
5
6
  MoveFileResult,
@@ -584,6 +585,81 @@ database:
584
585
  });
585
586
  });
586
587
 
588
+ describe('exists', () => {
589
+ it('should return true when file exists', async () => {
590
+ const mockResponse: FileExistsResult = {
591
+ success: true,
592
+ path: '/workspace/test.txt',
593
+ exists: true,
594
+ timestamp: '2023-01-01T00:00:00Z',
595
+ };
596
+
597
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
598
+
599
+ const result = await client.exists('/workspace/test.txt', 'session-exists');
600
+
601
+ expect(result.success).toBe(true);
602
+ expect(result.exists).toBe(true);
603
+ expect(result.path).toBe('/workspace/test.txt');
604
+ });
605
+
606
+ it('should return false when file does not exist', async () => {
607
+ const mockResponse: FileExistsResult = {
608
+ success: true,
609
+ path: '/workspace/nonexistent.txt',
610
+ exists: false,
611
+ timestamp: '2023-01-01T00:00:00Z',
612
+ };
613
+
614
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
615
+
616
+ const result = await client.exists('/workspace/nonexistent.txt', 'session-exists');
617
+
618
+ expect(result.success).toBe(true);
619
+ expect(result.exists).toBe(false);
620
+ });
621
+
622
+ it('should return true when directory exists', async () => {
623
+ const mockResponse: FileExistsResult = {
624
+ success: true,
625
+ path: '/workspace/some-dir',
626
+ exists: true,
627
+ timestamp: '2023-01-01T00:00:00Z',
628
+ };
629
+
630
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
631
+
632
+ const result = await client.exists('/workspace/some-dir', 'session-exists');
633
+
634
+ expect(result.success).toBe(true);
635
+ expect(result.exists).toBe(true);
636
+ });
637
+
638
+ it('should send correct request payload', async () => {
639
+ const mockResponse: FileExistsResult = {
640
+ success: true,
641
+ path: '/test/path',
642
+ exists: true,
643
+ timestamp: '2023-01-01T00:00:00Z',
644
+ };
645
+
646
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
647
+
648
+ await client.exists('/test/path', 'session-test');
649
+
650
+ expect(mockFetch).toHaveBeenCalledWith(
651
+ expect.stringContaining('/api/exists'),
652
+ expect.objectContaining({
653
+ method: 'POST',
654
+ body: JSON.stringify({
655
+ path: '/test/path',
656
+ sessionId: 'session-test',
657
+ })
658
+ })
659
+ );
660
+ });
661
+ });
662
+
587
663
  describe('error handling', () => {
588
664
  it('should handle network failures gracefully', async () => {
589
665
  mockFetch.mockRejectedValue(new Error('Network connection failed'));
@@ -0,0 +1,110 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getSandbox } from '../src/sandbox';
3
+
4
+ // Mock the Container module
5
+ vi.mock('@cloudflare/containers', () => ({
6
+ Container: class Container {
7
+ ctx: any;
8
+ env: any;
9
+ sleepAfter: string | number = '10m';
10
+ constructor(ctx: any, env: any) {
11
+ this.ctx = ctx;
12
+ this.env = env;
13
+ }
14
+ },
15
+ getContainer: vi.fn(),
16
+ }));
17
+
18
+ describe('getSandbox', () => {
19
+ let mockStub: any;
20
+ let mockGetContainer: any;
21
+
22
+ beforeEach(async () => {
23
+ vi.clearAllMocks();
24
+
25
+ // Create a fresh mock stub for each test
26
+ mockStub = {
27
+ sleepAfter: '10m',
28
+ setSandboxName: vi.fn(),
29
+ setBaseUrl: vi.fn(),
30
+ setSleepAfter: vi.fn((value: string | number) => {
31
+ mockStub.sleepAfter = value;
32
+ }),
33
+ };
34
+
35
+ // Mock getContainer to return our stub
36
+ const containers = await import('@cloudflare/containers');
37
+ mockGetContainer = vi.mocked(containers.getContainer);
38
+ mockGetContainer.mockReturnValue(mockStub);
39
+ });
40
+
41
+ it('should create a sandbox instance with default sleepAfter', () => {
42
+ const mockNamespace = {} as any;
43
+ const sandbox = getSandbox(mockNamespace, 'test-sandbox');
44
+
45
+ expect(sandbox).toBeDefined();
46
+ expect(sandbox.setSandboxName).toHaveBeenCalledWith('test-sandbox');
47
+ });
48
+
49
+ it('should apply sleepAfter option when provided as string', () => {
50
+ const mockNamespace = {} as any;
51
+ const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
52
+ sleepAfter: '5m',
53
+ });
54
+
55
+ expect(sandbox.sleepAfter).toBe('5m');
56
+ });
57
+
58
+ it('should apply sleepAfter option when provided as number', () => {
59
+ const mockNamespace = {} as any;
60
+ const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
61
+ sleepAfter: 300, // 5 minutes in seconds
62
+ });
63
+
64
+ expect(sandbox.sleepAfter).toBe(300);
65
+ });
66
+
67
+ it('should apply baseUrl option when provided', () => {
68
+ const mockNamespace = {} as any;
69
+ const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
70
+ baseUrl: 'https://example.com',
71
+ });
72
+
73
+ expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
74
+ });
75
+
76
+ it('should apply both sleepAfter and baseUrl options together', () => {
77
+ const mockNamespace = {} as any;
78
+ const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
79
+ sleepAfter: '10m',
80
+ baseUrl: 'https://example.com',
81
+ });
82
+
83
+ expect(sandbox.sleepAfter).toBe('10m');
84
+ expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
85
+ });
86
+
87
+ it('should not apply sleepAfter when not provided', () => {
88
+ const mockNamespace = {} as any;
89
+ const sandbox = getSandbox(mockNamespace, 'test-sandbox');
90
+
91
+ // Should remain default value from Container
92
+ expect(sandbox.sleepAfter).toBe('10m');
93
+ });
94
+
95
+ it('should accept various time string formats for sleepAfter', () => {
96
+ const mockNamespace = {} as any;
97
+ const testCases = ['30s', '1m', '10m', '1h', '2h'];
98
+
99
+ for (const timeString of testCases) {
100
+ // Reset the mock stub for each iteration
101
+ mockStub.sleepAfter = '3m';
102
+
103
+ const sandbox = getSandbox(mockNamespace, `test-sandbox-${timeString}`, {
104
+ sleepAfter: timeString,
105
+ });
106
+
107
+ expect(sandbox.sleepAfter).toBe(timeString);
108
+ }
109
+ });
110
+ });