@cloudflare/sandbox 0.4.8 → 0.4.10
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 +12 -12
- package/CHANGELOG.md +21 -0
- package/dist/chunk-E3RB3JOS.js +7 -0
- package/dist/{chunk-3RA7RDAX.js.map → chunk-E3RB3JOS.js.map} +1 -1
- package/dist/{chunk-MA44U7QN.js → chunk-I6PJN47O.js} +39 -3
- package/dist/chunk-I6PJN47O.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/interpreter.d.ts +1 -1
- package/dist/request-handler.d.ts +1 -1
- package/dist/request-handler.js +2 -2
- package/dist/{sandbox-DMlNr93l.d.ts → sandbox-DWQVgVTY.d.ts} +8 -1
- package/dist/sandbox.d.ts +1 -1
- package/dist/sandbox.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/clients/file-client.ts +26 -0
- package/src/request-handler.ts +10 -1
- package/src/sandbox.ts +17 -1
- package/src/version.ts +1 -1
- package/tests/file-client.test.ts +76 -0
- package/tests/request-handler.test.ts +240 -0
- package/tests/sandbox.test.ts +94 -5
- package/dist/chunk-3RA7RDAX.js +0 -7
- package/dist/chunk-MA44U7QN.js.map +0 -1
package/dist/interpreter.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CreateContextOptions, CodeContext, RunCodeOptions, Execution } from '@repo/shared';
|
|
2
|
-
import { b as Sandbox } from './sandbox-
|
|
2
|
+
import { b as Sandbox } from './sandbox-DWQVgVTY.js';
|
|
3
3
|
import 'cloudflare:workers';
|
|
4
4
|
import '@cloudflare/containers';
|
|
5
5
|
|
package/dist/request-handler.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isLocalhostPattern,
|
|
3
3
|
proxyToSandbox
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-I6PJN47O.js";
|
|
5
5
|
import "./chunk-JXZMAU2C.js";
|
|
6
6
|
import "./chunk-Z532A7QC.js";
|
|
7
7
|
import "./chunk-EKSWCBCA.js";
|
|
8
|
-
import "./chunk-
|
|
8
|
+
import "./chunk-E3RB3JOS.js";
|
|
9
9
|
export {
|
|
10
10
|
isLocalhostPattern,
|
|
11
11
|
proxyToSandbox
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _repo_shared from '@repo/shared';
|
|
2
|
-
import { Logger, MkdirResult, WriteFileResult, ReadFileResult, DeleteFileResult, RenameFileResult, MoveFileResult, ListFilesOptions, ListFilesResult, GitCheckoutResult, CreateContextOptions, CodeContext, OutputMessage, Result, ExecutionError, PortExposeResult, PortCloseResult, PortListResult, ProcessStartResult, ProcessListResult, ProcessInfoResult, ProcessKillResult, ProcessCleanupResult, ProcessLogsResult, ISandbox, ExecOptions, ExecResult, ProcessOptions, Process, StreamOptions, SessionOptions, ExecutionSession, RunCodeOptions, ExecutionResult, SandboxOptions } from '@repo/shared';
|
|
2
|
+
import { Logger, MkdirResult, WriteFileResult, ReadFileResult, DeleteFileResult, RenameFileResult, MoveFileResult, ListFilesOptions, ListFilesResult, FileExistsResult, GitCheckoutResult, CreateContextOptions, CodeContext, OutputMessage, Result, ExecutionError, PortExposeResult, PortCloseResult, PortListResult, ProcessStartResult, ProcessListResult, ProcessInfoResult, ProcessKillResult, ProcessCleanupResult, ProcessLogsResult, ISandbox, ExecOptions, ExecResult, ProcessOptions, Process, StreamOptions, SessionOptions, ExecutionSession, RunCodeOptions, ExecutionResult, SandboxOptions } from '@repo/shared';
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers';
|
|
4
4
|
import { Container } from '@cloudflare/containers';
|
|
5
5
|
|
|
@@ -239,6 +239,12 @@ declare class FileClient extends BaseHttpClient {
|
|
|
239
239
|
* @param options - Optional settings (recursive, includeHidden)
|
|
240
240
|
*/
|
|
241
241
|
listFiles(path: string, sessionId: string, options?: ListFilesOptions): Promise<ListFilesResult>;
|
|
242
|
+
/**
|
|
243
|
+
* Check if a file or directory exists
|
|
244
|
+
* @param path - Path to check
|
|
245
|
+
* @param sessionId - The session ID for this operation
|
|
246
|
+
*/
|
|
247
|
+
exists(path: string, sessionId: string): Promise<FileExistsResult>;
|
|
242
248
|
}
|
|
243
249
|
|
|
244
250
|
/**
|
|
@@ -546,6 +552,7 @@ declare class Sandbox<Env = unknown> extends Container<Env> implements ISandbox
|
|
|
546
552
|
recursive?: boolean;
|
|
547
553
|
includeHidden?: boolean;
|
|
548
554
|
}): Promise<_repo_shared.ListFilesResult>;
|
|
555
|
+
exists(path: string, sessionId?: string): Promise<_repo_shared.FileExistsResult>;
|
|
549
556
|
exposePort(port: number, options: {
|
|
550
557
|
name?: string;
|
|
551
558
|
hostname: string;
|
package/dist/sandbox.d.ts
CHANGED
package/dist/sandbox.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Sandbox,
|
|
3
3
|
getSandbox
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-I6PJN47O.js";
|
|
5
5
|
import "./chunk-JXZMAU2C.js";
|
|
6
6
|
import "./chunk-Z532A7QC.js";
|
|
7
7
|
import "./chunk-EKSWCBCA.js";
|
|
8
|
-
import "./chunk-
|
|
8
|
+
import "./chunk-E3RB3JOS.js";
|
|
9
9
|
export {
|
|
10
10
|
Sandbox,
|
|
11
11
|
getSandbox
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/sandbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/cloudflare/sandbox-sdk"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version .",
|
|
28
28
|
"docker:publish": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox:$npm_package_version --push .",
|
|
29
29
|
"docker:publish:beta": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox:$npm_package_version-beta --push .",
|
|
30
|
-
"test": "vitest run --config vitest.config.ts",
|
|
30
|
+
"test": "vitest run --config vitest.config.ts \"$@\"",
|
|
31
31
|
"test:e2e": "cd ../.. && vitest run --config vitest.e2e.config.ts \"$@\""
|
|
32
32
|
},
|
|
33
33
|
"exports": {
|
|
@@ -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
|
}
|
package/src/request-handler.ts
CHANGED
|
@@ -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 });
|
package/src/sandbox.ts
CHANGED
|
@@ -238,7 +238,17 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
238
238
|
await this.ctx.storage.put('sandboxName', name);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
//
|
|
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
|
|
242
252
|
const port = this.determinePort(url);
|
|
243
253
|
|
|
244
254
|
// Route to the appropriate port
|
|
@@ -697,6 +707,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
697
707
|
return this.client.files.listFiles(path, session, options);
|
|
698
708
|
}
|
|
699
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
|
+
|
|
700
715
|
async exposePort(port: number, options: { name?: string; hostname: string }) {
|
|
701
716
|
// Check if hostname is workers.dev domain (doesn't support wildcard subdomains)
|
|
702
717
|
if (options.hostname.endsWith('.workers.dev')) {
|
|
@@ -934,6 +949,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
934
949
|
renameFile: (oldPath, newPath) => this.renameFile(oldPath, newPath, sessionId),
|
|
935
950
|
moveFile: (sourcePath, destPath) => this.moveFile(sourcePath, destPath, sessionId),
|
|
936
951
|
listFiles: (path, options) => this.client.files.listFiles(path, sessionId, options),
|
|
952
|
+
exists: (path) => this.exists(path, sessionId),
|
|
937
953
|
|
|
938
954
|
// Git operations
|
|
939
955
|
gitCheckout: (repoUrl, options) => this.gitCheckout(repoUrl, { ...options, sessionId }),
|
package/src/version.ts
CHANGED
|
@@ -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,240 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { proxyToSandbox, type SandboxEnv } from '../src/request-handler';
|
|
3
|
+
import type { Sandbox } from '../src/sandbox';
|
|
4
|
+
|
|
5
|
+
// Mock getSandbox from sandbox.ts
|
|
6
|
+
vi.mock('../src/sandbox', () => {
|
|
7
|
+
const mockFn = vi.fn();
|
|
8
|
+
return {
|
|
9
|
+
getSandbox: mockFn,
|
|
10
|
+
Sandbox: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Import the mock after vi.mock is set up
|
|
15
|
+
import { getSandbox } from '../src/sandbox';
|
|
16
|
+
|
|
17
|
+
describe('proxyToSandbox - WebSocket Support', () => {
|
|
18
|
+
let mockSandbox: Partial<Sandbox>;
|
|
19
|
+
let mockEnv: SandboxEnv;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
|
|
24
|
+
// Mock Sandbox with necessary methods
|
|
25
|
+
mockSandbox = {
|
|
26
|
+
validatePortToken: vi.fn().mockResolvedValue(true),
|
|
27
|
+
fetch: vi.fn().mockResolvedValue(new Response('WebSocket response')),
|
|
28
|
+
containerFetch: vi.fn().mockResolvedValue(new Response('HTTP response')),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
mockEnv = {
|
|
32
|
+
Sandbox: {} as any,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
vi.mocked(getSandbox).mockReturnValue(mockSandbox as Sandbox);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('WebSocket detection and routing', () => {
|
|
39
|
+
it('should detect WebSocket upgrade header (case-insensitive)', async () => {
|
|
40
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/ws', {
|
|
41
|
+
headers: {
|
|
42
|
+
'Upgrade': 'websocket',
|
|
43
|
+
'Connection': 'Upgrade',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await proxyToSandbox(request, mockEnv);
|
|
48
|
+
|
|
49
|
+
// Should route through fetch() for WebSocket
|
|
50
|
+
expect(mockSandbox.fetch).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(mockSandbox.containerFetch).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should set cf-container-target-port header for WebSocket', async () => {
|
|
55
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/ws', {
|
|
56
|
+
headers: {
|
|
57
|
+
'Upgrade': 'websocket',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await proxyToSandbox(request, mockEnv);
|
|
62
|
+
|
|
63
|
+
expect(mockSandbox.fetch).toHaveBeenCalledTimes(1);
|
|
64
|
+
const fetchCall = vi.mocked(mockSandbox.fetch as any).mock.calls[0][0] as Request;
|
|
65
|
+
expect(fetchCall.headers.get('cf-container-target-port')).toBe('8080');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should preserve original headers for WebSocket', async () => {
|
|
69
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/ws', {
|
|
70
|
+
headers: {
|
|
71
|
+
'Upgrade': 'websocket',
|
|
72
|
+
'Sec-WebSocket-Key': 'test-key-123',
|
|
73
|
+
'Sec-WebSocket-Version': '13',
|
|
74
|
+
'User-Agent': 'test-client',
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await proxyToSandbox(request, mockEnv);
|
|
79
|
+
|
|
80
|
+
const fetchCall = vi.mocked(mockSandbox.fetch as any).mock.calls[0][0] as Request;
|
|
81
|
+
expect(fetchCall.headers.get('Upgrade')).toBe('websocket');
|
|
82
|
+
expect(fetchCall.headers.get('Sec-WebSocket-Key')).toBe('test-key-123');
|
|
83
|
+
expect(fetchCall.headers.get('Sec-WebSocket-Version')).toBe('13');
|
|
84
|
+
expect(fetchCall.headers.get('User-Agent')).toBe('test-client');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('HTTP routing (existing behavior)', () => {
|
|
89
|
+
it('should route HTTP requests through containerFetch', async () => {
|
|
90
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/api/data', {
|
|
91
|
+
method: 'GET',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await proxyToSandbox(request, mockEnv);
|
|
95
|
+
|
|
96
|
+
// Should route through containerFetch() for HTTP
|
|
97
|
+
expect(mockSandbox.containerFetch).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(mockSandbox.fetch).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should route POST requests through containerFetch', async () => {
|
|
102
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/api/data', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
body: JSON.stringify({ data: 'test' }),
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await proxyToSandbox(request, mockEnv);
|
|
111
|
+
|
|
112
|
+
expect(mockSandbox.containerFetch).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(mockSandbox.fetch).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should not detect SSE as WebSocket', async () => {
|
|
117
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/events', {
|
|
118
|
+
headers: {
|
|
119
|
+
'Accept': 'text/event-stream',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await proxyToSandbox(request, mockEnv);
|
|
124
|
+
|
|
125
|
+
// SSE should use HTTP path, not WebSocket path
|
|
126
|
+
expect(mockSandbox.containerFetch).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(mockSandbox.fetch).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Token validation', () => {
|
|
132
|
+
it('should validate token for both WebSocket and HTTP requests', async () => {
|
|
133
|
+
const wsRequest = new Request('https://8080-sandbox-token12345678901.example.com/ws', {
|
|
134
|
+
headers: { 'Upgrade': 'websocket' },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await proxyToSandbox(wsRequest, mockEnv);
|
|
138
|
+
expect(mockSandbox.validatePortToken).toHaveBeenCalledWith(8080, 'token12345678901');
|
|
139
|
+
|
|
140
|
+
vi.clearAllMocks();
|
|
141
|
+
|
|
142
|
+
const httpRequest = new Request('https://8080-sandbox-token12345678901.example.com/api');
|
|
143
|
+
await proxyToSandbox(httpRequest, mockEnv);
|
|
144
|
+
expect(mockSandbox.validatePortToken).toHaveBeenCalledWith(8080, 'token12345678901');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should reject requests with invalid token', async () => {
|
|
148
|
+
vi.mocked(mockSandbox.validatePortToken as any).mockResolvedValue(false);
|
|
149
|
+
|
|
150
|
+
const request = new Request('https://8080-sandbox-invalidtoken1234.example.com/ws', {
|
|
151
|
+
headers: { 'Upgrade': 'websocket' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const response = await proxyToSandbox(request, mockEnv);
|
|
155
|
+
|
|
156
|
+
expect(response?.status).toBe(404);
|
|
157
|
+
expect(mockSandbox.fetch).not.toHaveBeenCalled();
|
|
158
|
+
|
|
159
|
+
const body = await response?.json();
|
|
160
|
+
expect(body).toMatchObject({
|
|
161
|
+
error: 'Access denied: Invalid token or port not exposed',
|
|
162
|
+
code: 'INVALID_TOKEN',
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should reject reserved port 3000', async () => {
|
|
167
|
+
// Port 3000 is reserved as control plane port and rejected by validatePort()
|
|
168
|
+
const request = new Request('https://3000-sandbox-anytoken12345678.example.com/status', {
|
|
169
|
+
method: 'GET',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const response = await proxyToSandbox(request, mockEnv);
|
|
173
|
+
|
|
174
|
+
// Port 3000 is reserved and should be rejected (extractSandboxRoute returns null)
|
|
175
|
+
expect(response).toBeNull();
|
|
176
|
+
expect(mockSandbox.validatePortToken).not.toHaveBeenCalled();
|
|
177
|
+
expect(mockSandbox.containerFetch).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('Port routing', () => {
|
|
182
|
+
it('should route to correct port from subdomain', async () => {
|
|
183
|
+
const request = new Request('https://9000-sandbox-token12345678901.example.com/api', {
|
|
184
|
+
method: 'GET',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await proxyToSandbox(request, mockEnv);
|
|
188
|
+
|
|
189
|
+
expect(mockSandbox.validatePortToken).toHaveBeenCalledWith(9000, 'token12345678901');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('Non-sandbox requests', () => {
|
|
194
|
+
it('should return null for non-sandbox URLs', async () => {
|
|
195
|
+
const request = new Request('https://example.com/some-path');
|
|
196
|
+
|
|
197
|
+
const response = await proxyToSandbox(request, mockEnv);
|
|
198
|
+
|
|
199
|
+
expect(response).toBeNull();
|
|
200
|
+
expect(mockSandbox.fetch).not.toHaveBeenCalled();
|
|
201
|
+
expect(mockSandbox.containerFetch).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should return null for invalid subdomain patterns', async () => {
|
|
205
|
+
const request = new Request('https://invalid-pattern.example.com');
|
|
206
|
+
|
|
207
|
+
const response = await proxyToSandbox(request, mockEnv);
|
|
208
|
+
|
|
209
|
+
expect(response).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('Error handling', () => {
|
|
214
|
+
it('should handle errors during WebSocket routing', async () => {
|
|
215
|
+
(mockSandbox.fetch as any).mockImplementation(() => Promise.reject(new Error('Connection failed')));
|
|
216
|
+
|
|
217
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/ws', {
|
|
218
|
+
headers: {
|
|
219
|
+
'Upgrade': 'websocket',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const response = await proxyToSandbox(request, mockEnv);
|
|
224
|
+
|
|
225
|
+
expect(response?.status).toBe(500);
|
|
226
|
+
const text = await response?.text();
|
|
227
|
+
expect(text).toBe('Proxy routing error');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should handle errors during HTTP routing', async () => {
|
|
231
|
+
(mockSandbox.containerFetch as any).mockImplementation(() => Promise.reject(new Error('Service error')));
|
|
232
|
+
|
|
233
|
+
const request = new Request('https://8080-sandbox-token12345678901.example.com/api');
|
|
234
|
+
|
|
235
|
+
const response = await proxyToSandbox(request, mockEnv);
|
|
236
|
+
|
|
237
|
+
expect(response?.status).toBe(500);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
package/tests/sandbox.test.ts
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
import type { DurableObjectState } from '@cloudflare/workers-types';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { Sandbox } from '../src/sandbox';
|
|
4
|
+
import { Container } from '@cloudflare/containers';
|
|
4
5
|
|
|
5
6
|
// Mock dependencies before imports
|
|
6
7
|
vi.mock('./interpreter', () => ({
|
|
7
8
|
CodeInterpreter: vi.fn().mockImplementation(() => ({})),
|
|
8
9
|
}));
|
|
9
10
|
|
|
10
|
-
vi.mock('@cloudflare/containers', () =>
|
|
11
|
-
|
|
11
|
+
vi.mock('@cloudflare/containers', () => {
|
|
12
|
+
const MockContainer = class Container {
|
|
12
13
|
ctx: any;
|
|
13
14
|
env: any;
|
|
14
15
|
constructor(ctx: any, env: any) {
|
|
15
16
|
this.ctx = ctx;
|
|
16
17
|
this.env = env;
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
async fetch(request: Request): Promise<Response> {
|
|
20
|
+
// Mock implementation - will be spied on in tests
|
|
21
|
+
return new Response('Mock Container fetch');
|
|
22
|
+
}
|
|
23
|
+
async containerFetch(request: Request, port: number): Promise<Response> {
|
|
24
|
+
// Mock implementation for HTTP path
|
|
25
|
+
return new Response('Mock Container HTTP fetch');
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
Container: MockContainer,
|
|
31
|
+
getContainer: vi.fn(),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
21
34
|
|
|
22
35
|
describe('Sandbox - Automatic Session Management', () => {
|
|
23
36
|
let sandbox: Sandbox;
|
|
@@ -462,4 +475,80 @@ describe('Sandbox - Automatic Session Management', () => {
|
|
|
462
475
|
expect(sandbox.client.ports.exposePort).toHaveBeenCalled();
|
|
463
476
|
});
|
|
464
477
|
});
|
|
478
|
+
|
|
479
|
+
describe('fetch() override - WebSocket detection', () => {
|
|
480
|
+
let superFetchSpy: any;
|
|
481
|
+
|
|
482
|
+
beforeEach(async () => {
|
|
483
|
+
await sandbox.setSandboxName('test-sandbox');
|
|
484
|
+
|
|
485
|
+
// Spy on Container.prototype.fetch to verify WebSocket routing
|
|
486
|
+
superFetchSpy = vi.spyOn(Container.prototype, 'fetch')
|
|
487
|
+
.mockResolvedValue(new Response('WebSocket response'));
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
afterEach(() => {
|
|
491
|
+
superFetchSpy?.mockRestore();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should detect WebSocket upgrade header and route to super.fetch', async () => {
|
|
495
|
+
const request = new Request('https://example.com/ws', {
|
|
496
|
+
headers: {
|
|
497
|
+
'Upgrade': 'websocket',
|
|
498
|
+
'Connection': 'Upgrade',
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const response = await sandbox.fetch(request);
|
|
503
|
+
|
|
504
|
+
// Should route through super.fetch() for WebSocket
|
|
505
|
+
expect(superFetchSpy).toHaveBeenCalledTimes(1);
|
|
506
|
+
expect(await response.text()).toBe('WebSocket response');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('should route non-WebSocket requests through containerFetch', async () => {
|
|
510
|
+
// GET request
|
|
511
|
+
const getRequest = new Request('https://example.com/api/data');
|
|
512
|
+
await sandbox.fetch(getRequest);
|
|
513
|
+
expect(superFetchSpy).not.toHaveBeenCalled();
|
|
514
|
+
|
|
515
|
+
vi.clearAllMocks();
|
|
516
|
+
|
|
517
|
+
// POST request
|
|
518
|
+
const postRequest = new Request('https://example.com/api/data', {
|
|
519
|
+
method: 'POST',
|
|
520
|
+
body: JSON.stringify({ data: 'test' }),
|
|
521
|
+
headers: { 'Content-Type': 'application/json' },
|
|
522
|
+
});
|
|
523
|
+
await sandbox.fetch(postRequest);
|
|
524
|
+
expect(superFetchSpy).not.toHaveBeenCalled();
|
|
525
|
+
|
|
526
|
+
vi.clearAllMocks();
|
|
527
|
+
|
|
528
|
+
// SSE request (should not be detected as WebSocket)
|
|
529
|
+
const sseRequest = new Request('https://example.com/events', {
|
|
530
|
+
headers: { 'Accept': 'text/event-stream' },
|
|
531
|
+
});
|
|
532
|
+
await sandbox.fetch(sseRequest);
|
|
533
|
+
expect(superFetchSpy).not.toHaveBeenCalled();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should preserve WebSocket request unchanged when calling super.fetch()', async () => {
|
|
537
|
+
const request = new Request('https://example.com/ws', {
|
|
538
|
+
headers: {
|
|
539
|
+
'Upgrade': 'websocket',
|
|
540
|
+
'Sec-WebSocket-Key': 'test-key-123',
|
|
541
|
+
'Sec-WebSocket-Version': '13',
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await sandbox.fetch(request);
|
|
546
|
+
|
|
547
|
+
expect(superFetchSpy).toHaveBeenCalledTimes(1);
|
|
548
|
+
const passedRequest = superFetchSpy.mock.calls[0][0] as Request;
|
|
549
|
+
expect(passedRequest.headers.get('Upgrade')).toBe('websocket');
|
|
550
|
+
expect(passedRequest.headers.get('Sec-WebSocket-Key')).toBe('test-key-123');
|
|
551
|
+
expect(passedRequest.headers.get('Sec-WebSocket-Version')).toBe('13');
|
|
552
|
+
});
|
|
553
|
+
});
|
|
465
554
|
});
|