@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 +12 -0
- package/Dockerfile +7 -0
- package/README.md +58 -5
- package/container_src/handler/file.ts +51 -0
- package/container_src/index.ts +9 -0
- package/container_src/isolation.ts +173 -9
- package/package.json +1 -1
- package/src/client.ts +39 -0
- package/src/file-stream.ts +162 -0
- package/src/index.ts +6 -0
- package/src/sandbox.ts +10 -1
- package/src/types.ts +69 -0
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.
|
|
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
|
|
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
|
}
|
package/container_src/index.ts
CHANGED
|
@@ -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<{
|
|
450
|
-
|
|
451
|
-
|
|
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:
|
|
455
|
-
exitCode:
|
|
456
|
-
content
|
|
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
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 {
|