@cloudflare/sandbox 0.0.0-d86b60e โ†’ 0.0.0-d951819

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,17 @@
1
1
  # @cloudflare/sandbox
2
2
 
3
+ ## 0.3.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#90](https://github.com/cloudflare/sandbox-sdk/pull/90) [`66cc85b`](https://github.com/cloudflare/sandbox-sdk/commit/66cc85b679b466b3ffb1f00fbd697670fc186f06) Thanks [@eastlondoner](https://github.com/eastlondoner)! - set bun idletimeout
8
+
9
+ ## 0.3.5
10
+
11
+ ### Patch Changes
12
+
13
+ - [#88](https://github.com/cloudflare/sandbox-sdk/pull/88) [`46eb4e6`](https://github.com/cloudflare/sandbox-sdk/commit/46eb4e6b6c671b682fc74f83563ccf5f316011cb) Thanks [@ghostwriternr](https://github.com/ghostwriternr)! - Add binary file support with automatic MIME detection and streaming
14
+
3
15
  ## 0.3.4
4
16
 
5
17
  ### Patch Changes
package/Dockerfile CHANGED
@@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y \
13
13
  git \
14
14
  unzip \
15
15
  zip \
16
+ file \
16
17
  # Process management
17
18
  procps \
18
19
  htop \
@@ -51,6 +52,12 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
51
52
  COPY --from=bun-source /usr/local/bin/bun /usr/local/bin/bun
52
53
  COPY --from=bun-source /usr/local/bin/bunx /usr/local/bin/bunx
53
54
 
55
+ # Install development tools globally
56
+ RUN npm install -g \
57
+ wrangler \
58
+ vite \
59
+ opencode-ai
60
+
54
61
  # Install essential Python packages for code execution
55
62
  RUN pip3 install --no-cache-dir \
56
63
  matplotlib \
package/README.md CHANGED
@@ -50,10 +50,11 @@ The Cloudflare Sandbox SDK enables you to run isolated code environments directl
50
50
  - **๐Ÿ”’ Secure Isolation**: Each sandbox runs in its own container with full process isolation
51
51
  - **โšก Edge-Native**: Runs on Cloudflare's global network for low latency worldwide
52
52
  - **๐Ÿ“ File System Access**: Read, write, and manage files within the sandbox
53
+ - **๐Ÿ–ผ๏ธ Binary File Support**: Automatic MIME type detection and base64 encoding for images, PDFs, and other binary files
53
54
  - **๐Ÿ”ง Command Execution**: Run any command or process inside the container
54
55
  - **๐ŸŒ Preview URLs**: Expose services running in your sandbox via public URLs
55
56
  - **๐Ÿ”„ Git Integration**: Clone repositories directly into sandboxes
56
- - **๐Ÿš€ Streaming Support**: Real-time output streaming for long-running commands
57
+ - **๐Ÿš€ Streaming Support**: Real-time output streaming for long-running commands and file transfers
57
58
  - **๐ŸŽฎ Session Management**: Maintain state across multiple operations
58
59
  - **๐Ÿงช Code Interpreter**: Execute Python and JavaScript with rich outputs (charts, tables, formatted data)
59
60
  - **๐Ÿ“Š Multi-Language Support**: Persistent execution contexts for Python and JavaScript/TypeScript
@@ -72,7 +73,7 @@ npm install @cloudflare/sandbox
72
73
  1. **Create a Dockerfile** (temporary requirement, will be removed in future releases):
73
74
 
74
75
  ```dockerfile
75
- FROM docker.io/cloudflare/sandbox:0.3.4
76
+ FROM docker.io/cloudflare/sandbox:0.3.6
76
77
 
77
78
  # Expose the ports you want to expose
78
79
  EXPOSE 3000
@@ -189,11 +190,57 @@ await sandbox.writeFile("/workspace/app.js", "console.log('Hello!');");
189
190
 
190
191
  #### `readFile(path, options?)`
191
192
 
192
- Read a file from the sandbox.
193
+ Read a file from the sandbox with automatic binary detection.
193
194
 
194
195
  ```typescript
196
+ // Read text files
195
197
  const file = await sandbox.readFile("/package.json");
196
- console.log(file.content);
198
+ console.log(file.content); // UTF-8 text content
199
+
200
+ // Read binary files - automatically detected and base64 encoded
201
+ const image = await sandbox.readFile("/workspace/chart.png");
202
+ console.log(image.mimeType); // "image/png"
203
+ console.log(image.isBinary); // true
204
+ console.log(image.encoding); // "base64"
205
+ console.log(image.size); // File size in bytes
206
+
207
+ // Use the base64 content directly in data URLs
208
+ const dataUrl = `data:${image.mimeType};base64,${image.content}`;
209
+ ```
210
+
211
+ #### `readFileStream(path)`
212
+
213
+ Stream large files efficiently with automatic chunking and encoding.
214
+
215
+ ```typescript
216
+ import { streamFile, collectFile } from '@cloudflare/sandbox';
217
+
218
+ // Stream a large file
219
+ const stream = await sandbox.readFileStream("/large-video.mp4");
220
+
221
+ // Option 1: Process chunks as they arrive
222
+ for await (const chunk of streamFile(stream)) {
223
+ if (chunk instanceof Uint8Array) {
224
+ // Binary chunk - already decoded from base64
225
+ console.log(`Received ${chunk.byteLength} bytes`);
226
+ // Process binary data...
227
+ } else {
228
+ // Text chunk
229
+ console.log('Text:', chunk);
230
+ }
231
+ }
232
+
233
+ // Option 2: Collect entire file into memory
234
+ const { content, metadata } = await collectFile(stream);
235
+ console.log(`MIME: ${metadata.mimeType}, Size: ${metadata.size} bytes`);
236
+
237
+ if (content instanceof Uint8Array) {
238
+ // Binary file - ready to save or process
239
+ await writeToStorage(content);
240
+ } else {
241
+ // Text file
242
+ console.log('Content:', content);
243
+ }
197
244
  ```
198
245
 
199
246
  #### `gitCheckout(repoUrl, options?)`
@@ -241,7 +288,8 @@ console.log(result.stdout); // "production"
241
288
  #### File System Methods
242
289
 
243
290
  - `writeFile(path, content, options?)` - Write content to a file
244
- - `readFile(path, options?)` - Read a file from the sandbox
291
+ - `readFile(path, options?)` - Read a file with automatic binary detection and base64 encoding
292
+ - `readFileStream(path)` - Stream large files efficiently with chunking
245
293
  - `mkdir(path, options?)` - Create a directory
246
294
  - `deleteFile(path)` - Delete a file
247
295
  - `renameFile(oldPath, newPath)` - Rename a file
@@ -665,10 +713,15 @@ for await (const event of parseSSEStream<ExecEvent>(stream)) {
665
713
 
666
714
  The SDK exports utilities for working with Server-Sent Event streams:
667
715
 
716
+ **Command Execution:**
668
717
  - **`parseSSEStream<T>(stream)`** - Convert ReadableStream to typed AsyncIterable
669
718
  - **`responseToAsyncIterable<T>(response)`** - Convert SSE Response to AsyncIterable
670
719
  - **`asyncIterableToSSEStream<T>(iterable)`** - Convert AsyncIterable back to SSE stream
671
720
 
721
+ **File Streaming:**
722
+ - **`streamFile(stream, signal?)`** - Convert file SSE stream to AsyncIterable with automatic base64 decoding
723
+ - **`collectFile(stream, signal?)`** - Collect entire file from stream into memory
724
+
672
725
  #### Advanced Streaming Examples
673
726
 
674
727
  **CI/CD Build System:**
@@ -204,6 +204,11 @@ export async function handleReadFileRequest(
204
204
  path,
205
205
  success: result.success,
206
206
  timestamp: new Date().toISOString(),
207
+ // New metadata fields for binary file support
208
+ encoding: result.encoding,
209
+ isBinary: result.isBinary,
210
+ mimeType: result.mimeType,
211
+ size: result.size,
207
212
  }),
208
213
  {
209
214
  headers: {
@@ -403,4 +408,50 @@ export async function handleListFilesRequest(
403
408
  } catch (error) {
404
409
  return createServerErrorResponse("handleListFilesRequest", error, corsHeaders);
405
410
  }
411
+ }
412
+
413
+ export async function handleReadFileStreamRequest(
414
+ req: Request,
415
+ corsHeaders: Record<string, string>,
416
+ sessionManager: SessionManager
417
+ ): Promise<Response> {
418
+ try {
419
+ const body = (await req.json()) as ReadFileRequest;
420
+ const { path, sessionId } = body;
421
+
422
+ // Validate path
423
+ const pathError = validatePath(path);
424
+ if (pathError) {
425
+ return createPathErrorResponse(pathError, corsHeaders);
426
+ }
427
+
428
+ console.log(`[Server] Streaming file: ${path}${sessionId ? ` in session: ${sessionId}` : ''}`);
429
+
430
+ // Get the appropriate session
431
+ const session = sessionId
432
+ ? sessionManager.getSession(sessionId)
433
+ : await sessionManager.getOrCreateDefaultSession();
434
+
435
+ if (!session) {
436
+ return createServerErrorResponse(
437
+ "handleReadFileStreamRequest",
438
+ new Error(`Session '${sessionId}' not found`),
439
+ corsHeaders
440
+ );
441
+ }
442
+
443
+ // Create SSE stream
444
+ const stream = await session.readFileStreamOperation(path);
445
+
446
+ return new Response(stream, {
447
+ headers: {
448
+ "Content-Type": "text/event-stream",
449
+ "Cache-Control": "no-cache",
450
+ "Connection": "keep-alive",
451
+ ...corsHeaders,
452
+ },
453
+ });
454
+ } catch (error) {
455
+ return createServerErrorResponse("handleReadFileStreamRequest", error, corsHeaders);
456
+ }
406
457
  }
@@ -9,6 +9,7 @@ import {
9
9
  handleMkdirRequest,
10
10
  handleMoveFileRequest,
11
11
  handleReadFileRequest,
12
+ handleReadFileStreamRequest,
12
13
  handleRenameFileRequest,
13
14
  handleWriteFileRequest,
14
15
  } from "./handler/file";
@@ -86,6 +87,7 @@ console.log("[Container] Interpreter service ready - no cold start!");
86
87
  console.log("[Container] All API endpoints available immediately");
87
88
 
88
89
  const server = serve({
90
+ idleTimeout: 255,
89
91
  async fetch(req: Request) {
90
92
  const url = new URL(req.url);
91
93
  const pathname = url.pathname;
@@ -190,6 +192,12 @@ const server = serve({
190
192
  }
191
193
  break;
192
194
 
195
+ case "/api/read/stream":
196
+ if (req.method === "POST") {
197
+ return handleReadFileStreamRequest(req, corsHeaders, sessionManager);
198
+ }
199
+ break;
200
+
193
201
  case "/api/delete":
194
202
  if (req.method === "POST") {
195
203
  return handleDeleteFileRequest(req, corsHeaders, sessionManager);
@@ -569,6 +577,7 @@ console.log(` POST /api/git/checkout - Checkout a git repository`);
569
577
  console.log(` POST /api/mkdir - Create a directory`);
570
578
  console.log(` POST /api/write - Write a file`);
571
579
  console.log(` POST /api/read - Read a file`);
580
+ console.log(` POST /api/read/stream - Stream a file (SSE)`);
572
581
  console.log(` POST /api/delete - Delete a file`);
573
582
  console.log(` POST /api/rename - Rename a file`);
574
583
  console.log(` POST /api/move - Move a file`);
@@ -446,16 +446,180 @@ SANDBOX_EOF`;
446
446
  };
