@cloudflare/sandbox 0.0.9 → 0.1.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/Dockerfile +1 -14
  3. package/container_src/handler/exec.ts +337 -0
  4. package/container_src/handler/file.ts +844 -0
  5. package/container_src/handler/git.ts +182 -0
  6. package/container_src/handler/ports.ts +314 -0
  7. package/container_src/handler/process.ts +640 -0
  8. package/container_src/index.ts +82 -2973
  9. package/container_src/types.ts +103 -0
  10. package/dist/chunk-6THNBO4S.js +46 -0
  11. package/dist/chunk-6THNBO4S.js.map +1 -0
  12. package/dist/chunk-6UAWTJ5S.js +85 -0
  13. package/dist/chunk-6UAWTJ5S.js.map +1 -0
  14. package/dist/chunk-G4XT4SP7.js +638 -0
  15. package/dist/chunk-G4XT4SP7.js.map +1 -0
  16. package/dist/chunk-ISFOIYQC.js +585 -0
  17. package/dist/chunk-ISFOIYQC.js.map +1 -0
  18. package/dist/chunk-NNGBXDMY.js +89 -0
  19. package/dist/chunk-NNGBXDMY.js.map +1 -0
  20. package/dist/client-Da-mLX4p.d.ts +210 -0
  21. package/dist/client.d.ts +2 -1
  22. package/dist/client.js +3 -37
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.js +13 -3
  25. package/dist/request-handler.d.ts +2 -1
  26. package/dist/request-handler.js +4 -2
  27. package/dist/sandbox.d.ts +2 -1
  28. package/dist/sandbox.js +4 -2
  29. package/dist/security.d.ts +30 -0
  30. package/dist/security.js +13 -0
  31. package/dist/security.js.map +1 -0
  32. package/dist/sse-parser.d.ts +28 -0
  33. package/dist/sse-parser.js +11 -0
  34. package/dist/sse-parser.js.map +1 -0
  35. package/dist/types.d.ts +284 -0
  36. package/dist/types.js +19 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +2 -7
  39. package/src/client.ts +235 -1286
  40. package/src/index.ts +6 -0
  41. package/src/request-handler.ts +69 -20
  42. package/src/sandbox.ts +463 -70
  43. package/src/security.ts +113 -0
  44. package/src/sse-parser.ts +147 -0
  45. package/src/types.ts +386 -0
  46. package/README.md +0 -65
  47. package/dist/chunk-4J5LQCCN.js +0 -1446
  48. package/dist/chunk-4J5LQCCN.js.map +0 -1
  49. package/dist/chunk-5SZ3RVJZ.js +0 -250
  50. package/dist/chunk-5SZ3RVJZ.js.map +0 -1
  51. package/dist/client-BuVjqV00.d.ts +0 -247
  52. package/tests/client.example.ts +0 -308
  53. package/tests/connection-test.ts +0 -81
  54. package/tests/simple-test.ts +0 -81
  55. package/tests/test1.ts +0 -281
  56. package/tests/test2.ts +0 -929
package/src/sandbox.ts CHANGED
@@ -1,6 +1,25 @@
1
1
  import { Container, getContainer } from "@cloudflare/containers";
2
2
  import { HttpClient } from "./client";
3
3
  import { isLocalhostPattern } from "./request-handler";
4
+ import {
5
+ logSecurityEvent,
6
+ SecurityError,
7
+ sanitizeSandboxId,
8
+ validatePort
9
+ } from "./security";
10
+ import type {
11
+ ExecOptions,
12
+ ExecResult,
13
+ ISandbox,
14
+ Process,
15
+ ProcessOptions,
16
+ ProcessStatus,
17
+ StreamOptions
18
+ } from "./types";
19
+ import {
20
+ ProcessNotFoundError,
21
+ SandboxError
22
+ } from "./types";
4
23
 
5
24
  export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
6
25
  const stub = getContainer(ns, id);
@@ -11,26 +30,25 @@ export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
11
30
  return stub;
12
31
  }
13
32
 
