@cloudflare/sandbox 0.2.0 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/Dockerfile +31 -7
  3. package/README.md +226 -2
  4. package/container_src/bun.lock +122 -0
  5. package/container_src/circuit-breaker.ts +121 -0
  6. package/container_src/index.ts +305 -10
  7. package/container_src/jupyter-server.ts +579 -0
  8. package/container_src/jupyter-service.ts +448 -0
  9. package/container_src/mime-processor.ts +255 -0
  10. package/container_src/package.json +9 -0
  11. package/container_src/startup.sh +83 -0
  12. package/dist/{chunk-YVZ3K26G.js → chunk-CUHYLCMT.js} +9 -21
  13. package/dist/chunk-CUHYLCMT.js.map +1 -0
  14. package/dist/chunk-EGC5IYXA.js +108 -0
  15. package/dist/chunk-EGC5IYXA.js.map +1 -0
  16. package/dist/chunk-FKBV7CZS.js +113 -0
  17. package/dist/chunk-FKBV7CZS.js.map +1 -0
  18. package/dist/chunk-LALY4SFU.js +129 -0
  19. package/dist/chunk-LALY4SFU.js.map +1 -0
  20. package/dist/{chunk-6THNBO4S.js → chunk-S5FFBU4Y.js} +1 -1
  21. package/dist/{chunk-6THNBO4S.js.map → chunk-S5FFBU4Y.js.map} +1 -1
  22. package/dist/chunk-VTKZL632.js +237 -0
  23. package/dist/chunk-VTKZL632.js.map +1 -0
  24. package/dist/{chunk-ZJN2PQOS.js → chunk-ZMPO44U4.js} +171 -72
  25. package/dist/chunk-ZMPO44U4.js.map +1 -0
  26. package/dist/{client-BXYlxy-j.d.ts → client-bzEV222a.d.ts} +52 -4
  27. package/dist/client.d.ts +2 -1
  28. package/dist/client.js +1 -1
  29. package/dist/errors.d.ts +95 -0
  30. package/dist/errors.js +27 -0
  31. package/dist/errors.js.map +1 -0
  32. package/dist/index.d.ts +3 -1
  33. package/dist/index.js +33 -3
  34. package/dist/interpreter-types.d.ts +259 -0
  35. package/dist/interpreter-types.js +9 -0
  36. package/dist/interpreter-types.js.map +1 -0
  37. package/dist/interpreter.d.ts +33 -0
  38. package/dist/interpreter.js +8 -0
  39. package/dist/interpreter.js.map +1 -0
  40. package/dist/jupyter-client.d.ts +4 -0
  41. package/dist/jupyter-client.js +9 -0
  42. package/dist/jupyter-client.js.map +1 -0
  43. package/dist/request-handler.d.ts +2 -1
  44. package/dist/request-handler.js +8 -3
  45. package/dist/sandbox.d.ts +2 -1
  46. package/dist/sandbox.js +8 -3
  47. package/dist/types.d.ts +8 -0
  48. package/dist/types.js +1 -1
  49. package/package.json +1 -1
  50. package/src/client.ts +37 -54
  51. package/src/errors.ts +218 -0
  52. package/src/index.ts +44 -10
  53. package/src/interpreter-types.ts +383 -0
  54. package/src/interpreter.ts +150 -0
  55. package/src/jupyter-client.ts +349 -0
  56. package/src/sandbox.ts +281 -153
  57. package/src/types.ts +15 -0
  58. package/dist/chunk-YVZ3K26G.js.map +0 -1
  59. package/dist/chunk-ZJN2PQOS.js.map +0 -1