447
447
  }
448
448
 
449
- async readFileOperation(path: string, encoding: string = 'utf-8'): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
450
- const command = `cat "${path}"`;
451
- const result = await this.exec(command);
452
-
449
+ async readFileOperation(path: string, encoding: string = 'utf-8'): Promise<{
450
+ success: boolean;
451
+ exitCode: number;
452
+ content: string;
453
+ path: string;
454
+ encoding?: 'utf-8' | 'base64';
455
+ isBinary?: boolean;
456
+ mimeType?: string;
457
+ size?: number;
458
+ }> {
459
+ // Step 1: Check if file exists and get metadata
460
+ const statCommand = `stat -c '%s' "${path}" 2>/dev/null || echo "FILE_NOT_FOUND"`;
461
+ const statResult = await this.exec(statCommand);
462
+
463
+ if (statResult.stdout.trim() === 'FILE_NOT_FOUND') {
464
+ // File doesn't exist - return error
465
+ return {
466
+ success: false,
467
+ exitCode: 1,
468
+ content: '',
469
+ path
470
+ };
471
+ }
472
+
473
+ const fileSize = parseInt(statResult.stdout.trim(), 10);
474
+
475
+ // Step 2: Detect MIME type using file command
476
+ const mimeCommand = `file --mime-type -b "${path}"`;
477
+ const mimeResult = await this.exec(mimeCommand);
478
+ const mimeType = mimeResult.stdout.trim();
479
+
480
+ // Step 3: Determine if file is binary based on MIME type
481
+ // Text MIME types: text/*, application/json, application/xml, application/javascript, etc.
482
+ const isBinary = !mimeType.startsWith('text/') &&
483
+ !mimeType.includes('json') &&
484
+ !mimeType.includes('xml') &&
485
+ !mimeType.includes('javascript') &&
486
+ !mimeType.includes('x-empty');
487
+
488
+ // Step 4: Read file with appropriate encoding
489
+ let content: string;
490
+ let actualEncoding: 'utf-8' | 'base64';
491
+
492
+ if (isBinary) {
493
+ // Use base64 for binary files
494
+ const base64Command = `base64 -w 0 "${path}"`;
495
+ const base64Result = await this.exec(base64Command);
496
+ content = base64Result.stdout;
497
+ actualEncoding = 'base64';
498
+ } else {
499
+ // Use cat for text files
500
+ const catCommand = `cat "${path}"`;
501
+ const catResult = await this.exec(catCommand);
502
+ content = catResult.stdout;
503
+ actualEncoding = 'utf-8';
504
+ }
505
+
453
506
  return {
454
- success: result.exitCode === 0,
455
- exitCode: result.exitCode,
456
- content: result.stdout,
457
- path
507
+ success: true,
508
+ exitCode: 0,
509
+ content,
510
+ path,
511
+ encoding: actualEncoding,
512
+ isBinary,
513
+ mimeType,
514
+ size: fileSize
515
+ };
516
+ }
517
+
518
+ async readFileStreamOperation(path: string): Promise<ReadableStream<Uint8Array>> {
519
+ const encoder = new TextEncoder();
520
+
521
+ // Helper to send SSE event
522
+ const sseEvent = (event: any): Uint8Array => {
523
+ return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
458
524
  };
525
+
526
+ // Create streaming response
527
+ return new ReadableStream({
528
+ start: async (controller) => {
529
+ try {
530
+ // Step 1: Get file metadata (same logic as readFileOperation)
531
+ const statCommand = `stat -c '%s' "${path}" 2>/dev/null || echo "FILE_NOT_FOUND"`;
532
+ const statResult = await this.exec(statCommand);
533
+
534
+ if (statResult.stdout.trim() === 'FILE_NOT_FOUND') {
535
+ // File doesn't exist - send error event
536
+ controller.enqueue(sseEvent({
537
+ type: 'error',
538
+ error: `File not found: ${path}`
539
+ }));
540
+ controller.close();
541
+ return;
542
+ }
543
+
544
+ const fileSize = parseInt(statResult.stdout.trim(), 10);
545
+
546
+ // Step 2: Detect MIME type
547
+ const mimeCommand = `file --mime-type -b "${path}"`;
548
+ const mimeResult = await this.exec(mimeCommand);
549
+ const mimeType = mimeResult.stdout.trim();
550
+
551
+ // Step 3: Determine if binary
552
+ const isBinary = !mimeType.startsWith('text/') &&
553
+ !mimeType.includes('json') &&
554
+ !mimeType.includes('xml') &&
555
+ !mimeType.includes('javascript') &&
556
+ !mimeType.includes('x-empty');
557
+
558
+ const encoding: 'utf-8' | 'base64' = isBinary ? 'base64' : 'utf-8';
559
+
560
+ // Step 4: Send metadata event
561
+ controller.enqueue(sseEvent({
562
+ type: 'metadata',
563
+ mimeType,
564
+ size: fileSize,
565
+ isBinary,
566
+ encoding
567
+ }));
568
+
569
+ // Step 5: Stream file in chunks
570
+ // IMPORTANT: Chunk size MUST be divisible by 3 for base64 encoding!
571
+ // Base64 encodes 3 bytes at a time. If chunks aren't aligned,
572
+ // concatenating separately-encoded base64 strings corrupts the data.
573
+ const CHUNK_SIZE = 65535;
574
+ let bytesRead = 0;
575
+
576
+ while (bytesRead < fileSize) {
577
+ const remainingBytes = fileSize - bytesRead;
578
+ const chunkSize = Math.min(CHUNK_SIZE, remainingBytes);
579
+
580
+ // Use dd to read chunk at specific offset
581
+ // bs=1 means 1 byte block size, skip=offset, count=chunkSize
582
+ let chunkCommand: string;
583
+ if (isBinary) {
584
+ // For binary, read and encode as base64
585
+ chunkCommand = `dd if="${path}" bs=1 skip=${bytesRead} count=${chunkSize} 2>/dev/null | base64 -w 0`;
586
+ } else {
587
+ // For text, just read
588
+ chunkCommand = `dd if="${path}" bs=1 skip=${bytesRead} count=${chunkSize} 2>/dev/null`;
589
+ }
590
+
591
+ const chunkResult = await this.exec(chunkCommand);
592
+
593
+ // Send chunk event
594
+ controller.enqueue(sseEvent({
595
+ type: 'chunk',
596
+ data: chunkResult.stdout
597
+ }));
598
+
599
+ bytesRead += chunkSize;
600
+ }
601
+
602
+ // Step 6: Send complete event
603
+ controller.enqueue(sseEvent({
604
+ type: 'complete',
605
+ bytesRead
606
+ }));
607
+
608
+ controller.close();
609
+ } catch (error) {
610
+ // Send error event
611
+ controller.enqueue(sseEvent({
612
+ type: 'error',
613
+ error: error instanceof Error ? error.message : String(error)
614
+ }));
615
+ controller.close();
616
+ }
617
+ },
618
+
619
+ cancel() {
620
+ console.log(`[Session] File stream cancelled for: ${path}`);
621
+ }
622
+ });
459
623
  }