14
- export class Sandbox<Env = unknown> extends Container<Env> {
33
+ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
15
34
  sleepAfter = "3m"; // Sleep the sandbox if no requests are made in this timeframe
16
35
  client: HttpClient;
17
- private workerHostname: string | null = null;
18
36
  private sandboxName: string | null = null;
19
37
 
20
38
  constructor(ctx: DurableObjectState, env: Env) {
21
39
  super(ctx, env);
22
40
  this.client = new HttpClient({
23
- onCommandComplete: (success, exitCode, _stdout, _stderr, command, _args) => {
41
+ onCommandComplete: (success, exitCode, _stdout, _stderr, command) => {
24
42
  console.log(
25
43
  `[Container] Command completed: ${command}, Success: ${success}, Exit code: ${exitCode}`
26
44
  );
27
45
  },
28
- onCommandStart: (command, args) => {
46
+ onCommandStart: (command) => {
29
47
  console.log(
30
- `[Container] Command started: ${command} ${args.join(" ")}`
48
+ `[Container] Command started: ${command}`
31
49
  );
32
50
  },
33
- onError: (error, _command, _args) => {
51
+ onError: (error, _command) => {
34
52
  console.error(`[Container] Command error: ${error}`);
35
53
  },
36
54
  onOutput: (stream, data, _command) => {
@@ -55,6 +73,12 @@ export class Sandbox<Env = unknown> extends Container<Env> {
55
73
  }
56
74
  }
57
75
 
76
+ // RPC method to set environment variables
77
+ async setEnvVars(envVars: Record<string, string>): Promise<void> {
78
+ this.envVars = { ...this.envVars, ...envVars };
79
+ console.log(`[Sandbox] Updated environment variables`);
80
+ }
81
+
58
82
  override onStart() {
59
83
  console.log("Sandbox successfully started");
60
84
  }
@@ -70,16 +94,10 @@ export class Sandbox<Env = unknown> extends Container<Env> {
70
94
  console.log("Sandbox error:", error);
71
95
  }
72
96
 
73
- // Override fetch to capture the hostname and route to appropriate ports
97
+ // Override fetch to route internal container requests to appropriate ports
74
98
  override async fetch(request: Request): Promise<Response> {
75
99
  const url = new URL(request.url);
76
100
 
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
101
  // Capture and store the sandbox name from the header if present
84
102
  if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
85
103
  const name = request.headers.get('X-Sandbox-Name')!;
@@ -107,88 +125,385 @@ export class Sandbox<Env = unknown> extends Container<Env> {
107
125
  return 3000;
108
126
  }
109
127
 
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);
128
+ // Enhanced exec method - always returns ExecResult with optional streaming
129
+ // This replaces the old exec method to match ISandbox interface
130
+ async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
131
+ const startTime = Date.now();
132
+ const timestamp = new Date().toISOString();
133
+
134
+ // Handle timeout
135
+ let timeoutId: NodeJS.Timeout | undefined;
136
+
137
+ try {
138
+ // Handle cancellation
139
+ if (options?.signal?.aborted) {
140
+ throw new Error('Operation was aborted');
141
+ }
142
+
143
+ let result: ExecResult;
144
+
145
+ if (options?.stream && options?.onOutput) {
146
+ // Streaming with callbacks - we need to collect the final result
147
+ result = await this.executeWithStreaming(command, options, startTime, timestamp);
148
+ } else {
149
+ // Regular execution
150
+ const response = await this.client.execute(
151
+ command,
152
+ options?.sessionId
153
+ );
154
+
155
+ const duration = Date.now() - startTime;
156
+ result = this.mapExecuteResponseToExecResult(response, duration, options?.sessionId);
157
+ }
158
+
159
+ // Call completion callback if provided
160
+ if (options?.onComplete) {
161
+ options.onComplete(result);
162
+ }
163
+
164
+ return result;
165
+ } catch (error) {
166
+ if (options?.onError && error instanceof Error) {
167
+ options.onError(error);
168
+ }
169
+ throw error;
170
+ } finally {
171
+ if (timeoutId) {
172
+ clearTimeout(timeoutId);
173
+ }
174
+ }
175
+ }
176
+
177
+ private async executeWithStreaming(
178
+ command: string,
179
+ options: ExecOptions,
180
+ startTime: number,
181
+ timestamp: string
182
+ ): Promise<ExecResult> {
183
+ let stdout = '';
184
+ let stderr = '';
185
+
186
+ try {
187
+ const stream = await this.client.executeCommandStream(command, options.sessionId);
188
+ const { parseSSEStream } = await import('./sse-parser');
189
+
190
+ for await (const event of parseSSEStream<import('./types').ExecEvent>(stream)) {
191
+ // Check for cancellation
192
+ if (options.signal?.aborted) {
193
+ throw new Error('Operation was aborted');
194
+ }
195
+
196
+ switch (event.type) {
197
+ case 'stdout':
198
+ case 'stderr':
199
+ if (event.data) {
200
+ // Update accumulated output
201
+ if (event.type === 'stdout') stdout += event.data;
202
+ if (event.type === 'stderr') stderr += event.data;
203
+
204
+ // Call user's callback
205
+ if (options.onOutput) {
206
+ options.onOutput(event.type, event.data);
207
+ }
208
+ }
209
+ break;
210
+
211
+ case 'complete': {
212
+ // Use result from complete event if available
213
+ const duration = Date.now() - startTime;
214
+ return event.result || {
215
+ success: event.exitCode === 0,
216
+ exitCode: event.exitCode || 0,
217
+ stdout,
218
+ stderr,
219
+ command,
220
+ duration,
221
+ timestamp,
222
+ sessionId: options.sessionId
223
+ };
224
+ }
225
+
226
+ case 'error':
227
+ throw new Error(event.error || 'Command execution failed');
228
+ }
229
+ }
230
+
231
+ // If we get here without a complete event, something went wrong
232
+ throw new Error('Stream ended without completion event');
233
+
234
+ } catch (error) {
235
+ if (options.signal?.aborted) {
236
+ throw new Error('Operation was aborted');
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ private mapExecuteResponseToExecResult(
243
+ response: import('./client').ExecuteResponse,
244
+ duration: number,
245
+ sessionId?: string
246
+ ): ExecResult {
247
+ return {
248
+ success: response.success,
249
+ exitCode: response.exitCode,
250
+ stdout: response.stdout,
251
+ stderr: response.stderr,
252
+ command: response.command,
253
+ duration,
254
+ timestamp: response.timestamp,
255
+ sessionId
256
+ };
257
+ }
258
+
259
+
260
+ // Background process management
261
+ async startProcess(command: string, options?: ProcessOptions): Promise<Process> {
262
+ // Use the new HttpClient method to start the process
263
+ try {
264
+ const response = await this.client.startProcess(command, {
265
+ processId: options?.processId,
266
+ sessionId: options?.sessionId,
267
+ timeout: options?.timeout,
268
+ env: options?.env,
269
+ cwd: options?.cwd,
270
+ encoding: options?.encoding,
271
+ autoCleanup: options?.autoCleanup
272
+ });
273
+
274
+ const process = response.process;
275
+ const processObj: Process = {
276
+ id: process.id,
277
+ pid: process.pid,
278
+ command: process.command,
279
+ status: process.status as ProcessStatus,
280
+ startTime: new Date(process.startTime),
281
+ endTime: undefined,
282
+ exitCode: undefined,
283
+ sessionId: process.sessionId,
284
+
285
+ async kill(): Promise<void> {
286
+ throw new Error('Method will be replaced');
287
+ },
288
+ async getStatus(): Promise<ProcessStatus> {
289
+ throw new Error('Method will be replaced');
290
+ },
291
+ async getLogs(): Promise<{ stdout: string; stderr: string }> {
292
+ throw new Error('Method will be replaced');
293
+ }
294
+ };
295
+
296
+ // Bind context properly
297
+ processObj.kill = async (signal?: string) => {
298
+ await this.killProcess(process.id, signal);
299
+ };
300
+
301
+ processObj.getStatus = async () => {
302
+ const current = await this.getProcess(process.id);
303
+ return current?.status || 'error';
304
+ };
305
+
306
+ processObj.getLogs = async () => {
307
+ const logs = await this.getProcessLogs(process.id);
308
+ return { stdout: logs.stdout, stderr: logs.stderr };
309
+ };
310
+
311
+ // Call onStart callback if provided
312
+ if (options?.onStart) {
313
+ options.onStart(processObj);
314
+ }
315
+
316
+ return processObj;
317
+
318
+ } catch (error) {
319
+ if (options?.onError && error instanceof Error) {
320
+ options.onError(error);
321
+ }
322
+
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ async listProcesses(): Promise<Process[]> {
328
+ const response = await this.client.listProcesses();
329
+
330
+ return response.processes.map(processData => ({
331
+ id: processData.id,
332
+ pid: processData.pid,
333
+ command: processData.command,
334
+ status: processData.status,
335
+ startTime: new Date(processData.startTime),
336
+ endTime: processData.endTime ? new Date(processData.endTime) : undefined,
337
+ exitCode: processData.exitCode,
338
+ sessionId: processData.sessionId,
339
+
340
+ kill: async (signal?: string) => {
341
+ await this.killProcess(processData.id, signal);
342
+ },
343
+
344
+ getStatus: async () => {
345
+ const current = await this.getProcess(processData.id);
346
+ return current?.status || 'error';
347
+ },
348
+
349
+ getLogs: async () => {
350
+ const logs = await this.getProcessLogs(processData.id);
351
+ return { stdout: logs.stdout, stderr: logs.stderr };
352
+ }
353
+ }));
354
+ }
355
+
356
+ async getProcess(id: string): Promise<Process | null> {
357
+ const response = await this.client.getProcess(id);
358
+ if (!response.process) {
359
+ return null;
360
+ }
361
+
362
+ const processData = response.process;
363
+ return {
364
+ id: processData.id,
365
+ pid: processData.pid,
366
+ command: processData.command,
367
+ status: processData.status,
368
+ startTime: new Date(processData.startTime),
369
+ endTime: processData.endTime ? new Date(processData.endTime) : undefined,
370
+ exitCode: processData.exitCode,
371
+ sessionId: processData.sessionId,
372
+
373
+ kill: async (signal?: string) => {
374
+ await this.killProcess(processData.id, signal);
375
+ },
376
+
377
+ getStatus: async () => {
378
+ const current = await this.getProcess(processData.id);
379
+ return current?.status || 'error';
380
+ },
381
+
382
+ getLogs: async () => {
383
+ const logs = await this.getProcessLogs(processData.id);
384
+ return { stdout: logs.stdout, stderr: logs.stderr };
385
+ }
386
+ };
387
+ }
388
+
389
+ async killProcess(id: string, _signal?: string): Promise<void> {
390
+ try {
391
+ // Note: signal parameter is not currently supported by the HttpClient implementation
392
+ await this.client.killProcess(id);
393
+ } catch (error) {
394
+ if (error instanceof Error && error.message.includes('Process not found')) {
395
+ throw new ProcessNotFoundError(id);
396
+ }
397
+ throw new SandboxError(
398
+ `Failed to kill process ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
399
+ 'KILL_PROCESS_FAILED'
400
+ );
401
+ }
402
+ }
403
+
404
+ async killAllProcesses(): Promise<number> {
405
+ const response = await this.client.killAllProcesses();
406
+ return response.killedCount;
407
+ }
408
+
409
+ async cleanupCompletedProcesses(): Promise<number> {
410
+ // For now, this would need to be implemented as a container endpoint
411
+ // as we no longer maintain local process storage
412
+ // We'll return 0 as a placeholder until the container endpoint is added
413
+ return 0;
414
+ }
415
+
416
+ async getProcessLogs(id: string): Promise<{ stdout: string; stderr: string }> {
417
+ try {
418
+ const response = await this.client.getProcessLogs(id);
419
+ return {
420
+ stdout: response.stdout,
421
+ stderr: response.stderr
422
+ };
423
+ } catch (error) {
424
+ if (error instanceof Error && error.message.includes('Process not found')) {
425
+ throw new ProcessNotFoundError(id);
426
+ }
427
+ throw error;
113
428
  }
114
- return this.client.execute(command, args, undefined, options?.background);
429
+ }
430
+
431
+
432
+ // Streaming methods - return ReadableStream for RPC compatibility
433
+ async execStream(command: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>> {
434
+ // Check for cancellation
435
+ if (options?.signal?.aborted) {
436
+ throw new Error('Operation was aborted');
437
+ }
438
+
439
+ // Get the stream from HttpClient (need to add this method)
440
+ const stream = await this.client.executeCommandStream(command, options?.sessionId);
441
+
442
+ // Return the ReadableStream directly - can be converted to AsyncIterable by consumers
443
+ return stream;
444
+ }
445
+
446
+ async streamProcessLogs(processId: string, options?: { signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>> {
447
+ // Check for cancellation
448
+ if (options?.signal?.aborted) {
449
+ throw new Error('Operation was aborted');
450
+ }
451
+
452
+ // Get the stream from HttpClient
453
+ const stream = await this.client.streamProcessLogs(processId);
454
+
455
+ // Return the ReadableStream directly - can be converted to AsyncIterable by consumers
456
+ return stream;
115
457
  }
116
458
 
117
459
  async gitCheckout(
118
460
  repoUrl: string,
119
- options: { branch?: string; targetDir?: string; stream?: boolean }
461
+ options: { branch?: string; targetDir?: string }
120
462
  ) {
121
- if (options?.stream) {
122
- return this.client.gitCheckoutStream(
123
- repoUrl,
124
- options.branch,
125
- options.targetDir
126
- );
127
- }
128
463
  return this.client.gitCheckout(repoUrl, options.branch, options.targetDir);
129
464
  }
130
465
 
131
466
  async mkdir(
132
467
  path: string,
133
- options: { recursive?: boolean; stream?: boolean } = {}
468
+ options: { recursive?: boolean } = {}
134
469
  ) {
135
- if (options?.stream) {
136
- return this.client.mkdirStream(path, options.recursive);
137
- }
138
470
  return this.client.mkdir(path, options.recursive);
139
471
  }
140
472
 
141
473
  async writeFile(
142
474
  path: string,
143
475
  content: string,
144
- options: { encoding?: string; stream?: boolean } = {}
476
+ options: { encoding?: string } = {}
145
477
  ) {
146
- if (options?.stream) {
147
- return this.client.writeFileStream(path, content, options.encoding);
148
- }
149
478
  return this.client.writeFile(path, content, options.encoding);
150
479
  }
151
480
 
152
- async deleteFile(path: string, options: { stream?: boolean } = {}) {
153
- if (options?.stream) {
154
- return this.client.deleteFileStream(path);
155
- }
481
+ async deleteFile(path: string) {
156
482
  return this.client.deleteFile(path);
157
483
  }
158
484
 
159
485
  async renameFile(
160
486
  oldPath: string,
161
- newPath: string,
162
- options: { stream?: boolean } = {}
487
+ newPath: string
163
488
  ) {
164
- if (options?.stream) {
165
- return this.client.renameFileStream(oldPath, newPath);
166
- }
167
489
  return this.client.renameFile(oldPath, newPath);
168
490
  }
169
491
 
170
492
  async moveFile(
171
493
  sourcePath: string,
172
- destinationPath: string,
173
- options: { stream?: boolean } = {}
494
+ destinationPath: string
174
495
  ) {
175
- if (options?.stream) {
176
- return this.client.moveFileStream(sourcePath, destinationPath);
177
- }
178
496
  return this.client.moveFile(sourcePath, destinationPath);
179
497
  }
180
498
 
181
499
  async readFile(
182
500
  path: string,
183
- options: { encoding?: string; stream?: boolean } = {}
501
+ options: { encoding?: string } = {}
184
502
  ) {
185
- if (options?.stream) {
186
- return this.client.readFileStream(path, options.encoding);
187
- }
188
503
  return this.client.readFile(path, options.encoding);
189
504
  }
190
505
 
191
- async exposePort(port: number, options?: { name?: string }) {
506
+ async exposePort(port: number, options: { name?: string; hostname: string }) {
192
507
  await this.client.exposePort(port, options?.name);
193
508
 
194
509
  // We need the sandbox name to construct preview URLs
@@ -196,8 +511,7 @@ export class Sandbox<Env = unknown> extends Container<Env> {
196
511
  throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
197
512
  }
198
513
 
199
- const hostname = this.getHostname();
200
- const url = this.constructPreviewUrl(port, this.sandboxName, hostname);
514
+ const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname);
201
515
 
202
516
  return {
203
517
  url,
@@ -207,10 +521,21 @@ export class Sandbox<Env = unknown> extends Container<Env> {
207
521
  }
208
522
 
209
523
  async unexposePort(port: number) {
524
+ if (!validatePort(port)) {
525
+ logSecurityEvent('INVALID_PORT_UNEXPOSE', {
526
+ port
527
+ }, 'high');
528
+ throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
529
+ }
530
+
210
531
  await this.client.unexposePort(port);
532
+
533
+ logSecurityEvent('PORT_UNEXPOSED', {
534
+ port
535
+ }, 'low');
211
536
  }
212
537
 
213
- async getExposedPorts() {
538
+ async getExposedPorts(hostname: string) {
214
539
  const response = await this.client.getExposedPorts();
215
540
 
216
541
  // We need the sandbox name to construct preview URLs
@@ -218,8 +543,6 @@ export class Sandbox<Env = unknown> extends Container<Env> {
218
543
  throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
219
544
  }
220
545
 
221
- const hostname = this.getHostname();
222
-
223
546
  return response.ports.map(port => ({
224
547
  url: this.constructPreviewUrl(port.port, this.sandboxName!, hostname),
225
548
  port: port.port,
@@ -228,25 +551,95 @@ export class Sandbox<Env = unknown> extends Container<Env> {
228
551
  }));
229
552
  }
230
553
 
231
- private getHostname(): string {
232
- // Use the captured hostname or fall back to localhost for development
233
- return this.workerHostname || "localhost:8787";
234
- }
235
554
 
236
555
  private constructPreviewUrl(port: number, sandboxId: string, hostname: string): string {
237
- // Check if this is a localhost pattern
556
+ if (!validatePort(port)) {
557
+ logSecurityEvent('INVALID_PORT_REJECTED', {
558
+ port,
559
+ sandboxId,
560
+ hostname
561
+ }, 'high');
562
+ throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
563
+ }
564
+
565
+ let sanitizedSandboxId: string;
566
+ try {
567
+ sanitizedSandboxId = sanitizeSandboxId(sandboxId);
568
+ } catch (error) {
569
+ logSecurityEvent('INVALID_SANDBOX_ID_REJECTED', {
570
+ sandboxId,
571
+ port,
572
+ hostname,
573
+ error: error instanceof Error ? error.message : 'Unknown error'
574
+ }, 'high');
575
+ throw error;
576
+ }
577
+
238
578
  const isLocalhost = isLocalhostPattern(hostname);
239
579
 
240
580
  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}`;
581
+ // Unified subdomain approach for localhost (RFC 6761)
582
+ const [host, portStr] = hostname.split(':');
583
+ const mainPort = portStr || '80';
584
+
585
+ // Use URL constructor for safe URL building
586
+ try {
587
+ const baseUrl = new URL(`http://${host}:${mainPort}`);
588
+ // Construct subdomain safely
589
+ const subdomainHost = `${port}-${sanitizedSandboxId}.${host}`;
590
+ baseUrl.hostname = subdomainHost;
591
+
592
+ const finalUrl = baseUrl.toString();
593
+
594
+ logSecurityEvent('PREVIEW_URL_CONSTRUCTED', {
595
+ port,
596
+ sandboxId: sanitizedSandboxId,
597
+ hostname,
598
+ resultUrl: finalUrl,
599
+ environment: 'localhost'
600
+ }, 'low');
601
+
602
+ return finalUrl;
603
+ } catch (error) {
604
+ logSecurityEvent('URL_CONSTRUCTION_FAILED', {
605
+ port,
606
+ sandboxId: sanitizedSandboxId,
607
+ hostname,
608
+ error: error instanceof Error ? error.message : 'Unknown error'
609
+ }, 'high');
610
+ throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
611
+ }
245
612
  }
246
613
 
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}`;
614
+ // Production subdomain logic - enforce HTTPS
615
+ try {
616
+ // Always use HTTPS for production (non-localhost)
617
+ const protocol = "https";
618
+ const baseUrl = new URL(`${protocol}://${hostname}`);
619
+
620
+ // Construct subdomain safely
621
+ const subdomainHost = `${port}-${sanitizedSandboxId}.${hostname}`;
622
+ baseUrl.hostname = subdomainHost;
623
+
624
+ const finalUrl = baseUrl.toString();
625
+
626
+ logSecurityEvent('PREVIEW_URL_CONSTRUCTED', {
627
+ port,
628
+ sandboxId: sanitizedSandboxId,
629
+ hostname,
630
+ resultUrl: finalUrl,
631
+ environment: 'production'
632
+ }, 'low');
633
+
634
+ return finalUrl;
635
+ } catch (error) {
636
+ logSecurityEvent('URL_CONSTRUCTION_FAILED', {
637
+ port,
638
+ sandboxId: sanitizedSandboxId,
639
+ hostname,
640
+ error: error instanceof Error ? error.message : 'Unknown error'
641
+ }, 'high');
642
+ throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
643
+ }
251
644
  }
252
645
  }