@cloudflare/sandbox 0.0.0-6d1b288 → 0.0.0-6f16c81

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/src/index.ts CHANGED
@@ -1,79 +1,14 @@
1
- import { Container, getContainer } from "@cloudflare/containers";
2
- import { HttpClient } from "./client";
3
-
4
- export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
5
- return getContainer(ns, id);
6
- }
7
-
8
- export class Sandbox<Env = unknown> extends Container<Env> {
9
- defaultPort = 3000; // The default port for the container to listen on
10
- sleepAfter = "3m"; // Sleep the sandbox if no requests are made in this timeframe
11
-
12
- client: HttpClient = new HttpClient({
13
- onCommandComplete: (success, exitCode, stdout, stderr, command, args) => {
14
- console.log(
15
- `[Container] Command completed: ${command}, Success: ${success}, Exit code: ${exitCode}`
16
- );
17
- },
18
- onCommandStart: (command, args) => {
19
- console.log(`[Container] Command started: ${command} ${args.join(" ")}`);
20
- },
21
- onError: (error, command, args) => {
22
- console.error(`[Container] Command error: ${error}`);
23
- },
24
- onOutput: (stream, data, command) => {
25
- console.log(`[Container] [${stream}] ${data}`);
26
- },
27
- port: this.defaultPort,
28
- });
29
-
30
- envVars = {
31
- MESSAGE: "I was passed in via the Sandbox class!",
32
- };
33
-
34
- override onStart() {
35
- console.log("Sandbox successfully started");
36
- }
37
-
38
- override onStop() {
39
- console.log("Sandbox successfully shut down");
40
- if (this.client) {
41
- this.client.clearSession();
42
- }
43
- }
44
-
45
- override onError(error: unknown) {
46
- console.log("Sandbox error:", error);
47
- }
48
-
49
- async exec(command: string, args: string[], options?: { stream?: boolean }) {
50
- if (options?.stream) {
51
- return this.client.executeStream(command, args);
52
- }
53
- return this.client.execute(command, args);
54
- }
55
-
56
- async gitCheckout(
57
- repoUrl: string,
58
- options: { branch?: string; targetDir?: string; stream?: boolean }
59
- ) {
60
- if (options?.stream) {
61
- return this.client.gitCheckoutStream(
62
- repoUrl,
63
- options.branch,
64
- options.targetDir
65
- );
66
- }
67
- return this.client.gitCheckout(repoUrl, options.branch, options.targetDir);
68
- }
69
-
70
- async mkdir(
71
- path: string,
72
- options: { recursive?: boolean; stream?: boolean }
73
- ) {
74
- if (options?.stream) {
75
- return this.client.mkdirStream(path, options.recursive);
76
- }
77
- return this.client.mkdir(path, options.recursive);
78
- }
79
- }
1
+ // Export types from client
2
+ export type {
3
+ DeleteFileResponse, ExecuteResponse,
4
+ GitCheckoutResponse,
5
+ MkdirResponse, MoveFileResponse,
6
+ ReadFileResponse, RenameFileResponse, WriteFileResponse
7
+ } from "./client";
8
+
9
+ // Re-export request handler utilities
10
+ export {
11
+ proxyToSandbox, type RouteInfo, type SandboxEnv
12
+ } from './request-handler';
13
+
14
+ export { getSandbox, Sandbox } from "./sandbox";
@@ -0,0 +1,95 @@
1
+ import { getSandbox, type Sandbox } from "./sandbox";
2
+
3
+ export interface SandboxEnv {
4
+ Sandbox: DurableObjectNamespace<Sandbox>;
5
+ }
6
+
7
+ export interface RouteInfo {
8
+ port: number;
9
+ sandboxId: string;
10
+ path: string;
11
+ }
12
+
13
+ export async function proxyToSandbox<E extends SandboxEnv>(
14
+ request: Request,
15
+ env: E
16
+ ): Promise<Response | null> {
17
+ try {
18
+ const url = new URL(request.url);
19
+ const routeInfo = extractSandboxRoute(url);
20
+
21
+ if (!routeInfo) {
22
+ return null; // Not a request to an exposed container port
23
+ }
24
+
25
+ const { sandboxId, port, path } = routeInfo;
26
+ const sandbox = getSandbox(env.Sandbox, sandboxId);
27
+
28
+ // Build proxy request with proper headers
29
+ let proxyUrl: string;
30
+
31
+ // Route based on the target port
32
+ if (port !== 3000) {
33
+ // Route directly to user's service on the specified port
34
+ proxyUrl = `http://localhost:${port}${path}${url.search}`;
35
+ } else {
36
+ // Port 3000 is our control plane - route normally
37
+ proxyUrl = `http://localhost:3000${path}${url.search}`;
38
+ }
39
+
40
+ const proxyRequest = new Request(proxyUrl, {
41
+ method: request.method,
42
+ headers: {
43
+ ...Object.fromEntries(request.headers),
44
+ 'X-Original-URL': request.url,
45
+ 'X-Forwarded-Host': url.hostname,
46
+ 'X-Forwarded-Proto': url.protocol.replace(':', ''),
47
+ 'X-Sandbox-Name': sandboxId, // Pass the friendly name
48
+ },
49
+ body: request.body,
50
+ });
51
+
52
+ return sandbox.containerFetch(proxyRequest, port);
53
+ } catch (error) {
54
+ console.error('[Sandbox] Proxy routing error:', error);
55
+ return new Response('Proxy routing error', { status: 500 });
56
+ }
57
+ }
58
+
59
+ function extractSandboxRoute(url: URL): RouteInfo | null {
60
+ // Production: subdomain pattern {port}-{sandboxId}.{domain}
61
+ const subdomainMatch = url.hostname.match(/^(\d+)-([a-zA-Z0-9-]+)\./);
62
+ if (subdomainMatch) {
63
+ return {
64
+ port: parseInt(subdomainMatch[1]),
65
+ sandboxId: subdomainMatch[2],
66
+ path: url.pathname,
67
+ };
68
+ }
69
+
70
+ // Development: path pattern /preview/{port}/{sandboxId}/*
71
+ if (isLocalhostPattern(url.hostname)) {
72
+ const pathMatch = url.pathname.match(/^\/preview\/(\d+)\/([^/]+)(\/.*)?$/);
73
+ if (pathMatch) {
74
+ return {
75
+ port: parseInt(pathMatch[1]),
76
+ sandboxId: pathMatch[2],
77
+ path: pathMatch[3] || "/",
78
+ };
79
+ }
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ export function isLocalhostPattern(hostname: string): boolean {
86
+ const hostPart = hostname.split(":")[0];
87
+ return (
88
+ hostPart === "localhost" ||
89
+ hostPart === "127.0.0.1" ||
90
+ hostPart === "::1" ||
91
+ hostPart === "[::1]" ||
92
+ hostPart === "0.0.0.0"
93
+ );
94
+ }
95
+
package/src/sandbox.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { Container, getContainer } from "@cloudflare/containers";
2
+ import { HttpClient } from "./client";
3
+ import { isLocalhostPattern } from "./request-handler";
4
+
5
+ export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
6
+ const stub = getContainer(ns, id);
7
+
8
+ // Store the name on first access
9
+ stub.setSandboxName?.(id);
10
+
11
+ return stub;
12
+ }
13
+
14
+ export class Sandbox<Env = unknown> extends Container<Env> {
15
+ sleepAfter = "3m"; // Sleep the sandbox if no requests are made in this timeframe
16
+ client: HttpClient;
17
+ private workerHostname: string | null = null;
18
+ private sandboxName: string | null = null;
19
+
20
+ constructor(ctx: DurableObjectState, env: Env) {
21
+ super(ctx, env);
22
+ this.client = new HttpClient({
23
+ onCommandComplete: (success, exitCode, _stdout, _stderr, command, _args) => {
24
+ console.log(
25
+ `[Container] Command completed: ${command}, Success: ${success}, Exit code: ${exitCode}`
26
+ );
27
+ },
28
+ onCommandStart: (command, args) => {
29
+ console.log(
30
+ `[Container] Command started: ${command} ${args.join(" ")}`
31
+ );
32
+ },
33
+ onError: (error, _command, _args) => {
34
+ console.error(`[Container] Command error: ${error}`);
35
+ },
36
+ onOutput: (stream, data, _command) => {
37
+ console.log(`[Container] [${stream}] ${data}`);
38
+ },
39
+ port: 3000, // Control plane port
40
+ stub: this,
41
+ });
42
+
43
+ // Load the sandbox name from storage on initialization
44
+ this.ctx.blockConcurrencyWhile(async () => {
45
+ this.sandboxName = await this.ctx.storage.get<string>('sandboxName') || null;
46
+ });
47
+ }
48
+
49
+ // RPC method to set the sandbox name
50
+ async setSandboxName(name: string): Promise<void> {
51
+ if (!this.sandboxName) {
52
+ this.sandboxName = name;
53
+ await this.ctx.storage.put('sandboxName', name);
54
+ console.log(`[Sandbox] Stored sandbox name via RPC: ${name}`);
55
+ }
56
+ }
57
+
58
+ override onStart() {
59
+ console.log("Sandbox successfully started");
60
+ }
61
+
62
+ override onStop() {
63
+ console.log("Sandbox successfully shut down");
64
+ if (this.client) {
65
+ this.client.clearSession();
66
+ }
67
+ }
68
+
69
+ override onError(error: unknown) {
70
+ console.log("Sandbox error:", error);
71
+ }
72
+
73
+ // Override fetch to capture the hostname and route to appropriate ports
74
+ override async fetch(request: Request): Promise<Response> {
75
+ const url = new URL(request.url);
76
+
77
+ // Capture the hostname from the first request
78
+ if (!this.workerHostname) {
79
+ this.workerHostname = url.hostname;
80
+ console.log(`[Sandbox] Captured hostname: ${this.workerHostname}`);
81
+ }
82
+
83
+ // Capture and store the sandbox name from the header if present
84
+ if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
85
+ const name = request.headers.get('X-Sandbox-Name')!;
86
+ this.sandboxName = name;
87
+ await this.ctx.storage.put('sandboxName', name);
88
+ console.log(`[Sandbox] Stored sandbox name: ${this.sandboxName}`);
89
+ }
90
+
91
+ // Determine which port to route to
92
+ const port = this.determinePort(url);
93
+
94
+ // Route to the appropriate port
95
+ return await this.containerFetch(request, port);
96
+ }
97
+
98
+ private determinePort(url: URL): number {
99
+ // Extract port from proxy requests (e.g., /proxy/8080/*)
100
+ const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/);
101
+ if (proxyMatch) {
102
+ return parseInt(proxyMatch[1]);
103
+ }
104
+
105
+ // All other requests go to control plane on port 3000
106
+ // This includes /api/* endpoints and any other control requests
107
+ return 3000;
108
+ }
109
+
110
+ async exec(command: string, args: string[], options?: { stream?: boolean; background?: boolean }) {
111
+ if (options?.stream) {
112
+ return this.client.executeStream(command, args, undefined, options?.background);
113
+ }
114
+ return this.client.execute(command, args, undefined, options?.background);
115
+ }
116
+
117
+ async gitCheckout(
118
+ repoUrl: string,
119
+ options: { branch?: string; targetDir?: string; stream?: boolean }
120
+ ) {
121
+ if (options?.stream) {
122
+ return this.client.gitCheckoutStream(
123
+ repoUrl,
124
+ options.branch,
125
+ options.targetDir
126
+ );
127
+ }
128
+ return this.client.gitCheckout(repoUrl, options.branch, options.targetDir);
129
+ }
130
+
131
+ async mkdir(
132
+ path: string,
133
+ options: { recursive?: boolean; stream?: boolean } = {}
134
+ ) {
135
+ if (options?.stream) {
136
+ return this.client.mkdirStream(path, options.recursive);
137
+ }
138
+ return this.client.mkdir(path, options.recursive);
139
+ }
140
+
141
+ async writeFile(
142
+ path: string,
143
+ content: string,
144
+ options: { encoding?: string; stream?: boolean } = {}
145
+ ) {
146
+ if (options?.stream) {
147
+ return this.client.writeFileStream(path, content, options.encoding);
148
+ }
149
+ return this.client.writeFile(path, content, options.encoding);
150
+ }
151
+
152
+ async deleteFile(path: string, options: { stream?: boolean } = {}) {
153
+ if (options?.stream) {
154
+ return this.client.deleteFileStream(path);
155
+ }
156
+ return this.client.deleteFile(path);
157
+ }
158
+
159
+ async renameFile(
160
+ oldPath: string,
161
+ newPath: string,
162
+ options: { stream?: boolean } = {}
163
+ ) {
164
+ if (options?.stream) {
165
+ return this.client.renameFileStream(oldPath, newPath);
166
+ }
167
+ return this.client.renameFile(oldPath, newPath);
168
+ }
169
+
170
+ async moveFile(
171
+ sourcePath: string,
172
+ destinationPath: string,
173
+ options: { stream?: boolean } = {}
174
+ ) {
175
+ if (options?.stream) {
176
+ return this.client.moveFileStream(sourcePath, destinationPath);
177
+ }
178
+ return this.client.moveFile(sourcePath, destinationPath);
179
+ }
180
+
181
+ async readFile(
182
+ path: string,
183
+ options: { encoding?: string; stream?: boolean } = {}
184
+ ) {
185
+ if (options?.stream) {
186
+ return this.client.readFileStream(path, options.encoding);
187
+ }
188
+ return this.client.readFile(path, options.encoding);
189
+ }
190
+
191
+ async exposePort(port: number, options?: { name?: string }) {
192
+ await this.client.exposePort(port, options?.name);
193
+
194
+ // We need the sandbox name to construct preview URLs
195
+ if (!this.sandboxName) {
196
+ throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
197
+ }
198
+
199
+ const hostname = this.getHostname();
200
+ const url = this.constructPreviewUrl(port, this.sandboxName, hostname);
201
+
202
+ return {
203
+ url,
204
+ port,
205
+ name: options?.name,
206
+ };
207
+ }
208
+
209
+ async unexposePort(port: number) {
210
+ await this.client.unexposePort(port);
211
+ }
212
+
213
+ async getExposedPorts() {
214
+ const response = await this.client.getExposedPorts();
215
+
216
+ // We need the sandbox name to construct preview URLs
217
+ if (!this.sandboxName) {
218
+ throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
219
+ }
220
+
221
+ const hostname = this.getHostname();
222
+
223
+ return response.ports.map(port => ({
224
+ url: this.constructPreviewUrl(port.port, this.sandboxName!, hostname),
225
+ port: port.port,
226
+ name: port.name,
227
+ exposedAt: port.exposedAt,
228
+ }));
229
+ }
230
+
231
+ private getHostname(): string {
232
+ // Use the captured hostname or fall back to localhost for development
233
+ return this.workerHostname || "localhost:8787";
234
+ }
235
+
236
+ private constructPreviewUrl(port: number, sandboxId: string, hostname: string): string {
237
+ // Check if this is a localhost pattern
238
+ const isLocalhost = isLocalhostPattern(hostname);
239
+
240
+ if (isLocalhost) {
241
+ // For local development, we need to use a different approach
242
+ // Since subdomains don't work with localhost, we'll use the base URL
243
+ // with a note that the user needs to handle routing differently
244
+ return `http://${hostname}/preview/${port}/${sandboxId}`;
245
+ }
246
+
247
+ // For all other domains (workers.dev, custom domains, etc.)
248
+ // Use subdomain-based routing pattern
249
+ const protocol = hostname.includes(":") ? "http" : "https";
250
+ return `${protocol}://${port}-${sandboxId}.${hostname}`;
251
+ }
252
+ }
package/tests/test2.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  quickExecuteStream,
8
8
  quickMoveFile,
