@cloudflare/sandbox 0.0.8 → 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 +16 -0
  2. package/Dockerfile +73 -9
  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 +102 -2647
  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 +5 -200
  24. package/dist/index.js +17 -106
  25. package/dist/index.js.map +1 -1
  26. package/dist/request-handler.d.ts +16 -0
  27. package/dist/request-handler.js +12 -0
  28. package/dist/request-handler.js.map +1 -0
  29. package/dist/sandbox.d.ts +3 -0
  30. package/dist/sandbox.js +12 -0
  31. package/dist/sandbox.js.map +1 -0
  32. package/dist/security.d.ts +30 -0
  33. package/dist/security.js +13 -0
  34. package/dist/security.js.map +1 -0
  35. package/dist/sse-parser.d.ts +28 -0
  36. package/dist/sse-parser.js +11 -0
  37. package/dist/sse-parser.js.map +1 -0
  38. package/dist/types.d.ts +284 -0
  39. package/dist/types.js +19 -0
  40. package/dist/types.js.map +1 -0
  41. package/package.json +2 -7
  42. package/src/client.ts +320 -1242
  43. package/src/index.ts +20 -136
  44. package/src/request-handler.ts +144 -0
  45. package/src/sandbox.ts +645 -0
  46. package/src/security.ts +113 -0
  47. package/src/sse-parser.ts +147 -0
  48. package/src/types.ts +386 -0
  49. package/README.md +0 -65
  50. package/dist/chunk-7WZJ3TRE.js +0 -1364
  51. package/dist/chunk-7WZJ3TRE.js.map +0 -1
  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 ADDED