@@ -0,0 +1,579 @@
1
+ import {
2
+ type Kernel,
3
+ KernelManager,
4
+ ServerConnection,
5
+ } from "@jupyterlab/services";
6
+ import type { IIOPubMessage } from "@jupyterlab/services/lib/kernel/messages";
7
+ import { v4 as uuidv4 } from "uuid";
8
+ import { processJupyterMessage } from "./mime-processor";
9
+
10
+ export interface JupyterContext {
11
+ id: string;
12
+ language: string;
13
+ connection: Kernel.IKernelConnection;
14
+ cwd: string;
15
+ createdAt: Date;
16
+ lastUsed: Date;
17
+ pooled?: boolean; // Track if this context is from the pool
18
+ inUse?: boolean; // Track if pooled context is currently in use
19
+ }
20
+
21
+ export interface CreateContextRequest {
22
+ language?: string;
23
+ cwd?: string;
24
+ envVars?: Record<string, string>;
25
+ }
26
+
27
+ export interface ExecuteCodeRequest {
28
+ context_id?: string;
29
+ code: string;
30
+ language?: string;
31
+ env_vars?: Record<string, string>;
32
+ }
33
+
34
+ interface ContextPool {
35
+ available: JupyterContext[];
36
+ inUse: Set<string>; // context IDs currently in use
37
+ minSize: number;
38
+ maxSize: number;
39
+ warming: boolean; // Track if pool is currently being warmed
40
+ }
41
+
42
+ export class JupyterServer {
43
+ private kernelManager: KernelManager;
44
+ private contexts = new Map<string, JupyterContext>();
45
+ private defaultContexts = new Map<string, string>(); // language -> context_id
46
+ private contextPools = new Map<string, ContextPool>(); // language -> pool
47
+
48
+ constructor() {
49
+ // Configure connection to local Jupyter server
50
+ const serverSettings = ServerConnection.makeSettings({
51
+ baseUrl: "http://localhost:8888",
52
+ token: "",
53
+ appUrl: "",
54
+ wsUrl: "ws://localhost:8888",
55
+ appendToken: false,
56
+ init: {
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ },
60
+ },
61
+ });
62
+
63
+ this.kernelManager = new KernelManager({ serverSettings });
64
+ }
65
+
66
+ async initialize() {
67
+ await this.kernelManager.ready;
68
+ console.log("[JupyterServer] Kernel manager initialized");
69
+
70
+ // Don't create default context during initialization - use lazy loading instead
71
+
72
+ // Initialize pools for common languages (but don't warm them yet)
73
+ this.initializePool("python", 0, 3);
74
+ this.initializePool("javascript", 0, 2);
75
+ }
76
+
77
+ /**
78
+ * Initialize a context pool for a specific language
79
+ */
80
+ private initializePool(language: string, minSize = 0, maxSize = 5) {
81
+ const pool: ContextPool = {
82
+ available: [],
83
+ inUse: new Set(),
84
+ minSize,
85
+ maxSize,
86
+ warming: false,
87
+ };
88
+
89
+ this.contextPools.set(language, pool);
90
+
91
+ // Pre-warm contexts in background if minSize > 0
92
+ if (minSize > 0) {
93
+ setTimeout(() => this.warmPool(language, minSize), 0);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Enable pool warming for a language (called after Jupyter is ready)
99
+ */
100
+ async enablePoolWarming(language: string, minSize: number) {
101
+ const pool = this.contextPools.get(language);
102
+ if (!pool) {
103
+ this.initializePool(language, minSize, 3);
104
+ return;
105
+ }
106
+
107
+ // Update min size and warm if needed
108
+ pool.minSize = minSize;
109
+ const toWarm = minSize - pool.available.length;
110
+ if (toWarm > 0) {
111
+ await this.warmPool(language, toWarm);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Pre-warm a pool with the specified number of contexts
117
+ */
118
+ private async warmPool(language: string, count: number) {
119
+ const pool = this.contextPools.get(language);
120
+ if (!pool || pool.warming) return;
121
+
122
+ pool.warming = true;
123
+ console.log(`[JupyterServer] Pre-warming ${count} ${language} contexts`);
124
+
125
+ try {
126
+ const promises: Promise<JupyterContext>[] = [];
127
+ for (let i = 0; i < count; i++) {
128
+ promises.push(this.createPooledContext(language));
129
+ }
130
+
131
+ const contexts = await Promise.all(promises);
132
+ pool.available.push(...contexts);
133
+ console.log(
134
+ `[JupyterServer] Pre-warmed ${contexts.length} ${language} contexts`
135
+ );
136
+ } catch (error) {
137
+ console.error(
138
+ `[JupyterServer] Error pre-warming ${language} pool:`,
139
+ error
140
+ );
141
+ } finally {
142
+ pool.warming = false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Create a context specifically for the pool
148
+ */
149
+ private async createPooledContext(language: string): Promise<JupyterContext> {
150
+ const context = await this.createContext({ language });
151
+ context.pooled = true;
152
+ context.inUse = false;
153
+ return context;
154
+ }
155
+
156
+ /**
157
+ * Get a context from the pool or create a new one
158
+ */
159
+ private async getPooledContext(
160
+ language: string,
161
+ cwd?: string,
162
+ envVars?: Record<string, string>
163
+ ): Promise<JupyterContext | null> {
164
+ const pool = this.contextPools.get(language);
165
+ if (!pool) return null;
166
+
167
+ // Find an available context in the pool
168
+ const availableContext = pool.available.find((ctx) => !ctx.inUse);
169
+
170
+ if (availableContext) {
171
+ // Mark as in use
172
+ availableContext.inUse = true;
173
+ pool.inUse.add(availableContext.id);
174
+
175
+ // Remove from available list
176
+ pool.available = pool.available.filter(
177
+ (ctx) => ctx.id !== availableContext.id
178
+ );
179
+
180
+ // Update context properties if needed
181
+ if (cwd && cwd !== availableContext.cwd) {
182
+ await this.changeWorkingDirectory(availableContext, cwd);
183
+ availableContext.cwd = cwd;
184
+ }
185
+
186
+ if (envVars) {
187
+ await this.setEnvironmentVariables(availableContext, envVars);
188
+ }
189
+
190
+ availableContext.lastUsed = new Date();
191
+ console.log(
192
+ `[JupyterServer] Reusing pooled ${language} context ${availableContext.id}`
193
+ );
194
+
195
+ // Warm another context in background if we're below minSize
196
+ if (pool.available.length < pool.minSize && !pool.warming) {
197
+ setTimeout(() => this.warmPool(language, 1), 0);
198
+ }
199
+
200
+ return availableContext;
201
+ }
202
+
203
+ // No available context, check if we can create a new one
204
+ if (pool.inUse.size < pool.maxSize) {
205
+ console.log(
206
+ `[JupyterServer] No pooled ${language} context available, creating new one`
207
+ );
208
+ return null; // Let the caller create a new context
209
+ }
210
+
211
+ // Pool is at max capacity
212
+ console.log(
213
+ `[JupyterServer] ${language} context pool at max capacity (${pool.maxSize})`
214
+ );
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * Release a pooled context back to the pool
220
+ */
221
+ private releasePooledContext(context: JupyterContext) {
222
+ if (!context.pooled) return;
223
+
224
+ const pool = this.contextPools.get(context.language);
225
+ if (!pool) return;
226
+
227
+ // Mark as not in use
228
+ context.inUse = false;
229
+ pool.inUse.delete(context.id);
230
+
231
+ // Add back to available list
232
+ pool.available.push(context);
233
+
234
+ console.log(
235
+ `[JupyterServer] Released ${context.language} context ${context.id} back to pool`
236
+ );
237
+ }
238
+
239
+ async createContext(req: CreateContextRequest): Promise<JupyterContext> {
240
+ const language = req.language || "python";
241
+ const cwd = req.cwd || "/workspace";
242
+
243
+ // Try to get a context from the pool first
244
+ const pooledContext = await this.getPooledContext(
245
+ language,
246
+ cwd,
247
+ req.envVars
248
+ );
249
+ if (pooledContext) {
250
+ // Add to active contexts map
251
+ this.contexts.set(pooledContext.id, pooledContext);
252
+ return pooledContext;
253
+ }
254
+
255
+ // Create a new context if pool didn't provide one
256
+ const kernelModel = await this.kernelManager.startNew({
257
+ name: this.getKernelName(language),
258
+ });
259
+
260
+ const connection = this.kernelManager.connectTo({ model: kernelModel });
261
+
262
+ const context: JupyterContext = {
263
+ id: uuidv4(),
264
+ language,
265
+ connection,
266
+ cwd,
267
+ createdAt: new Date(),
268
+ lastUsed: new Date(),
269
+ };
270
+
271
+ this.contexts.set(context.id, context);
272
+
273
+ // Set working directory
274
+ if (cwd !== "/workspace") {
275
+ await this.changeWorkingDirectory(context, cwd);
276
+ }
277
+
278
+ // Set environment variables if provided
279
+ if (req.envVars) {
280
+ await this.setEnvironmentVariables(context, req.envVars);
281
+ }
282
+
283
+ return context;
284
+ }
285
+
286
+ private async getOrCreateDefaultContext(
287
+ language: string
288
+ ): Promise<JupyterContext | undefined> {
289
+ // Check if we already have a default context for this language
290
+ const defaultContextId = this.defaultContexts.get(language);
291
+ if (defaultContextId) {
292
+ const context = this.contexts.get(defaultContextId);
293
+ if (context) {
294
+ return context;
295
+ }
296
+ }
297
+
298
+ // Create new default context lazily
299
+ console.log(
300
+ `[JupyterServer] Creating default ${language} context on first use`
301
+ );
302
+ const context = await this.createContext({ language });
303
+ this.defaultContexts.set(language, context.id);
304
+ return context;
305
+ }
306
+
307
+ async executeCode(
308
+ contextId: string | undefined,
309
+ code: string,
310
+ language?: string
311
+ ): Promise<Response> {
312
+ let context: JupyterContext | undefined;
313
+
314
+ if (contextId) {
315
+ context = this.contexts.get(contextId);
316
+ if (!context) {
317
+ return new Response(
318
+ JSON.stringify({ error: `Context ${contextId} not found` }),
319
+ {
320
+ status: 404,
321
+ headers: { "Content-Type": "application/json" },
322
+ }
323
+ );
324
+ }
325
+ } else {
326
+ // Use or create default context for the language
327
+ const lang = language || "python";
328
+ context = await this.getOrCreateDefaultContext(lang);
329
+ }
330
+
331
+ if (!context) {
332
+ return new Response(JSON.stringify({ error: "No context available" }), {
333
+ status: 400,
334
+ headers: { "Content-Type": "application/json" },
335
+ });
336
+ }
337
+
338
+ // Update last used
339
+ context.lastUsed = new Date();
340
+
341
+ // Execute with streaming
342
+ return this.streamExecution(context.connection, code);
343
+ }
344
+
345
+ private async streamExecution(
346
+ connection: Kernel.IKernelConnection,
347
+ code: string
348
+ ): Promise<Response> {
349
+ const stream = new ReadableStream({
350
+ async start(controller) {
351
+ const future = connection.requestExecute({
352
+ code,
353
+ stop_on_error: false,
354
+ store_history: true,
355
+ silent: false,
356
+ allow_stdin: false,
357
+ });
358
+
359
+ // Handle different message types
360
+ future.onIOPub = (msg: IIOPubMessage) => {
361
+ const result = processJupyterMessage(msg);
362
+ if (result) {
363
+ controller.enqueue(
364
+ new TextEncoder().encode(`${JSON.stringify(result)}\n`)
365
+ );
366
+ }
367
+ };
368
+
369
+ future.onReply = (msg: any) => {
370
+ if (msg.content.status === "ok") {
371
+ controller.enqueue(
372
+ new TextEncoder().encode(
373
+ `${JSON.stringify({
374
+ type: "execution_complete",
375
+ execution_count: msg.content.execution_count,
376
+ })}\n`
377
+ )
378
+ );
379
+ } else if (msg.content.status === "error") {
380
+ controller.enqueue(
381
+ new TextEncoder().encode(
382
+ `${JSON.stringify({
383
+ type: "error",
384
+ ename: msg.content.ename,
385
+ evalue: msg.content.evalue,
386
+ traceback: msg.content.traceback,
387
+ })}\n`
388
+ )
389
+ );
390
+ }
391
+ controller.close();
392
+ };
393
+
394
+ future.onStdin = (msg: any) => {
395
+ // We don't support stdin for now
396
+ console.warn("[JupyterServer] Stdin requested but not supported");
397
+ };
398
+ },
399
+ });
400
+
401
+ return new Response(stream, {
402
+ headers: {
403
+ "Content-Type": "text/event-stream",
404
+ "Cache-Control": "no-cache",
405
+ Connection: "keep-alive",
406
+ },
407
+ });
408
+ }
409
+
410
+ private getKernelName(language: string): string {
411
+ const kernelMap: Record<string, string> = {
412
+ python: "python3",
413
+ javascript: "javascript",
414
+ typescript: "javascript",
415
+ js: "javascript",
416
+ ts: "javascript",
417
+ };
418
+ return kernelMap[language.toLowerCase()] || "python3";
419
+ }
420
+
421
+ private async changeWorkingDirectory(context: JupyterContext, cwd: string) {
422
+ const code =
423
+ context.language === "python"
424
+ ? `import os; os.chdir('${cwd}')`
425
+ : `process.chdir('${cwd}')`;
426
+
427
+ const future = context.connection.requestExecute({
428
+ code,
429
+ silent: true,
430
+ store_history: false,
431
+ });
432
+
433
+ return future.done;
434
+ }
435
+
436
+ private async setEnvironmentVariables(
437
+ context: JupyterContext,
438
+ envVars: Record<string, string>
439
+ ) {
440
+ const commands: string[] = [];
441
+
442
+ for (const [key, value] of Object.entries(envVars)) {
443
+ if (context.language === "python") {
444
+ commands.push(`import os; os.environ['${key}'] = '${value}'`);
445
+ } else if (
446
+ context.language === "javascript" ||
447
+ context.language === "typescript"
448
+ ) {
449
+ commands.push(`process.env['${key}'] = '${value}'`);
450
+ }
451
+ }
452
+
453
+ if (commands.length > 0) {
454
+ const code = commands.join("\n");
455
+ const future = context.connection.requestExecute({
456
+ code,
457
+ silent: true,
458
+ store_history: false,
459
+ });
460
+
461
+ return future.done;
462
+ }
463
+ }
464
+
465
+ async listContexts(): Promise<
466
+ Array<{
467
+ id: string;
468
+ language: string;
469
+ cwd: string;
470
+ createdAt: Date;
471
+ lastUsed: Date;
472
+ }>
473
+ > {
474
+ return Array.from(this.contexts.values()).map((ctx) => ({
475
+ id: ctx.id,
476
+ language: ctx.language,
477
+ cwd: ctx.cwd,
478
+ createdAt: ctx.createdAt,
479
+ lastUsed: ctx.lastUsed,
480
+ }));
481
+ }
482
+
483
+ async deleteContext(contextId: string): Promise<void> {
484
+ const context = this.contexts.get(contextId);
485
+ if (!context) {
486
+ throw new Error(`Context ${contextId} not found`);
487
+ }
488
+
489
+ // Remove from active contexts map
490
+ this.contexts.delete(contextId);
491
+
492
+ // Remove from default contexts if it was a default
493
+ for (const [lang, id] of this.defaultContexts.entries()) {
494
+ if (id === contextId) {
495
+ this.defaultContexts.delete(lang);
496
+ break;
497
+ }
498
+ }
499
+
500
+ // If it's a pooled context, release it back to the pool
501
+ if (context.pooled) {
502
+ this.releasePooledContext(context);
503
+ } else {
504
+ // Only shutdown non-pooled contexts
505
+ await context.connection.shutdown();
506
+ }
507
+ }
508
+
509
+ async shutdown() {
510
+ // Shutdown all active contexts
511
+ for (const context of this.contexts.values()) {
512
+ try {
513
+ await context.connection.shutdown();
514
+ } catch (error) {
515
+ console.error("[JupyterServer] Error shutting down kernel:", error);
516
+ }
517
+ }
518
+
519
+ // Shutdown all pooled contexts
520
+ for (const pool of this.contextPools.values()) {
521
+ for (const context of pool.available) {
522
+ try {
523
+ await context.connection.shutdown();
524
+ } catch (error) {
525
+ console.error(
526
+ "[JupyterServer] Error shutting down pooled kernel:",
527
+ error
528
+ );
529
+ }
530
+ }
531
+ }
532
+
533
+ this.contexts.clear();
534
+ this.defaultContexts.clear();
535
+ this.contextPools.clear();
536
+ }
537
+
538
+ /**
539
+ * Get pool statistics for monitoring
540
+ */
541
+ async getPoolStats(): Promise<
542
+ Record<
543
+ string,
544
+ {
545
+ available: number;
546
+ inUse: number;
547
+ total: number;
548
+ minSize: number;
549
+ maxSize: number;
550
+ warming: boolean;
551
+ }
552
+ >
553
+ > {
554
+ const stats: Record<
555
+ string,
556
+ {
557
+ available: number;
558
+ inUse: number;
559
+ total: number;
560
+ minSize: number;
561
+ maxSize: number;
562
+ warming: boolean;
563
+ }
564
+ > = {};
565
+
566
+ for (const [language, pool] of this.contextPools.entries()) {
567
+ stats[language] = {
568
+ available: pool.available.length,
569
+ inUse: pool.inUse.size,
570
+ total: pool.available.length + pool.inUse.size,
571
+ minSize: pool.minSize,
572
+ maxSize: pool.maxSize,
573
+ warming: pool.warming,
574
+ };
575
+ }
576
+
577
+ return stats;
578
+ }
579
+ }