460
624
 
461
625
  async mkdirOperation(path: string, recursive: boolean = false): Promise<{ success: boolean; exitCode: number; path: string; recursive: boolean }> {
@@ -1010,7 +1174,7 @@ export class SessionManager {
1010
1174
  return defaultSession.writeFileOperation(path, content, encoding);
1011
1175
  }
1012
1176
 
1013
- async readFile(path: string, encoding?: string): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
1177
+ async readFile(path: string, encoding?: string): Promise<{ success: boolean; exitCode: number; content: string; path: string; encoding?: 'utf-8' | 'base64'; isBinary?: boolean; mimeType?: string; size?: number }> {
1014
1178
  const defaultSession = await this.getOrCreateDefaultSession();
1015
1179
  return defaultSession.readFileOperation(path, encoding);
1016
1180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/sandbox",
3
- "version": "0.0.0-d86b60e",
3
+ "version": "0.0.0-d951819",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cloudflare/sandbox-sdk"
package/src/client.ts CHANGED
@@ -481,6 +481,45 @@ export class HttpClient {
481
481
  }
482
482
  }
483
483
 
484
+ async readFileStream(
485
+ path: string,
486
+ sessionId: string
487
+ ): Promise<ReadableStream<Uint8Array>> {
488
+ try {
489
+ const response = await this.doFetch(`/api/read/stream`, {
490
+ method: "POST",
491
+ headers: {
492
+ "Content-Type": "application/json",
493
+ },
494
+ body: JSON.stringify({
495
+ path,
496
+ sessionId,
497
+ } as ReadFileRequest),
498
+ });
499
+
500
+ if (!response.ok) {
501
+ const errorData = (await response.json().catch(() => ({}))) as {
502
+ error?: string;
503
+ };
504
+ throw new Error(
505
+ errorData.error || `HTTP error! status: ${response.status}`
506
+ );
507
+ }
508
+
509
+ if (!response.body) {
510
+ throw new Error("No response body for file streaming");
511
+ }
512
+
513
+ console.log(
514
+ `[HTTP Client] Started streaming file: ${path}${sessionId ? ` in session: ${sessionId}` : ''}`
515
+ );
516
+ return response.body;
517
+ } catch (error) {
518
+ console.error("[HTTP Client] Error streaming file:", error);
519
+ throw error;
520
+ }
521
+ }
522
+
484
523
  async deleteFile(
485
524
  path: string,
486
525
  sessionId: string
@@ -0,0 +1,162 @@
1
+ /**
2
+ * File streaming utilities for reading binary and text files
3
+ * Provides simple AsyncIterable API over SSE stream with automatic base64 decoding
4
+ */
5
+
6
+ import { parseSSEStream } from './sse-parser';
7
+ import type { FileChunk, FileMetadata, FileStreamEvent } from './types';
8
+
9
+ /**
10
+ * Convert ReadableStream of SSE file events to AsyncIterable of file chunks
11
+ * Automatically decodes base64 for binary files and provides metadata
12
+ *
13
+ * @param stream - The SSE ReadableStream from readFileStream()
14
+ * @param signal - Optional AbortSignal for cancellation
15
+ * @returns AsyncIterable that yields file chunks (string for text, Uint8Array for binary)
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const stream = await sandbox.readFileStream('/path/to/file.png');
20
+ *
21
+ * for await (const chunk of streamFile(stream)) {
22
+ * if (chunk instanceof Uint8Array) {
23
+ * // Binary chunk - already decoded from base64
24
+ * console.log('Binary chunk:', chunk.byteLength, 'bytes');
25
+ * } else {
26
+ * // Text chunk
27
+ * console.log('Text chunk:', chunk);
28
+ * }
29
+ * }
30
+ *
31
+ * // Access metadata
32
+ * const iter = streamFile(stream);
33
+ * for await (const chunk of iter) {
34
+ * console.log('MIME type:', iter.metadata?.mimeType);
35
+ * // process chunk...
36
+ * }
37
+ * ```
38
+ */
39
+ export async function* streamFile(
40
+ stream: ReadableStream<Uint8Array>,
41
+ signal?: AbortSignal
42
+ ): AsyncGenerator<FileChunk, void, undefined> {
43
+ let metadata: FileMetadata | undefined;
44
+
45
+ try {
46
+ for await (const event of parseSSEStream<FileStreamEvent>(stream, signal)) {
47
+ switch (event.type) {
48
+ case 'metadata':
49
+ // Store metadata for access via iterator
50
+ metadata = {
51
+ mimeType: event.mimeType,
52
+ size: event.size,
53
+ isBinary: event.isBinary,
54
+ encoding: event.encoding,
55
+ };
56
+ // Store on generator function for external access
57
+ (streamFile as any).metadata = metadata;
58
+ break;
59
+
60
+ case 'chunk':
61
+ // Auto-decode base64 for binary files
62
+ if (metadata?.isBinary && metadata?.encoding === 'base64') {
63
+ // Decode base64 to Uint8Array
64
+ const binaryString = atob(event.data);
65
+ const bytes = new Uint8Array(binaryString.length);
66
+ for (let i = 0; i < binaryString.length; i++) {
67
+ bytes[i] = binaryString.charCodeAt(i);
68
+ }
69
+ yield bytes;
70
+ } else {
71
+ // Text file - yield as-is
72
+ yield event.data;
73
+ }
74
+ break;
75
+
76
+ case 'complete':
77
+ // Stream completed successfully
78
+ console.log(`[streamFile] File streaming complete: ${event.bytesRead} bytes read`);
79
+ return;
80
+
81
+ case 'error':
82
+ // Stream error
83
+ throw new Error(`File streaming error: ${event.error}`);
84
+ }
85
+ }
86
+ } catch (error) {
87
+ console.error('[streamFile] Error streaming file:', error);
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Helper to collect entire file from stream into memory
94
+ * Useful for smaller files where you want the complete content at once
95
+ *
96
+ * @param stream - The SSE ReadableStream from readFileStream()
97
+ * @param signal - Optional AbortSignal for cancellation
98
+ * @returns Object with content (string or Uint8Array) and metadata
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * const stream = await sandbox.readFileStream('/path/to/image.png');
103
+ * const { content, metadata } = await collectFile(stream);
104
+ *
105
+ * if (content instanceof Uint8Array) {
106
+ * console.log('Binary file:', metadata.mimeType, content.byteLength, 'bytes');
107
+ * } else {
108
+ * console.log('Text file:', metadata.mimeType, content.length, 'chars');
109
+ * }
110
+ * ```
111
+ */
112
+ export async function collectFile(
113
+ stream: ReadableStream<Uint8Array>,
114
+ signal?: AbortSignal
115
+ ): Promise<{ content: string | Uint8Array; metadata: FileMetadata }> {
116
+ let metadata: FileMetadata | undefined;
117
+ const chunks: FileChunk[] = [];
118
+
119
+ for await (const chunk of streamFile(stream, signal)) {
120
+ chunks.push(chunk);
121
+ // Capture metadata from first iteration
122
+ if (!metadata && (streamFile as any).metadata) {
123
+ metadata = (streamFile as any).metadata;
124
+ }
125
+ }
126
+
127
+ if (!metadata) {
128
+ throw new Error('No metadata received from file stream');
129
+ }
130
+
131
+ // Combine chunks based on type
132
+ if (chunks.length === 0) {
133
+ // Empty file
134
+ return {
135
+ content: metadata.isBinary ? new Uint8Array(0) : '',
136
+ metadata,
137
+ };
138
+ }
139
+
140
+ // Check if binary or text based on first chunk
141
+ if (chunks[0] instanceof Uint8Array) {
142
+ // Binary file - concatenate Uint8Arrays
143
+ const totalLength = chunks.reduce((sum, chunk) => {
144
+ return sum + (chunk as Uint8Array).byteLength;
145
+ }, 0);
146
+
147
+ const result = new Uint8Array(totalLength);
148
+ let offset = 0;
149
+ for (const chunk of chunks) {
150
+ result.set(chunk as Uint8Array, offset);
151
+ offset += (chunk as Uint8Array).byteLength;
152
+ }
153
+
154
+ return { content: result, metadata };
155
+ } else {
156
+ // Text file - concatenate strings
157
+ return {
158
+ content: chunks.join(''),
159
+ metadata,
160
+ };
161
+ }
162
+ }
package/src/index.ts CHANGED
@@ -52,6 +52,8 @@ export {
52
52
  parseSSEStream,
53
53
  responseToAsyncIterable,
54
54
  } from "./sse-parser";
55
+ // Export file streaming utilities
56
+ export { streamFile, collectFile } from "./file-stream";
55
57
  export type {
56
58
  DeleteFileResponse,
57
59
  ExecEvent,
@@ -59,6 +61,10 @@ export type {
59
61
  ExecResult,
60
62
  ExecuteResponse,
61
63
  ExecutionSession,
64
+ FileChunk,
65
+ FileMetadata,
66
+ FileStream,
67
+ FileStreamEvent,
62
68
  GitCheckoutResponse,
63
69
  ISandbox,
64
70
  ListFilesResponse,
package/src/sandbox.ts CHANGED
@@ -264,6 +264,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
264
264
  return session.readFile(path, options);
265
265
  }
266
266
 
267
+ async readFileStream(path: string): Promise<ReadableStream<Uint8Array>> {
268
+ const session = await this.ensureDefaultSession();
269
+ return session.readFileStream(path);
270
+ }
271
+
267
272
  async listFiles(
268
273
  path: string,
269
274
  options: {
@@ -678,7 +683,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
678
683
  readFile: async (path: string, options?: { encoding?: string }) => {
679
684
  return await this.client.readFile(path, options?.encoding, sessionId);
680
685
  },
681
-
686
+
687
+ readFileStream: async (path: string) => {
688
+ return await this.client.readFileStream(path, sessionId);
689
+ },
690
+
682
691
  mkdir: async (path: string, options?: { recursive?: boolean }) => {
683
692
  return await this.client.mkdir(path, options?.recursive, sessionId);
684
693
  },
package/src/types.ts CHANGED
@@ -215,6 +215,54 @@ export interface StreamOptions extends BaseExecOptions {
215
215
  signal?: AbortSignal;
216
216
  }
217
217
 
218
+ // File Streaming Types
219
+
220
+ /**
221
+ * SSE events for file streaming
222
+ */
223
+ export type FileStreamEvent =
224
+ | {
225
+ type: 'metadata';
226
+ mimeType: string;
227
+ size: number;
228
+ isBinary: boolean;
229
+ encoding: 'utf-8' | 'base64';
230
+ }
231
+ | {
232
+ type: 'chunk';
233
+ data: string; // base64 for binary, UTF-8 for text
234
+ }
235
+ | {
236
+ type: 'complete';
237
+ bytesRead: number;
238
+ }
239
+ | {
240
+ type: 'error';
241
+ error: string;
242
+ };
243
+
244
+ /**
245
+ * File metadata from streaming
246
+ */
247
+ export interface FileMetadata {
248
+ mimeType: string;
249
+ size: number;
250
+ isBinary: boolean;
251
+ encoding: 'utf-8' | 'base64';
252
+ }
253
+
254
+ /**
255
+ * File stream chunk - either string (text) or Uint8Array (binary, auto-decoded)
256
+ */
257
+ export type FileChunk = string | Uint8Array;
258
+
259
+ /**
260
+ * AsyncIterable of file chunks with metadata
261
+ */
262
+ export interface FileStream extends AsyncIterable<FileChunk> {
263
+ metadata?: FileMetadata;
264
+ }
265
+
218
266
  // Error Types
219
267
 
220
268
  export class SandboxError extends Error {
@@ -359,6 +407,7 @@ export interface ISandbox {
359
407
  renameFile(oldPath: string, newPath: string): Promise<RenameFileResponse>;
360
408
  moveFile(sourcePath: string, destinationPath: string): Promise<MoveFileResponse>;
361
409
  readFile(path: string, options?: { encoding?: string }): Promise<ReadFileResponse>;
410
+ readFileStream(path: string): Promise<ReadableStream<Uint8Array>>;
362
411
  listFiles(path: string, options?: { recursive?: boolean; includeHidden?: boolean }): Promise<ListFilesResponse>;
363
412
 
364
413
  // Port management
@@ -434,6 +483,26 @@ export interface ReadFileResponse {
434
483
  path: string;
435
484
  content: string;
436
485
  timestamp: string;
486
+
487
+ /**
488
+ * Encoding used for content (utf-8 for text, base64 for binary)
489
+ */
490
+ encoding?: 'utf-8' | 'base64';
491
+
492
+ /**
493
+ * Whether the file is detected as binary
494
+ */
495
+ isBinary?: boolean;
496
+
497
+ /**
498
+ * MIME type of the file (e.g., 'image/png', 'text/plain')
499
+ */
500
+ mimeType?: string;
501
+
502
+ /**
503
+ * File size in bytes
504
+ */
505
+ size?: number;
437
506
  }
438
507
 
439
508
  export interface DeleteFileResponse {