@cloudflare/sandbox 0.2.1 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @cloudflare/sandbox
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#51](https://github.com/cloudflare/sandbox-sdk/pull/51) [`4aceb32`](https://github.com/cloudflare/sandbox-sdk/commit/4aceb3215c836f59afcb88b2b325016b3f623f46) Thanks [@ghostwriternr](https://github.com/ghostwriternr)! - Handle intermittent interpreter failures and decouple jupyter startup
8
+
3
9
  ## 0.2.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -72,7 +72,7 @@ npm install @cloudflare/sandbox
72
72
  1. **Create a Dockerfile** (temporary requirement, will be removed in future releases):
73
73
 
74
74
  ```dockerfile
75
- FROM docker.io/cloudflare/sandbox:0.2.1
75
+ FROM docker.io/cloudflare/sandbox:0.2.2
76
76
 
77
77
  # Expose the ports you want to expose
78
78
  EXPOSE 3000
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Circuit Breaker implementation to prevent cascading failures
3
+ */
4
+ export class CircuitBreaker {
5
+ private failures = 0;
6
+ private lastFailure: number = 0;
7
+ private successCount = 0;
8
+ private state: "closed" | "open" | "half-open" = "closed";
9
+
10
+ // Configuration
11
+ private readonly threshold: number;
12
+ private readonly timeout: number;
13
+ private readonly halfOpenSuccessThreshold: number;
14
+ private readonly name: string;
15
+
16
+ constructor(options: {
17
+ name: string;
18
+ threshold?: number;
19
+ timeout?: number;
20
+ halfOpenSuccessThreshold?: number;
21
+ }) {
22
+ this.name = options.name;
23
+ this.threshold = options.threshold || 5;
24
+ this.timeout = options.timeout || 30000; // 30 seconds
25
+ this.halfOpenSuccessThreshold = options.halfOpenSuccessThreshold || 3;
26
+ }
27
+
28
+ /**
29
+ * Execute an operation with circuit breaker protection
30
+ */
31
+ async execute<T>(operation: () => Promise<T>): Promise<T> {
32
+ // Check circuit state
33
+ if (this.state === "open") {
34
+ if (Date.now() - this.lastFailure > this.timeout) {
35
+ console.log(
36
+ `[CircuitBreaker ${this.name}] Transitioning from open to half-open`
37
+ );
38
+ this.state = "half-open";
39
+ this.successCount = 0;
40
+ } else {
41
+ throw new Error(
42
+ `Circuit breaker is open for ${this.name}. Retry after ${
43
+ this.timeout - (Date.now() - this.lastFailure)
44
+ }ms`
45
+ );
46
+ }
47
+ }
48
+
49
+ try {
50
+ const result = await operation();
51
+
52
+ // Record success
53
+ if (this.state === "half-open") {
54
+ this.successCount++;
55
+ if (this.successCount >= this.halfOpenSuccessThreshold) {
56
+ console.log(
57
+ `[CircuitBreaker ${this.name}] Transitioning from half-open to closed`
58
+ );
59
+ this.state = "closed";
60
+ this.failures = 0;
61
+ }
62
+ } else if (this.state === "closed") {
63
+ // Reset failure count on success
64
+ this.failures = 0;
65
+ }
66
+
67
+ return result;
68
+ } catch (error) {
69
+ this.recordFailure();
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Record a failure and update circuit state
76
+ */
77
+ private recordFailure() {
78
+ this.failures++;
79
+ this.lastFailure = Date.now();
80
+
81
+ if (this.state === "half-open") {
82
+ console.log(
83
+ `[CircuitBreaker ${this.name}] Failure in half-open state, transitioning to open`
84
+ );
85
+ this.state = "open";
86
+ } else if (this.failures >= this.threshold) {
87
+ console.log(
88
+ `[CircuitBreaker ${this.name}] Threshold reached (${this.failures}/${this.threshold}), transitioning to open`
89
+ );
90
+ this.state = "open";
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Get current circuit breaker state
96
+ */
97
+ getState(): {
98
+ state: string;
99
+ failures: number;
100
+ lastFailure: number;
101
+ isOpen: boolean;
102
+ } {
103
+ return {
104
+ state: this.state,
105
+ failures: this.failures,
106
+ lastFailure: this.lastFailure,
107
+ isOpen: this.state === "open",
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Reset the circuit breaker
113
+ */
114
+ reset() {
115
+ this.state = "closed";
116
+ this.failures = 0;
117
+ this.successCount = 0;
118
+ this.lastFailure = 0;
119
+ console.log(`[CircuitBreaker ${this.name}] Reset to closed state`);
120
+ }
121
+ }
@@ -1,6 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { serve } from "bun";
3
- import { handleExecuteRequest, handleStreamingExecuteRequest } from "./handler/exec";
3
+ import {
4
+ handleExecuteRequest,
5
+ handleStreamingExecuteRequest,
6
+ } from "./handler/exec";
4
7
  import {
5
8
  handleDeleteFileRequest,
6
9
  handleMkdirRequest,
@@ -25,7 +28,8 @@ import {
25
28
  handleStartProcessRequest,
26
29
  handleStreamProcessLogsRequest,
27
30
  } from "./handler/process";
28
- import { type CreateContextRequest, JupyterServer } from "./jupyter-server";
31
+ import type { CreateContextRequest } from "./jupyter-server";
32
+ import { JupyterNotReadyError, JupyterService } from "./jupyter-service";
29
33
  import type { ProcessRecord, SessionData } from "./types";
30
34
 
31
35
  // In-memory session storage (in production, you'd want to use a proper database)
@@ -39,7 +43,7 @@ const processes = new Map<string, ProcessRecord>();
39
43
 
40
44
  // Generate a unique session ID using cryptographically secure randomness
41
45
  function generateSessionId(): string {
42
- return `session_${Date.now()}_${randomBytes(6).toString('hex')}`;
46
+ return `session_${Date.now()}_${randomBytes(6).toString("hex")}`;
43
47
  }
44
48
 
45
49
  // Clean up old sessions (older than 1 hour)
@@ -56,25 +60,28 @@ function cleanupOldSessions() {
56
60
  // Run cleanup every 10 minutes
57
61
  setInterval(cleanupOldSessions, 10 * 60 * 1000);
58
62
 
59
- // Initialize Jupyter server
60
- const jupyterServer = new JupyterServer();
61
- let jupyterInitialized = false;
62
-
63
- // Initialize Jupyter immediately since startup.sh ensures it's ready
64
- (async () => {
65
- try {
66
- await jupyterServer.initialize();
67
- jupyterInitialized = true;
68
- console.log("[Container] Jupyter integration initialized successfully");
69
- } catch (error) {
70
- console.error("[Container] Failed to initialize Jupyter:", error);
71
- // Log more details to help debug
72
- if (error instanceof Error) {
73
- console.error("[Container] Error details:", error.message);
74
- console.error("[Container] Stack trace:", error.stack);
75
- }
76
- }
77
- })();
63
+ // Initialize Jupyter service with graceful degradation
64
+ const jupyterService = new JupyterService();
65
+
66
+ // Start Jupyter initialization in background (non-blocking)
67
+ console.log("[Container] Starting Jupyter initialization in background...");
68
+ console.log(
69
+ "[Container] API endpoints are available immediately. Jupyter-dependent features will be available shortly."
70
+ );
71
+
72
+ jupyterService
73
+ .initialize()
74
+ .then(() => {
75
+ console.log(
76
+ "[Container] Jupyter fully initialized - all features available"
77
+ );
78
+ })
79
+ .catch((error) => {
80
+ console.error("[Container] Jupyter initialization failed:", error.message);
81
+ console.error(
82
+ "[Container] The API will continue in degraded mode without code execution capabilities"
83
+ );
84
+ });
78
85
 
79
86
  const server = serve({
80
87
  async fetch(req: Request) {
@@ -176,11 +183,17 @@ const server = serve({
176
183
 
177
184
  case "/api/ping":
178
185
  if (req.method === "GET") {
186
+ const health = await jupyterService.getHealthStatus();
179
187
  return new Response(
180
188
  JSON.stringify({
181
189
  message: "pong",
182
190
  timestamp: new Date().toISOString(),
183
- jupyter: jupyterInitialized ? "ready" : "not ready",
191
+ jupyter: health.ready
192
+ ? "ready"
193
+ : health.initializing
194
+ ? "initializing"
195
+ : "not ready",
196
+ jupyterHealth: health,
184
197
  }),
185
198
  {
186
199
  headers: {
@@ -305,30 +318,16 @@ const server = serve({
305
318
  // Code interpreter endpoints
306
319
  case "/api/contexts":
307
320
  if (req.method === "POST") {
308
- if (!jupyterInitialized) {
309
- return new Response(
310
- JSON.stringify({
311
- error: "Jupyter server is not ready. Please try again in a moment."
312
- }),
313
- {
314
- status: 503,
315
- headers: {
316
- "Content-Type": "application/json",
317
- ...corsHeaders,
318
- },
319
- }
320
- );
321
- }
322
321
  try {
323
- const body = await req.json() as CreateContextRequest;
324
- const context = await jupyterServer.createContext(body);
322
+ const body = (await req.json()) as CreateContextRequest;
323
+ const context = await jupyterService.createContext(body);
325
324
  return new Response(
326
325
  JSON.stringify({
327
326
  id: context.id,
328
327
  language: context.language,
329
328
  cwd: context.cwd,
330
329
  createdAt: context.createdAt,
331
- lastUsed: context.lastUsed
330
+ lastUsed: context.lastUsed,
332
331
  }),
333
332
  {
334
333
  headers: {
@@ -338,10 +337,63 @@ const server = serve({
338
337
  }
339
338
  );
340
339
  } catch (error) {
340
+ if (error instanceof JupyterNotReadyError) {
341
+ // This happens when request times out waiting for Jupyter
342
+ console.log(
343
+ `[Container] Request timed out waiting for Jupyter (${error.progress}% complete)`
344
+ );
345
+ return new Response(
346
+ JSON.stringify({
347
+ error: error.message,
348
+ status: "initializing",
349
+ progress: error.progress,
350
+ }),
351
+ {
352
+ status: 503,
353
+ headers: {
354
+ "Content-Type": "application/json",
355
+ "Retry-After": String(error.retryAfter),
356
+ ...corsHeaders,
357
+ },
358
+ }
359
+ );
360
+ }
361
+
362
+ // Check if it's a circuit breaker error
363
+ if (
364
+ error instanceof Error &&
365
+ error.message.includes("Circuit breaker is open")
366
+ ) {
367
+ console.log(
368
+ "[Container] Circuit breaker is open:",
369
+ error.message
370
+ );
371
+ return new Response(
372
+ JSON.stringify({
373
+ error:
374
+ "Service temporarily unavailable due to high error rate. Please try again later.",
375
+ status: "circuit_open",
376
+ details: error.message,
377
+ }),
378
+ {
379
+ status: 503,
380
+ headers: {
381
+ "Content-Type": "application/json",
382
+ "Retry-After": "60",
383
+ ...corsHeaders,
384
+ },
385
+ }
386
+ );
387
+ }
388
+
389
+ // Only log actual errors with stack traces
341
390
  console.error("[Container] Error creating context:", error);
342
391
  return new Response(
343
392
  JSON.stringify({
344
- error: error instanceof Error ? error.message : "Failed to create context"
393
+ error:
394
+ error instanceof Error
395
+ ? error.message
396
+ : "Failed to create context",
345
397
  }),
346
398
  {
347
399
  status: 500,
@@ -353,54 +405,75 @@ const server = serve({
353
405
  );
354
406
  }
355
407
  } else if (req.method === "GET") {
356
- if (!jupyterInitialized) {
357
- return new Response(
358
- JSON.stringify({ contexts: [] }),
359
- {
360
- headers: {
361
- "Content-Type": "application/json",
362
- ...corsHeaders,
363
- },
364
- }
365
- );
366
- }
367
- const contexts = await jupyterServer.listContexts();
368
- return new Response(
369
- JSON.stringify({ contexts }),
370
- {
371
- headers: {
372
- "Content-Type": "application/json",
373
- ...corsHeaders,
374
- },
375
- }
376
- );
408
+ const contexts = await jupyterService.listContexts();
409
+ return new Response(JSON.stringify({ contexts }), {
410
+ headers: {
411
+ "Content-Type": "application/json",
412
+ ...corsHeaders,
413
+ },
414
+ });
377
415
  }
378
416
  break;
379
417
 
380
418
  case "/api/execute/code":
381
419
  if (req.method === "POST") {
382
- if (!jupyterInitialized) {
383
- return new Response(
384
- JSON.stringify({
385
- error: "Jupyter server is not ready. Please try again in a moment."
386
- }),
387
- {
388
- status: 503,
389
- headers: {
390
- "Content-Type": "application/json",
391
- ...corsHeaders,
392
- },
393
- }
394
- );
395
- }
396
420
  try {
397
- const body = await req.json() as { context_id: string; code: string; language?: string };
398
- return await jupyterServer.executeCode(body.context_id, body.code, body.language);
421
+ const body = (await req.json()) as {
422
+ context_id: string;
423
+ code: string;
424
+ language?: string;
425
+ };
426
+ return await jupyterService.executeCode(
427
+ body.context_id,
428
+ body.code,
429
+ body.language
430
+ );
399
431
  } catch (error) {
400
- console.error("[Container] Error executing code:", error);
432
+ // Check if it's a circuit breaker error
433
+ if (
434
+ error instanceof Error &&
435
+ error.message.includes("Circuit breaker is open")
436
+ ) {
437
+ console.log(
438
+ "[Container] Circuit breaker is open for code execution:",
439
+ error.message
440
+ );
441
+ return new Response(
442
+ JSON.stringify({
443
+ error:
444
+ "Service temporarily unavailable due to high error rate. Please try again later.",
445
+ status: "circuit_open",
446
+ details: error.message,
447
+ }),
448
+ {
449
+ status: 503,
450
+ headers: {
451
+ "Content-Type": "application/json",
452
+ "Retry-After": "30",
453
+ ...corsHeaders,
454
+ },
455
+ }
456
+ );
457
+ }
458
+
459
+ // Don't log stack traces for expected initialization state
460
+ if (
461
+ error instanceof Error &&
462
+ error.message.includes("initializing")
463
+ ) {
464
+ console.log(
465
+ "[Container] Code execution deferred - Jupyter still initializing"
466
+ );
467
+ } else {
468
+ console.error("[Container] Error executing code:", error);
469
+ }
470
+ // Error response is already handled by jupyterService.executeCode for not ready state
401
471
  return new Response(
402
472
  JSON.stringify({
403
- error: error instanceof Error ? error.message : "Failed to execute code"
473
+ error:
474
+ error instanceof Error
475
+ ? error.message
476
+ : "Failed to execute code",
404
477
  }),
405
478
  {
406
479
  status: 500,
@@ -416,27 +489,54 @@ const server = serve({
416
489
 
417
490
  default:
418
491
  // Handle dynamic routes for contexts
419
- if (pathname.startsWith("/api/contexts/") && pathname.split('/').length === 4) {
420
- const contextId = pathname.split('/')[3];
492
+ if (
493
+ pathname.startsWith("/api/contexts/") &&
494
+ pathname.split("/").length === 4
495
+ ) {
496
+ const contextId = pathname.split("/")[3];
421
497
  if (req.method === "DELETE") {
422
498
  try {
423
- await jupyterServer.deleteContext(contextId);
424
- return new Response(
425
- JSON.stringify({ success: true }),
426
- {
427
- headers: {
428
- "Content-Type": "application/json",
429
- ...corsHeaders,
430
- },
431
- }
432
- );
499
+ await jupyterService.deleteContext(contextId);
500
+ return new Response(JSON.stringify({ success: true }), {
501
+ headers: {
502
+ "Content-Type": "application/json",
503
+ ...corsHeaders,
504
+ },
505
+ });
433
506
  } catch (error) {
507
+ if (error instanceof JupyterNotReadyError) {
508
+ console.log(
509
+ `[Container] Request timed out waiting for Jupyter (${error.progress}% complete)`
510
+ );
511
+ return new Response(
512
+ JSON.stringify({
513
+ error: error.message,
514
+ status: "initializing",
515
+ progress: error.progress,
516
+ }),
517
+ {
518
+ status: 503,
519
+ headers: {
520
+ "Content-Type": "application/json",
521
+ "Retry-After": "5",
522
+ ...corsHeaders,
523
+ },
524
+ }
525
+ );
526
+ }
434
527
  return new Response(
435
528
  JSON.stringify({
436
- error: error instanceof Error ? error.message : "Failed to delete context"
529
+ error:
530
+ error instanceof Error
531
+ ? error.message
532
+ : "Failed to delete context",
437
533
  }),
438
534
  {
439
- status: error instanceof Error && error.message.includes("not found") ? 404 : 500,
535
+ status:
536
+ error instanceof Error &&
537
+ error.message.includes("not found")
538
+ ? 404
539
+ : 500,
440
540
  headers: {
441
541
  "Content-Type": "application/json",
442
542
  ...corsHeaders,
@@ -446,22 +546,42 @@ const server = serve({
446
546
  }
447
547
  }
448
548
  }
449
-
549
+
450
550
  // Handle dynamic routes for individual processes
451
551
  if (pathname.startsWith("/api/process/")) {
452
- const segments = pathname.split('/');
552
+ const segments = pathname.split("/");
453
553
  if (segments.length >= 4) {
454
554
  const processId = segments[3];
455
555
  const action = segments[4]; // Optional: logs, stream, etc.
456
556
 
457
557
  if (!action && req.method === "GET") {
458
- return handleGetProcessRequest(processes, req, corsHeaders, processId);
558
+ return handleGetProcessRequest(
559
+ processes,
560
+ req,
561
+ corsHeaders,
562
+ processId
563
+ );
459
564
  } else if (!action && req.method === "DELETE") {
460
- return handleKillProcessRequest(processes, req, corsHeaders, processId);
565
+ return handleKillProcessRequest(
566
+ processes,
567
+ req,
568
+ corsHeaders,
569
+ processId
570
+ );
461
571
  } else if (action === "logs" && req.method === "GET") {
462
- return handleGetProcessLogsRequest(processes, req, corsHeaders, processId);
572
+ return handleGetProcessLogsRequest(
573
+ processes,
574
+ req,
575
+ corsHeaders,
576
+ processId
577
+ );
463
578
  } else if (action === "stream" && req.method === "GET") {
464
- return handleStreamProcessLogsRequest(processes, req, corsHeaders, processId);
579
+ return handleStreamProcessLogsRequest(
580
+ processes,
581
+ req,
582
+ corsHeaders,
583
+ processId
584
+ );
465
585
  }
466
586
  }
467
587
  }
@@ -477,7 +597,10 @@ const server = serve({
477
597
  });
478
598
  }
479
599
  } catch (error) {
480
- console.error(`[Container] Error handling ${req.method} ${pathname}:`, error);
600
+ console.error(
601
+ `[Container] Error handling ${req.method} ${pathname}:`,
602
+ error
603
+ );
481
604
  return new Response(
482
605
  JSON.stringify({
483
606
  error: "Internal server error",
@@ -496,7 +619,7 @@ const server = serve({
496
619
  hostname: "0.0.0.0",
497
620
  port: 3000,
498
621
  // We don't need this, but typescript complains
499
- websocket: { async message() { } },
622
+ websocket: { async message() {} },
500
623
  });
501
624
 
502
625
  console.log(`🚀 Bun server running on http://0.0.0.0:${server.port}`);
@@ -526,6 +649,8 @@ console.log(` GET /proxy/{port}/* - Proxy requests to exposed ports`);
526
649
  console.log(` POST /api/contexts - Create a code execution context`);
527
650
  console.log(` GET /api/contexts - List all contexts`);
528
651
  console.log(` DELETE /api/contexts/{id} - Delete a context`);
529
- console.log(` POST /api/execute/code - Execute code in a context (streaming)`);
652
+ console.log(
653
+ ` POST /api/execute/code - Execute code in a context (streaming)`
654
+ );
530
655
  console.log(` GET /api/ping - Health check`);
531
656
  console.log(` GET /api/commands - List available commands`);