9
9
  quickMoveFileStream,
10
+ quickReadFile,
11
+ quickReadFileStream,
10
12
  quickRenameFile,
11
13
  quickRenameFileStream,
12
14
  quickWriteFile,
@@ -683,6 +685,223 @@ async function testHttpClient() {
683
685
  );
684
686
  }
685
687
 
688
+ // Test 25: File reading
689
+ console.log("Test 25: File reading");
690
+ try {
691
+ // First create a file to read
692
+ const readContent = "This is a test file for reading\nLine 2\nLine 3";
693
+ await quickWriteFile("file-to-read.txt", readContent);
694
+ console.log("✅ Test file created for reading");
695
+
696
+ // Read the file
697
+ const result = await quickReadFile("file-to-read.txt");
698
+ console.log("✅ File read successfully:", result.success);
699
+ console.log(" Path:", result.path);
700
+ console.log(" Content length:", result.content.length, "characters");
701
+ console.log(" Exit code:", result.exitCode);
702
+
703
+ // Verify the content
704
+ console.log("✅ File content verified:", result.content === readContent);
705
+ console.log(" Content:", result.content);
706
+ console.log("✅ File reading test completed\n");
707
+ } catch (error) {
708
+ console.error("❌ Test 25 failed:", error);
709
+ }
710
+
711
+ // Test 26: File reading with custom encoding
712
+ console.log("Test 26: File reading with custom encoding");
713
+ try {
714
+ // Create a file with special characters
715
+ const specialContent = "Hello 世界! 🌍 Test with emojis and unicode";
716
+ await quickWriteFile("special-chars.txt", specialContent, "utf-8");
717
+ console.log("✅ Special characters file created");
718
+
719
+ // Read with explicit UTF-8 encoding
720
+ const result = await quickReadFile("special-chars.txt", "utf-8");
721
+ console.log(
722
+ "✅ Special characters file read successfully:",
723
+ result.success
724
+ );
725
+ console.log(
726
+ "✅ Special characters verified:",
727
+ result.content === specialContent
728
+ );
729
+ console.log(" Content:", result.content);
730
+ console.log("✅ Custom encoding test completed\n");
731
+ } catch (error) {
732
+ console.error("❌ Test 26 failed:", error);
733
+ }
734
+
735
+ // Test 27: File reading in nested directories
736
+ console.log("Test 27: File reading in nested directories");
737
+ try {
738
+ // Create a file in a nested directory
739
+ const nestedContent = "This file is in a nested directory for reading";
740
+ await quickWriteFile("nested/read/test-nested-read.txt", nestedContent);
741
+ console.log("✅ Nested file created for reading");
742
+
743
+ // Read the nested file
744
+ const result = await quickReadFile("nested/read/test-nested-read.txt");
745
+ console.log("✅ Nested file read successfully:", result.success);
746
+ console.log(
747
+ "✅ Nested file content verified:",
748
+ result.content === nestedContent
749
+ );
750
+ console.log(" Content:", result.content);
751
+ console.log("✅ Nested directory reading test completed\n");
752
+ } catch (error) {
753
+ console.error("❌ Test 27 failed:", error);
754
+ }
755
+
756
+ // Test 28: Streaming file reading
757
+ console.log("Test 28: Streaming file reading");
758
+ try {
759
+ const client = createClient();
760
+ await client.createSession();
761
+
762
+ // Create a file to read via streaming
763
+ const streamReadContent =
764
+ "This file will be read via streaming\nLine 2\nLine 3";
765
+ await client.writeFile("stream-read-file.txt", streamReadContent);
766
+ console.log("✅ Test file created for streaming reading");
767
+
768
+ let readContent = "";
769
+ let readCompleted = false;
770
+
771
+ // Set up event handlers for streaming
772
+ client.setOnStreamEvent((event) => {
773
+ if (event.type === "command_complete" && event.content) {
774
+ readContent = event.content;
775
+ readCompleted = true;
776
+ }
777
+ });
778
+
779
+ console.log(" Starting streaming file reading...");
780
+ await client.readFileStream("stream-read-file.txt");
781
+ console.log("✅ Streaming file reading completed");
782
+
783
+ // Verify the content was read correctly
784
+ console.log(
785
+ "✅ Streaming read content verified:",
786
+ readContent === streamReadContent
787
+ );
788
+ console.log(" Content length:", readContent.length, "characters");
789
+
790
+ client.clearSession();
791
+ console.log("✅ Streaming file reading test completed\n");
792
+ } catch (error) {
793
+ console.error("❌ Test 28 failed:", error);
794
+ }
795
+
796
+ // Test 29: Quick streaming file reading
797
+ console.log("Test 29: Quick streaming file reading");
798
+ try {
799
+ // Create a file for quick streaming read
800
+ const quickReadContent = "Quick streaming read test content";
801
+ await quickWriteFile("quick-read-stream.txt", quickReadContent);
802
+ console.log("✅ Test file created for quick streaming reading");
803
+
804
+ let quickReadContentReceived = "";
805
+ let quickReadCompleted = false;
806
+
807
+ console.log(" Starting quick streaming file reading...");
808
+ await quickReadFileStream("quick-read-stream.txt", "utf-8", {
809
+ onStreamEvent: (event) => {
810
+ if (event.type === "command_complete" && event.content) {
811
+ quickReadContentReceived = event.content;
812
+ quickReadCompleted = true;
813
+ }
814
+ },
815
+ });
816
+ console.log("✅ Quick streaming file reading completed");
817
+
818
+ // Verify the content
819
+ console.log(
820
+ "✅ Quick streaming read verified:",
821
+ quickReadContentReceived === quickReadContent
822
+ );
823
+ console.log(" Content:", quickReadContentReceived);
824
+ console.log("✅ Quick streaming file reading test completed\n");
825
+ } catch (error) {
826
+ console.error("❌ Test 29 failed:", error);
827
+ }
828
+
829
+ // Test 30: File reading with session management
830
+ console.log("Test 30: File reading with session management");
831
+ try {
832
+ const client = createClient();
833
+ const sessionId = await client.createSession();
834
+
835
+ // Create a file in session
836
+ const sessionReadContent =
837
+ "This file was created and read with session management";
838
+ await client.writeFile(
839
+ "session-read-file.txt",
840
+ sessionReadContent,
841
+ "utf-8",
842
+ sessionId
843
+ );
844
+ console.log("✅ Session file created for reading");
845
+
846
+ // Read the file in the same session
847
+ const result = await client.readFile(
848
+ "session-read-file.txt",
849
+ "utf-8",
850
+ sessionId
851
+ );
852
+ console.log("✅ Session file read successfully:", result.success);
853
+ console.log(
854
+ "✅ Session file content verified:",
855
+ result.content === sessionReadContent
856
+ );
857
+ console.log(" Session ID:", sessionId);
858
+ console.log(" Content:", result.content);
859
+
860
+ client.clearSession();
861
+ console.log("✅ Session file reading test completed\n");
862
+ } catch (error) {
863
+ console.error("❌ Test 30 failed:", error);
864
+ }
865
+
866
+ // Test 31: Error handling for file reading
867
+ console.log("Test 31: Error handling for file reading");
868
+ try {
869
+ // Try to read a non-existent file
870
+ await quickReadFile("non-existent-read-file.txt");
871
+ console.log("❌ Should have failed for non-existent file");
872
+ } catch (error) {
873
+ console.log("✅ Error handling works for non-existent files");
874
+ console.log(
875
+ " Error:",
876
+ error instanceof Error ? error.message : "Unknown error"
877
+ );
878
+ }
879
+
880
+ try {
881
+ // Try to read from a dangerous path
882
+ await quickReadFile("/etc/passwd");
883
+ console.log("❌ Should have failed for dangerous path");
884
+ } catch (error) {
885
+ console.log("✅ Error handling works for dangerous paths");
886
+ console.log(
887
+ " Error:",
888
+ error instanceof Error ? error.message : "Unknown error"
889
+ );
890
+ }
891
+
892
+ try {
893
+ // Try to read with empty path
894
+ await quickReadFile("");
895
+ console.log("❌ Should have failed for empty path");
896
+ } catch (error) {
897
+ console.log("✅ Error handling works for empty paths");
898
+ console.log(
899
+ " Error:",
900
+ error instanceof Error ? error.message : "Unknown error",
901
+ "\n"
902
+ );
903
+ }
904
+
686
905
  console.log("🎉 All tests completed!");
687
906
  }
688
907