mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 02:04:13 +00:00
docs: add Cloudflare Sandbox SDK deployment guide (#57)
* docs: add Cloudflare Sandbox SDK deployment guide - Add docs/deploy/cloudflare.mdx with full deployment guide - Add examples/cloudflare/ with working Worker code - Update docs navigation to include Cloudflare option - Update deploy index page with Cloudflare card The example shows how to run sandbox-agent inside a Cloudflare Sandbox with exposed ports for API access. Co-authored-by: Shelley <shelley@exe.dev> * fix: guard server startup to avoid port conflicts Add health check before starting sandbox-agent to prevent 'address already in use' errors on subsequent requests. The isServerRunning() function probes the health endpoint to determine if setup should be skipped. Co-authored-by: Shelley <shelley@exe.dev> * fix: default cloudflare/sandbox:0.7.0 (latest does not exist) * 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 --------- Co-authored-by: Shelley <shelley@exe.dev> Co-authored-by: Nathan Flurry <git@nathanflurry.com> Co-authored-by: Nathan Flurry <developer@nathanflurry.com>
This commit is contained in:
parent
64582ef299
commit
cc5a9e0d73
16 changed files with 1459 additions and 220 deletions
272
examples/cloudflare/frontend/App.tsx
Normal file
272
examples/cloudflare/frontend/App.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
12
examples/cloudflare/frontend/index.html
Normal file
12
examples/cloudflare/frontend/index.html
Normal 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>
|
||||
9
examples/cloudflare/frontend/main.tsx
Normal file
9
examples/cloudflare/frontend/main.tsx
Normal 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>
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue