feat(cloudflare): add React frontend and improve deployment docs

- Add React + Vite frontend for Cloudflare example with sandbox-agent SDK
- Update ensureRunning to poll health endpoint instead of fixed wait
- Fix SDK fetch binding issue (globalThis.fetch.bind)
- Update docs with .dev.vars format warning and container caching tip
- Use containerFetch proxy pattern for reliable local dev
This commit is contained in:
Nathan Flurry 2026-02-03 02:10:45 -08:00
parent 44382d2c12
commit 3576b7fcca
12 changed files with 619 additions and 425 deletions

View file

@ -0,0 +1,11 @@
FROM cloudflare/sandbox:0.7.0
# Install sandbox-agent
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh
# Pre-install agents
RUN sandbox-agent install-agent claude && \
sandbox-agent install-agent codex
# Expose port for local dev (wrangler dev requires EXPOSE directives)
EXPOSE 8000

View file

@ -0,0 +1,272 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { SandboxAgent } from "sandbox-agent";
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
export function App() {
const [sandboxName, setSandboxName] = useState("demo");
const [prompt, setPrompt] = useState("");
const [output, setOutput] = useState("");
const [status, setStatus] = useState<"idle" | "connecting" | "ready" | "thinking">("idle");
const [error, setError] = useState<string | null>(null);
const clientRef = useRef<SandboxAgent | null>(null);
const sessionIdRef = useRef<string>(`session-${Date.now()}`);
const abortRef = useRef<AbortController | null>(null);
const isThinkingRef = useRef(false);
const log = useCallback((msg: string) => {
setOutput((prev) => prev + msg + "\n");
}, []);
const connect = useCallback(async () => {
setStatus("connecting");
setError(null);
setOutput("");
try {
// Connect via proxy endpoint (need full URL for SDK)
const baseUrl = `${window.location.origin}/sandbox/${encodeURIComponent(sandboxName)}`;
log(`Connecting to sandbox: ${sandboxName}`);
const client = await SandboxAgent.connect({ baseUrl });
clientRef.current = client;
// Wait for health (this also ensures the container is started)
log("Waiting for sandbox-agent to be ready...");
for (let i = 0; i < 30; i++) {
try {
await client.getHealth();
break;
} catch {
if (i === 29) throw new Error("Timeout waiting for sandbox-agent");
await new Promise((r) => setTimeout(r, 1000));
}
}
// Create session
await client.createSession(sessionIdRef.current, { agent: "claude" });
log("Session created. Ready to chat.\n");
setStatus("ready");
// Start listening for events
startEventStream(client);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("idle");
}
}, [sandboxName, log]);
const startEventStream = useCallback(
async (client: SandboxAgent) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
for await (const event of client.streamEvents(sessionIdRef.current, undefined, controller.signal)) {
console.log("Event:", event.type, event.data);
// Auto-approve permissions
if (event.type === "permission.requested") {
const data = event.data as PermissionEventData;
log(`[Auto-approved] ${data.action}`);
await client.replyPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
}
// Reject questions (don't support interactive input)
if (event.type === "question.requested") {
const data = event.data as QuestionEventData;
log(`[Question rejected] ${data.prompt}`);
await client.rejectQuestion(sessionIdRef.current, data.question_id);
}
// Track when assistant starts thinking
if (event.type === "item.started") {
const item = (event.data as any)?.item;
if (item?.role === "assistant") {
isThinkingRef.current = true;
}
}
// Show deltas while assistant is thinking
if (event.type === "item.delta" && isThinkingRef.current) {
const delta = (event.data as any)?.delta;
if (delta) {
const text = typeof delta === "string" ? delta : delta.type === "text" ? delta.text || "" : "";
if (text) {
setOutput((prev) => prev + text);
}
}
}
// Track assistant turn completion
if (event.type === "item.completed") {
const item = (event.data as any)?.item;
if (item?.role === "assistant") {
isThinkingRef.current = false;
setOutput((prev) => prev + "\n\n");
setStatus("ready");
}
}
// Handle errors
if (event.type === "error") {
const data = event.data as any;
log(`Error: ${data?.message || JSON.stringify(data)}`);
}
// Handle session end
if (event.type === "session.ended") {
const data = event.data as any;
log(`Session ended: ${data?.reason || "unknown"}`);
setStatus("idle");
}
}
} catch (err) {
if (controller.signal.aborted) return;
console.error("Event stream error:", err);
}
},
[log]
);
const send = useCallback(async () => {
if (!clientRef.current || !prompt.trim() || status !== "ready") return;
const message = prompt.trim();
setPrompt("");
setOutput((prev) => prev + `user: ${message}\n\nassistant: `);
setStatus("thinking");
try {
await clientRef.current.postMessage(sessionIdRef.current, { message });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("ready");
}
}, [prompt, status]);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return (
<div style={styles.container}>
<h1 style={styles.title}>Sandbox Agent</h1>
{status === "idle" && (
<div style={styles.connectForm}>
<label style={styles.label}>
Sandbox name:
<input
style={styles.input}
value={sandboxName}
onChange={(e) => setSandboxName(e.target.value)}
placeholder="demo"
/>
</label>
<button style={styles.button} onClick={connect}>
Connect
</button>
</div>
)}
{status === "connecting" && <div style={styles.status}>Connecting to sandbox...</div>}
{error && <div style={styles.error}>{error}</div>}
{(status === "ready" || status === "thinking") && (
<>
<div style={styles.output}>{output}</div>
<div style={styles.inputRow}>
<input
style={styles.promptInput}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Enter prompt..."
disabled={status === "thinking"}
/>
<button style={styles.button} onClick={send} disabled={status === "thinking"}>
{status === "thinking" ? "..." : "Send"}
</button>
</div>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
fontFamily: "system-ui, sans-serif",
maxWidth: 800,
margin: "2rem auto",
padding: "1rem",
},
title: {
marginBottom: "1rem",
},
connectForm: {
display: "flex",
gap: "1rem",
alignItems: "flex-end",
},
label: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
color: "#666",
},
input: {
padding: "0.5rem",
fontSize: "1rem",
width: 200,
},
button: {
padding: "0.5rem 1rem",
fontSize: "1rem",
cursor: "pointer",
backgroundColor: "#0066cc",
color: "white",
border: "none",
borderRadius: 4,
},
status: {
color: "#666",
fontStyle: "italic",
},
error: {
color: "#cc0000",
padding: "0.5rem",
backgroundColor: "#fff0f0",
borderRadius: 4,
marginBottom: "1rem",
},
output: {
whiteSpace: "pre-wrap",
background: "#1e1e1e",
color: "#d4d4d4",
padding: "1rem",
minHeight: 300,
fontFamily: "monospace",
fontSize: 14,
overflow: "auto",
borderRadius: 4,
},
inputRow: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
promptInput: {
flex: 1,
padding: "0.5rem",
fontSize: "1rem",
},
};

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -3,17 +3,25 @@
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"dev": "vite build --watch & wrangler dev",
"build": "vite build",
"deploy": "vite build && wrangler deploy",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cloudflare/sandbox": "latest"
"@cloudflare/sandbox": "latest",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@cloudflare/workers-types": "latest",
"@types/node": "latest",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"typescript": "latest",
"vite": "^6.2.0",
"vitest": "^3.0.0",
"wrangler": "latest"
}

