@cloudflare/sandbox 0.0.0-eb0ea62 → 0.0.0-f106fda

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/client.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { DurableObject } from "cloudflare:workers";
2
1
  import type { Sandbox } from "./index";
3
2
 
4
3
  interface ExecuteRequest {
5
4
  command: string;
6
5
  args?: string[];
6
+ sessionId?: string;
7
+ background?: boolean;
7
8
  }
8
9
 
9
10
  export interface ExecuteResponse {
@@ -139,6 +140,37 @@ export interface MoveFileResponse {
139
140
  timestamp: string;
140
141
  }
141
142
 
143
+ interface PreviewInfo {
144
+ url: string;
145
+ port: number;
146
+ name?: string;
147
+ }
148
+
149
+ interface ExposedPort extends PreviewInfo {
150
+ exposedAt: string;
151
+ timestamp: string;
152
+ }
153
+
154
+ interface ExposePortResponse {
155
+ success: boolean;
156
+ port: number;
157
+ name?: string;
158
+ exposedAt: string;
159
+ timestamp: string;
160
+ }
161
+
162
+ interface UnexposePortResponse {
163
+ success: boolean;
164
+ port: number;
165
+ timestamp: string;
166
+ }
167
+
168
+ interface GetExposedPortsResponse {
169
+ ports: ExposedPort[];
170
+ count: number;
171
+ timestamp: string;
172
+ }
173
+
142
174
  interface PingResponse {
143
175
  message: string;
144
176
  timestamp: string;
@@ -203,10 +235,41 @@ export class HttpClient {
203
235
  path: string,
204
236
  options?: RequestInit
205
237
  ): Promise<Response> {
206
- if (this.options.stub) {
207
- return this.options.stub.containerFetch(path, options, this.options.port);
238
+ const url = this.options.stub
239
+ ? `http://localhost:${this.options.port}${path}`
240
+ : `${this.baseUrl}${path}`;
241
+ const method = options?.method || "GET";
242
+
243
+ console.log(`[HTTP Client] Making ${method} request to ${url}`);
244
+
245
+ try {
246
+ let response: Response;
247
+
248
+ if (this.options.stub) {
249
+ response = await this.options.stub.containerFetch(
250
+ url,
251
+ options,
252
+ this.options.port
253
+ );
254
+ } else {
255
+ response = await fetch(url, options);
256
+ }
257
+
258
+ console.log(
259
+ `[HTTP Client] Response: ${response.status} ${response.statusText}`
260
+ );
261
+
262
+ if (!response.ok) {
263
+ console.error(
264
+ `[HTTP Client] Request failed: ${method} ${url} - ${response.status} ${response.statusText}`
265
+ );
266
+ }
267
+
268
+ return response;
269
+ } catch (error) {
270
+ console.error(`[HTTP Client] Request error: ${method} ${url}`, error);
271
+ throw error;
208
272
  }
209
- return fetch(this.baseUrl + path, options);
210
273
  }
211
274
  // Public methods to set event handlers
212
275
  setOnOutput(
@@ -245,13 +308,13 @@ export class HttpClient {
245
308
 
246
309
  getOnCommandComplete():
247
310
  | ((
248
- success: boolean,
249
- exitCode: number,
250
- stdout: string,
251
- stderr: string,
252
- command: string,
253
- args: string[]
254
- ) => void)
311
+ success: boolean,
312
+ exitCode: number,
313
+ stdout: string,
314
+ stderr: string,
315
+ command: string,
316
+ args: string[]
317
+ ) => void)
255
318
  | undefined {
256
319
  return this.options.onCommandComplete;
257
320
  }
@@ -308,7 +371,8 @@ export class HttpClient {
308
371
  async execute(
309
372
  command: string,
310
373
  args: string[] = [],
311
- sessionId?: string
374
+ sessionId?: string,
375
+ background: boolean = false,
312
376
  ): Promise<ExecuteResponse> {
313
377
  try {
314
378
  const targetSessionId = sessionId || this.sessionId;
@@ -317,8 +381,9 @@ export class HttpClient {
317
381
  body: JSON.stringify({
318
382
  args,
319
383
  command,
384
+ background,
320
385
  sessionId: targetSessionId,
321
- }),
386
+ } as ExecuteRequest),
322
387
  headers: {
323
388
  "Content-Type": "application/json",
324
389
  },
@@ -364,7 +429,8 @@ export class HttpClient {
364
429
  async executeStream(
365
430
  command: string,
366
431
  args: string[] = [],
367
- sessionId?: string
432
+ sessionId?: string,
433
+ background: boolean = false
368
434
  ): Promise<void> {
369
435
  try {
370
436
  const targetSessionId = sessionId || this.sessionId;
@@ -373,6 +439,7 @@ export class HttpClient {
373
439
  body: JSON.stringify({
374
440
  args,
375
441
  command,
442
+ background,
376
443
  sessionId: targetSessionId,
377
444
  }),
378
445
  headers: {
@@ -420,8 +487,7 @@ export class HttpClient {
420
487
  switch (event.type) {
421
488
  case "command_start":
422
489
  console.log(
423
- `[HTTP Client] Command started: ${
424
- event.command
490
+ `[HTTP Client] Command started: ${event.command
425
491
  } ${event.args?.join(" ")}`
426
492
  );
427
493
  this.options.onCommandStart?.(
@@ -502,7 +568,7 @@ export class HttpClient {
502
568
  repoUrl,
503
569
  sessionId: targetSessionId,
504
570
  targetDir,
505
- }),
571
+ } as GitCheckoutRequest),
506
572
  headers: {
507
573
  "Content-Type": "application/json",
508
574
  },
@@ -593,8 +659,7 @@ export class HttpClient {
593
659
  switch (event.type) {
594
660
  case "command_start":
595
661
  console.log(
596
- `[HTTP Client] Git checkout started: ${
597
- event.command
662
+ `[HTTP Client] Git checkout started: ${event.command
598
663
  } ${event.args?.join(" ")}`
599
664
  );
600
665
  this.options.onCommandStart?.(
@@ -673,7 +738,7 @@ export class HttpClient {
673
738
  path,
674
739
  recursive,
675
740
  sessionId: targetSessionId,
676
- }),
741
+ } as MkdirRequest),
677
742
  headers: {
678
743
  "Content-Type": "application/json",
679
744
  },
@@ -714,7 +779,7 @@ export class HttpClient {
714
779
  path,
715
780
  recursive,
716
781
  sessionId: targetSessionId,
717
- }),
782
+ } as MkdirRequest),
718
783
  headers: {
719
784
  "Content-Type": "application/json",
720
785
  },
@@ -760,8 +825,7 @@ export class HttpClient {
760
825
  switch (event.type) {
761
826
  case "command_start":
762
827
  console.log(
763
- `[HTTP Client] Mkdir started: ${
764
- event.command
828
+ `[HTTP Client] Mkdir started: ${event.command
765
829
  } ${event.args?.join(" ")}`
766
830
  );
767
831
  this.options.onCommandStart?.(
@@ -840,7 +904,7 @@ export class HttpClient {
840
904
  encoding,
841
905
  path,
842
906
  sessionId: targetSessionId,
843
- }),
907
+ } as WriteFileRequest),
844
908
  headers: {
845
909
  "Content-Type": "application/json",
846
910
  },
@@ -883,7 +947,7 @@ export class HttpClient {
883
947
  encoding,
884
948
  path,
885
949
  sessionId: targetSessionId,
886
- }),
950
+ } as WriteFileRequest),
887
951
  headers: {
888
952
  "Content-Type": "application/json",
889
953
  },
@@ -1006,7 +1070,7 @@ export class HttpClient {
1006
1070
  encoding,
1007
1071
  path,
1008
1072
  sessionId: targetSessionId,
1009
- }),
1073
+ } as ReadFileRequest),
1010
1074
  headers: {
1011
1075
  "Content-Type": "application/json",
1012
1076
  },
@@ -1047,7 +1111,7 @@ export class HttpClient {
1047
1111
  encoding,
1048
1112
  path,
1049
1113
  sessionId: targetSessionId,
1050
- }),
1114
+ } as ReadFileRequest),
1051
1115
  headers: {
1052
1116
  "Content-Type": "application/json",
1053
1117
  },
@@ -1102,10 +1166,8 @@ export class HttpClient {
1102
1166
 
1103
1167
  case "command_complete":
1104
1168
  console.log(
1105
- `[HTTP Client] Read file completed: ${
1106
- event.path
1107
- }, Success: ${event.success}, Content length: ${
1108
- event.content?.length || 0
1169
+ `[HTTP Client] Read file completed: ${event.path
1170
+ }, Success: ${event.success}, Content length: ${event.content?.length || 0
1109
1171
  }`
1110
1172
  );
1111
1173
  this.options.onCommandComplete?.(
@@ -1162,7 +1224,7 @@ export class HttpClient {
1162
1224
  body: JSON.stringify({
1163
1225
  path,
1164
1226
  sessionId: targetSessionId,
1165
- }),
1227
+ } as DeleteFileRequest),
1166
1228
  headers: {
1167
1229
  "Content-Type": "application/json",
1168
1230
  },
@@ -1198,7 +1260,7 @@ export class HttpClient {
1198
1260
  body: JSON.stringify({
1199
1261
  path,
1200
1262
  sessionId: targetSessionId,
1201
- }),
1263
+ } as DeleteFileRequest),
1202
1264
  headers: {
1203
1265
  "Content-Type": "application/json",
1204
1266
  },
@@ -1308,7 +1370,7 @@ export class HttpClient {
1308
1370
  newPath,
1309
1371
  oldPath,
1310
1372
  sessionId: targetSessionId,
1311
- }),
1373
+ } as RenameFileRequest),
1312
1374
  headers: {
1313
1375
  "Content-Type": "application/json",
1314
1376
  },
@@ -1349,7 +1411,7 @@ export class HttpClient {
1349
1411
  newPath,
1350
1412
  oldPath,
1351
1413
  sessionId: targetSessionId,
1352
- }),
1414
+ } as RenameFileRequest),
1353
1415
  headers: {
1354
1416
  "Content-Type": "application/json",
1355
1417
  },
@@ -1462,7 +1524,7 @@ export class HttpClient {
1462
1524
  destinationPath,
1463
1525
  sessionId: targetSessionId,
1464
1526
  sourcePath,
1465
- }),
1527
+ } as MoveFileRequest),
1466
1528
  headers: {
1467
1529
  "Content-Type": "application/json",
1468
1530
  },
@@ -1503,7 +1565,7 @@ export class HttpClient {
1503
1565
  destinationPath,
1504
1566
  sessionId: targetSessionId,
1505
1567
  sourcePath,
1506
- }),
1568
+ } as MoveFileRequest),
1507
1569
  headers: {
1508
1570
  "Content-Type": "application/json",
1509
1571
  },