@@ -0,0 +1,645 @@
1
+ import { Container, getContainer } from "@cloudflare/containers";
2
+ import { HttpClient } from "./client";
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";
23
+
24
+ export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
25
+ const stub = getContainer(ns, id);
26
+
27
+ // Store the name on first access
28
+ stub.setSandboxName?.(id);
29
+
30
+ return stub;
31
+ }
32
+
33
+ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
34
+ sleepAfter = "3m"; // Sleep the sandbox if no requests are made in this timeframe
35
+ client: HttpClient;
36
+ private sandboxName: string | null = null;
37
+
38
+ constructor(ctx: DurableObjectState, env: Env) {
39
+ super(ctx, env);
40
+ this.client = new HttpClient({
41
+ onCommandComplete: (success, exitCode, _stdout, _stderr, command) => {
42
+ console.log(
43
+ `[Container] Command completed: ${command}, Success: ${success}, Exit code: ${exitCode}`
44
+ );
45
+ },
46
+ onCommandStart: (command) => {
47
+ console.log(
48
+ `[Container] Command started: ${command}`
49
+ );
50
+ },
51
+ onError: (error, _command) => {
52
+ console.error(`[Container] Command error: ${error}`);
53
+ },
54
+ onOutput: (stream, data, _command) => {
55
+ console.log(`[Container] [${stream}] ${data}`);
56
+ },
57
+ port: 3000, // Control plane port
58
+ stub: this,
59
+ });
60
+
61
+ // Load the sandbox name from storage on initialization
62
+ this.ctx.blockConcurrencyWhile(async () => {
63
+ this.sandboxName = await this.ctx.storage.get<string>('sandboxName') || null;
64
+ });
65
+ }
66
+
67
+ // RPC method to set the sandbox name
68
+ async setSandboxName(name: string): Promise<void> {
69
+ if (!this.sandboxName) {
70
+ this.sandboxName = name;
71
+ await this.ctx.storage.put('sandboxName', name);
72
+ console.log(`[Sandbox] Stored sandbox name via RPC: ${name}`);
73
+ }
74
+ }
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
+
82
+ override onStart() {
83
+ console.log("Sandbox successfully started");
84
+ }
85
+
86
+ override onStop() {
87
+ console.log("Sandbox successfully shut down");
88
+ if (this.client) {
89
+ this.client.clearSession();
90
+ }
91
+ }
92
+
93
+ override onError(error: unknown) {
94
+ console.log("Sandbox error:", error);
95
+ }
96
+
97
+ // Override fetch to route internal container requests to appropriate ports
98
+ override async fetch(request: Request): Promise<Response> {
99
+ const url = new URL(request.url);
100
+
101
+ // Capture and store the sandbox name from the header if present
102
+ if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
103
+ const name = request.headers.get('X-Sandbox-Name')!;
104
+ this.sandboxName = name;
105
+ await this.ctx.storage.put('sandboxName', name);
106
+ console.log(`[Sandbox] Stored sandbox name: ${this.sandboxName}`);
107
+ }
108
+
109
+ // Determine which port to route to
110
+ const port = this.determinePort(url);
111
+
112
+ // Route to the appropriate port
113
+ return await this.containerFetch(request, port);
114
+ }
115
+
116
+ private determinePort(url: URL): number {
117
+ // Extract port from proxy requests (e.g., /proxy/8080/*)
118
+ const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/);
119
+ if (proxyMatch) {
120
+ return parseInt(proxyMatch[1]);
121
+ }
122
+
123
+ // All other requests go to control plane on port 3000
124
+ // This includes /api/* endpoints and any other control requests
125
+ return 3000;
126
+ }
127
+
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;
428
+ }
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;
457
+ }
458
+
459
+ async gitCheckout(
460
+ repoUrl: string,
461
+ options: { branch?: string; targetDir?: string }
462
+ ) {
463
+ return this.client.gitCheckout(repoUrl, options.branch, options.targetDir);
464
+ }
465
+
466
+ async mkdir(
467
+ path: string,
468
+ options: { recursive?: boolean } = {}
469
+ ) {
470
+ return this.client.mkdir(path, options.recursive);
471
+ }
472
+
473
+ async writeFile(
474
+ path: string,
475
+ content: string,
476
+ options: { encoding?: string } = {}
477
+ ) {
478
+ return this.client.writeFile(path, content, options.encoding);
479
+ }
480
+
481
+ async deleteFile(path: string) {
482
+ return this.client.deleteFile(path);
483
+ }
484
+
485
+ async renameFile(
486
+ oldPath: string,
487
+ newPath: string
488
+ ) {
489
+ return this.client.renameFile(oldPath, newPath);
490
+ }
491
+
492
+ async moveFile(
493
+ sourcePath: string,
494
+ destinationPath: string
495
+ ) {
496
+ return this.client.moveFile(sourcePath, destinationPath);
497
+ }
498
+
499
+ async readFile(
500
+ path: string,
501
+ options: { encoding?: string } = {}
502
+ ) {
503
+ return this.client.readFile(path, options.encoding);
504
+ }
505
+
506
+ async exposePort(port: number, options: { name?: string; hostname: string }) {
507
+ await this.client.exposePort(port, options?.name);
508
+
509
+ // We need the sandbox name to construct preview URLs
510
+ if (!this.sandboxName) {
511
+ throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
512
+ }
513
+
514
+ const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname);
515
+
516
+ return {
517
+ url,
518
+ port,
519
+ name: options?.name,
520
+ };
521
+ }
522
+
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
+
531
+ await this.client.unexposePort(port);
532
+
533
+ logSecurityEvent('PORT_UNEXPOSED', {
534
+ port
535
+ }, 'low');
536
+ }
537
+
538
+ async getExposedPorts(hostname: string) {
539
+ const response = await this.client.getExposedPorts();
540
+
541
+ // We need the sandbox name to construct preview URLs
542
+ if (!this.sandboxName) {
543
+ throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
544
+ }
545
+
546
+ return response.ports.map(port => ({
547
+ url: this.constructPreviewUrl(port.port, this.sandboxName!, hostname),
548
+ port: port.port,
549
+ name: port.name,
550
+ exposedAt: port.exposedAt,
551
+ }));
552
+ }
553
+
554
+
555
+ private constructPreviewUrl(port: number, sandboxId: string, hostname: string): string {
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
+
578
+ const isLocalhost = isLocalhostPattern(hostname);
579
+
580
+ if (isLocalhost) {
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
+ }
612
+ }
613
+
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
+ }
644
+ }
645
+ }