Skip to content

Commit 46eb4e6

Browse files
Add binary file support with streaming (#88)
* Add binary file support Enables reading binary files (images, PDFs, etc.) from the sandbox with automatic MIME type detection and base64 encoding. The SDK now returns file metadata including type, size, and encoding. * Fix chunk size for base64 file streaming Change streaming chunk size from 65536 to 65535 bytes to ensure proper base64 encoding. Base64 encodes 3 bytes at a time, so chunk sizes must be divisible by 3. Non-aligned chunks cause corruption when separately-encoded base64 strings are concatenated. Also add streaming mode toggle to examples UI to allow testing both readFile() and readFileStream() approaches side-by-side. * Update README with binary file support * Add changeset * Fix type errors
1 parent d86b60e commit 46eb4e6

File tree

18 files changed

+1277
-17
lines changed

18 files changed

+1277
-17
lines changed

.changeset/few-tires-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/sandbox": patch
3+
---
4+
5+
Add binary file support with automatic MIME detection and streaming

examples/basic/app/index.tsx

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,110 @@ class SandboxApiClient {
276276
});
277277
}
278278

279+
async readFileStream(path: string): Promise<{
280+
path: string;
281+
mimeType: string;
282+
size: number;
283+
isBinary: boolean;
284+
encoding: string;
285+
content: string;
286+
}> {
287+
const response = await fetch(`${this.baseUrl}/api/read/stream`, {
288+
method: "POST",
289+
headers: {
290+
"Content-Type": "application/json",
291+
"X-Sandbox-Client-Id": this.sandboxId,
292+
},
293+
body: JSON.stringify({ path }),
294+
});
295+
296+
if (!response.ok) {
297+
throw new Error(`HTTP error! status: ${response.status}`);
298+
}
299+
300+
// Parse SSE stream with proper buffering to handle chunk splitting
301+
const reader = response.body?.getReader();
302+
if (!reader) {
303+
throw new Error("No response body");
304+
}
305+
306+
const decoder = new TextDecoder();
307+
let metadata: any = null;
308+
let content = "";
309+
let buffer = ""; // Buffer for incomplete lines
310+
311+
try {
312+
while (true) {
313+
const { done, value } = await reader.read();
314+
315+
if (value) {
316+
// Add new data to buffer
317+
buffer += decoder.decode(value, { stream: true });
318+
}
319+
320+
if (done) break;
321+
322+
// Process complete lines from buffer
323+
let newlineIndex = buffer.indexOf("\n");
324+
while (newlineIndex !== -1) {
325+
const line = buffer.slice(0, newlineIndex).trim();
326+
buffer = buffer.slice(newlineIndex + 1);
327+
328+
if (line.startsWith("data: ")) {
329+
try {
330+
const data = JSON.parse(line.slice(6));
331+
332+
if (data.type === "metadata") {
333+
metadata = data;
334+
} else if (data.type === "chunk") {
335+
content += data.data;
336+
} else if (data.type === "complete") {
337+
return {
338+
path,
339+
mimeType: metadata?.mimeType || "unknown",
340+
size: metadata?.size || 0,
341+
isBinary: metadata?.isBinary || false,
342+
encoding: metadata?.encoding || "utf-8",
343+
content,
344+
};
345+
} else if (data.type === "error") {
346+
throw new Error(data.error);
347+
}
348+
} catch (parseError) {
349+
console.error("Failed to parse SSE line:", line.substring(0, 100), parseError);
350+
// Skip malformed lines
351+
}
352+
}
353+
354+
newlineIndex = buffer.indexOf("\n");
355+
}
356+
}
357+
358+
// Process any remaining data in buffer
359+
if (buffer.trim().startsWith("data: ")) {
360+
try {
361+
const data = JSON.parse(buffer.trim().slice(6));
362+
if (data.type === "complete") {
363+
return {
364+
path,
365+
mimeType: metadata?.mimeType || "unknown",
366+
size: metadata?.size || 0,
367+
isBinary: metadata?.isBinary || false,
368+
encoding: metadata?.encoding || "utf-8",
369+
content,
370+
};
371+
}
372+
} catch (e) {
373+
// Ignore final buffer parsing errors
374+
}
375+
}
376+
} finally {
377+
reader.releaseLock();
378+
}
379+
380+
throw new Error("Stream ended unexpectedly");
381+
}
382+
279383
async deleteFile(path: string) {
280384
return this.doFetch("/api/delete", {
281385
method: "POST",
@@ -321,6 +425,12 @@ class SandboxApiClient {
321425
});
322426
}
323427

428+
async createTestBinaryFile() {
429+
return this.doFetch("/api/create-test-binary", {
430+
method: "POST",
431+
});
432+
}
433+
324434
async setupNextjs(projectName?: string) {
325435
return this.doFetch("/api/templates/nextjs", {
326436
method: "POST",
@@ -1455,6 +1565,20 @@ function FilesTab({
14551565
const [gitBranch, setGitBranch] = useState("main");
14561566
const [gitTargetDir, setGitTargetDir] = useState("");
14571567

1568+
// Binary File Support
1569+
const [binaryFilePath, setBinaryFilePath] = useState("/workspace/demo-chart.png");
1570+
const [binaryFileMetadata, setBinaryFileMetadata] = useState<{
1571+
path: string;
1572+
mimeType: string;
1573+
size: number;
1574+
isBinary: boolean;
1575+
encoding: string;
1576+
content?: string;
1577+
} | null>(null);
1578+
const [isCreatingBinary, setIsCreatingBinary] = useState(false);
1579+
const [isReadingBinary, setIsReadingBinary] = useState(false);
1580+
const [useStreaming, setUseStreaming] = useState(false);
1581+
14581582
const addResult = (type: "success" | "error", message: string) => {
14591583
setResults((prev) => [...prev, { type, message, timestamp: new Date() }]);
14601584
};
@@ -1608,6 +1732,47 @@ function FilesTab({
16081732
}
16091733
};
16101734

1735+
const handleCreateTestBinary = async () => {
1736+
if (!client) return;
1737+
setIsCreatingBinary(true);
1738+
try {
1739+
const result = await client.createTestBinaryFile();
1740+
addResult("success", `Created test PNG: ${result.path}`);
1741+
setBinaryFilePath(result.path);
1742+
// Clear any existing metadata to show fresh state
1743+
setBinaryFileMetadata(null);
1744+
} catch (error: any) {
1745+
addResult("error", `Failed to create test binary: ${error.message}`);
1746+
} finally {
1747+
setIsCreatingBinary(false);
1748+
}
1749+
};
1750+
1751+
const handleReadBinaryFile = async () => {
1752+
if (!client || !binaryFilePath.trim()) return;
1753+
setIsReadingBinary(true);
1754+
try {
1755+
const result = useStreaming
1756+
? await client.readFileStream(binaryFilePath)
1757+
: await client.readFile(binaryFilePath);
1758+
1759+
setBinaryFileMetadata({
1760+
path: result.path,
1761+
mimeType: result.mimeType || "unknown",
1762+
size: result.size || 0,
1763+
isBinary: result.isBinary || false,
1764+
encoding: result.encoding || "utf-8",
1765+
content: result.content,
1766+
});
1767+
addResult("success", `Read binary file with metadata${useStreaming ? ' (streamed)' : ''}: ${binaryFilePath}`);
1768+
} catch (error: any) {
1769+
addResult("error", `Failed to read binary file: ${error.message}`);
1770+
setBinaryFileMetadata(null);
1771+
} finally {
1772+
setIsReadingBinary(false);
1773+
}
1774+
};
1775+
16111776
return (
16121777
<div className="files-tab">
16131778
<div className="files-section">
@@ -2052,6 +2217,133 @@ function FilesTab({
20522217
</div>
20532218
</div>
20542219

2220+
{/* Binary File Support Showcase */}
2221+
<div className="binary-showcase-section">
2222+
<h2>🎨 Binary File Support Demo</h2>
2223+
<p className="section-description">
2224+
Test the new binary file reading capabilities with automatic format detection and metadata extraction.
2225+
</p>
2226+
2227+
<div className="operation-group">
2228+
<h3>Step 1: Create Test Binary File</h3>
2229+
<p className="help-text">Generate a PNG chart using matplotlib in the sandbox</p>
2230+
<button
2231+
onClick={handleCreateTestBinary}
2232+
disabled={isCreatingBinary || connectionStatus !== "connected"}
2233+
className="action-button create-binary"
2234+
>
2235+
{isCreatingBinary ? "Creating..." : "🎨 Create Test PNG Chart"}
2236+
</button>
2237+
</div>
2238+
2239+
<div className="operation-group">
2240+
<h3>Step 2: Read Binary File with Metadata</h3>
2241+
<div className="streaming-toggle">
2242+
<label className="toggle-label">
2243+
<input
2244+
type="checkbox"
2245+
checked={useStreaming}
2246+
onChange={(e) => setUseStreaming(e.target.checked)}
2247+
className="toggle-checkbox"
2248+
/>
2249+
<span className="toggle-text">
2250+
Use streaming (readFileStream)
2251+
</span>
2252+
</label>
2253+
<p className="help-text">
2254+
{useStreaming
2255+
? "📡 Streams file in chunks via SSE - better for large files"
2256+
: "📄 Reads entire file at once - simpler but loads all into memory"}
2257+
</p>
2258+
</div>
2259+
<div className="input-group">
2260+
<input
2261+
type="text"
2262+
placeholder="Binary file path"
2263+
value={binaryFilePath}
2264+
onChange={(e) => setBinaryFilePath(e.target.value)}
2265+
className="file-input"
2266+
/>
2267+
<button
2268+
onClick={handleReadBinaryFile}
2269+
disabled={!binaryFilePath.trim() || isReadingBinary || connectionStatus !== "connected"}
2270+
className="action-button"
2271+
>
2272+
{isReadingBinary ? "Reading..." : "📖 Read & Display"}
2273+
</button>
2274+
</div>
2275+
</div>
2276+
2277+
{/* File Metadata Display */}
2278+
{binaryFileMetadata && (
2279+
<div className="binary-metadata-card">
2280+
<h4>📊 File Metadata</h4>
2281+
<div className="metadata-grid">
2282+
<div className="metadata-item">
2283+
<span className="metadata-label">File Type:</span>
2284+
<span className="metadata-value">
2285+
{binaryFileMetadata.isBinary ? "🖼️" : "📄"} {binaryFileMetadata.mimeType}
2286+
</span>
2287+
</div>
2288+
<div className="metadata-item">
2289+
<span className="metadata-label">Size:</span>
2290+
<span className="metadata-value">
2291+
{(binaryFileMetadata.size / 1024).toFixed(2)} KB
2292+
</span>
2293+
</div>
2294+
<div className="metadata-item">
2295+
<span className="metadata-label">Encoding:</span>
2296+
<span className="metadata-value metadata-encoding">
2297+
{binaryFileMetadata.encoding}
2298+
</span>
2299+
</div>
2300+
<div className="metadata-item">
2301+
<span className="metadata-label">Binary:</span>
2302+
<span className={`metadata-value ${binaryFileMetadata.isBinary ? "binary-yes" : "binary-no"}`}>
2303+
{binaryFileMetadata.isBinary ? "✓ Yes" : "✗ No"}
2304+
</span>
2305+
</div>
2306+
</div>
2307+
2308+
{/* File Preview */}
2309+
<div className="file-preview">
2310+
<h4>🔍 Preview</h4>
2311+
{binaryFileMetadata.isBinary && binaryFileMetadata.mimeType.startsWith("image/") ? (
2312+
<div className="image-preview">
2313+
<img
2314+
src={`data:${binaryFileMetadata.mimeType};base64,${binaryFileMetadata.content}`}
2315+
alt="Binary file preview"
2316+
className="preview-image"
2317+
/>
2318+
<p className="preview-caption">
2319+
✅ Binary file successfully read and decoded from base64!
2320+
</p>
2321+
</div>
2322+
) : binaryFileMetadata.isBinary ? (
2323+
<div className="binary-preview">
2324+
<p className="binary-info">
2325+
📦 Binary file ({binaryFileMetadata.mimeType})
2326+
</p>
2327+
<pre className="base64-preview">
2328+
{binaryFileMetadata.content?.substring(0, 200)}...
2329+
</pre>
2330+
<p className="preview-caption">
2331+
Base64 encoded content (first 200 chars shown)
2332+
</p>
2333+
</div>
2334+
) : (
2335+
<div className="text-preview">
2336+
<pre className="code-block">
2337+
{binaryFileMetadata.content}
2338+
</pre>
2339+
<p className="preview-caption">Text file content</p>
2340+
</div>
2341+
)}
2342+
</div>
2343+
</div>
2344+
)}
2345+
</div>
2346+
20552347
{/* Results */}
20562348
<div className="results-section">
20572349
<h3>Operation Results</h3>

0 commit comments

Comments
 (0)