View file

@ -1,69 +1,76 @@
import { getSandbox, proxyToSandbox, type Sandbox } from "@cloudflare/sandbox";
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
type Env = {
Sandbox: DurableObjectNamespace<Sandbox>;
ANTHROPIC_API_KEY?: string;
OPENAI_API_KEY?: string;
Bindings: {
Sandbox: DurableObjectNamespace<Sandbox>;
ASSETS: Fetcher;
ANTHROPIC_API_KEY?: string;
OPENAI_API_KEY?: string;
};
};
const PORT = 8000;
/** Check if sandbox-agent is already running by probing its health endpoint */
async function isServerRunning(sandbox: Sandbox): Promise<boolean> {
try {
const result = await sandbox.exec("curl -sf http://localhost:8000/v1/health");
const result = await sandbox.exec(`curl -sf http://localhost:${PORT}/v1/health`);
return result.success;
} catch {
return false;
}
}
/** Ensure sandbox-agent is running in the container */
async function ensureRunning(sandbox: Sandbox, env: Env["Bindings"]): Promise<void> {
if (await isServerRunning(sandbox)) return;
// Set environment variables for agents
const envVars: Record<string, string> = {};
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
await sandbox.setEnvVars(envVars);
// Start sandbox-agent server as background process
await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`);
// Poll health endpoint until server is ready (max ~6 seconds)
for (let i = 0; i < 30; i++) {
if (await isServerRunning(sandbox)) return;
await new Promise((r) => setTimeout(r, 200));
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Proxy requests to exposed ports first
const proxyResponse = await proxyToSandbox(request, env);
if (proxyResponse) return proxyResponse;
async fetch(request: Request, env: Env["Bindings"]): Promise<Response> {
const url = new URL(request.url);
const { hostname } = new URL(request.url);
const sandbox = getSandbox(env.Sandbox, "sandbox-agent");
// Proxy requests to sandbox-agent: /sandbox/:name/v1/...
const match = url.pathname.match(/^\/sandbox\/([^/]+)(\/.*)?$/);
if (match) {
if (!env.ANTHROPIC_API_KEY && !env.OPENAI_API_KEY) {
return Response.json(
{ error: "ANTHROPIC_API_KEY or OPENAI_API_KEY must be set" },
{ status: 500 }
);
}
// Check if server is already running to avoid port conflicts
const alreadyRunning = await isServerRunning(sandbox);
const name = match[1];
const path = match[2] || "/";
const sandbox = getSandbox(env.Sandbox, name);
if (!alreadyRunning) {
console.log("Installing sandbox-agent...");
await sandbox.exec(
"curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"
await ensureRunning(sandbox, env);
// Proxy request to container
return sandbox.containerFetch(
new Request(`http://localhost${path}${url.search}`, request),
PORT
);
console.log("Installing agents...");
await sandbox.exec("sandbox-agent install-agent claude");
await sandbox.exec("sandbox-agent install-agent codex");
// Set environment variables for agents
const envVars: Record<string, string> = {};
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
await sandbox.setEnvVars(envVars);
console.log("Starting sandbox-agent server...");
await sandbox.startProcess(
"sandbox-agent server --no-token --host 0.0.0.0 --port 8000"
);
// Wait for server to start
await new Promise((r) => setTimeout(r, 2000));
}
// Expose the port with a preview URL
const exposed = await sandbox.exposePort(8000, { hostname });
console.log("Server accessible at:", exposed.url);
return Response.json({
endpoint: exposed.url,
message: alreadyRunning
? "sandbox-agent server was already running"
: "sandbox-agent server started",
});
// Serve frontend assets
return env.ASSETS.fetch(request);
},
};
} satisfies ExportedHandler<Env["Bindings"]>;

View file

@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
root: "frontend",
build: {
outDir: "../dist",
emptyOutDir: true,
},
});

View file

@ -4,10 +4,14 @@
"main": "src/cloudflare.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist",
"binding": "ASSETS"
},
"containers": [
{
"class_name": "Sandbox",
"image": "docker.io/cloudflare/sandbox:0.7.0",
"image": "./Dockerfile",
"instance_type": "lite",
"max_instances": 1
}