@@ -1606,6 +1668,104 @@ export class HttpClient {
1606
1668
  }
1607
1669
  }
1608
1670
 
1671
+ async exposePort(port: number, name?: string): Promise<ExposePortResponse> {
1672
+ try {
1673
+ const response = await this.doFetch(`/api/expose-port`, {
1674
+ body: JSON.stringify({
1675
+ port,
1676
+ name,
1677
+ }),
1678
+ headers: {
1679
+ "Content-Type": "application/json",
1680
+ },
1681
+ method: "POST",
1682
+ });
1683
+
1684
+ if (!response.ok) {
1685
+ const errorData = (await response.json().catch(() => ({}))) as {
1686
+ error?: string;
1687
+ };
1688
+ console.log(errorData);
1689
+ throw new Error(
1690
+ errorData.error || `HTTP error! status: ${response.status}`
1691
+ );
1692
+ }
1693
+
1694
+ const data: ExposePortResponse = await response.json();
1695
+ console.log(
1696
+ `[HTTP Client] Port exposed: ${port}${name ? ` (${name})` : ""}, Success: ${data.success}`
1697
+ );
1698
+
1699
+ return data;
1700
+ } catch (error) {
1701
+ console.error("[HTTP Client] Error exposing port:", error);
1702
+ throw error;
1703
+ }
1704
+ }
1705
+
1706
+ async unexposePort(port: number): Promise<UnexposePortResponse> {
1707
+ try {
1708
+ const response = await this.doFetch(`/api/unexpose-port`, {
1709
+ body: JSON.stringify({
1710
+ port,
1711
+ }),
1712
+ headers: {
1713
+ "Content-Type": "application/json",
1714
+ },
1715
+ method: "DELETE",
1716
+ });
1717
+
1718
+ if (!response.ok) {
1719
+ const errorData = (await response.json().catch(() => ({}))) as {
1720
+ error?: string;
1721
+ };
1722
+ throw new Error(
1723
+ errorData.error || `HTTP error! status: ${response.status}`
1724
+ );
1725
+ }
1726
+
1727
+ const data: UnexposePortResponse = await response.json();
1728
+ console.log(
1729
+ `[HTTP Client] Port unexposed: ${port}, Success: ${data.success}`
1730
+ );
1731
+
1732
+ return data;
1733
+ } catch (error) {
1734
+ console.error("[HTTP Client] Error unexposing port:", error);
1735
+ throw error;
1736
+ }
1737
+ }
1738
+
1739
+ async getExposedPorts(): Promise<GetExposedPortsResponse> {
1740
+ try {
1741
+ const response = await this.doFetch(`/api/exposed-ports`, {
1742
+ headers: {
1743
+ "Content-Type": "application/json",
1744
+ },
1745
+ method: "GET",
1746
+ });
1747
+
1748
+ if (!response.ok) {
1749
+ const errorData = (await response.json().catch(() => ({}))) as {
1750
+ error?: string;
1751
+ };
1752
+ throw new Error(
1753
+ errorData.error || `HTTP error! status: ${response.status}`
1754
+ );
1755
+ }
1756
+
1757
+ const data: GetExposedPortsResponse = await response.json();
1758
+ console.log(
1759
+ `[HTTP Client] Got ${data.count} exposed ports`
1760
+ );
1761
+
1762
+ return data;
1763
+ } catch (error) {
1764
+ console.error("[HTTP Client] Error getting exposed ports:", error);
1765
+ throw error;
1766
+ }
1767
+ }
1768
+
1609
1769
  async ping(): Promise<string> {
1610
1770
  try {
1611
1771
  const response = await this.doFetch(`/api/ping`, {
package/src/index.ts CHANGED
@@ -1,129 +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
-
80
- async writeFile(
81
- path: string,
82
- content: string,
83
- options: { encoding?: string; stream?: boolean }
84
- ) {
85
- if (options?.stream) {
86
- return this.client.writeFileStream(path, content, options.encoding);
87
- }
88
- return this.client.writeFile(path, content, options.encoding);
89
- }
90
-
91
- async deleteFile(path: string, options: { stream?: boolean }) {
92
- if (options?.stream) {
93
- return this.client.deleteFileStream(path);
94
- }
95
- return this.client.deleteFile(path);
96
- }
97
-
98
- async renameFile(
99
- oldPath: string,
100
- newPath: string,
101
- options: { stream?: boolean }
102
- ) {
103
- if (options?.stream) {
104
- return this.client.renameFileStream(oldPath, newPath);
105
- }
106
- return this.client.renameFile(oldPath, newPath);
107
- }
108
-
109
- async moveFile(
110
- sourcePath: string,
111
- destinationPath: string,
112
- options: { stream?: boolean }
113
- ) {
114
- if (options?.stream) {
115
- return this.client.moveFileStream(sourcePath, destinationPath);
116
- }
117
- return this.client.moveFile(sourcePath, destinationPath);
118
- }
119
-
120
- async readFile(
121
- path: string,
122
- options: { encoding?: string; stream?: boolean }
123
- ) {
124
- if (options?.stream) {
125
- return this.client.readFileStream(path, options.encoding);
126
- }
127
- return this.client.readFile(path, options.encoding);
128
- }
129
- }
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
+