diff --git a/examples/sandbox-vercel/.gitignore b/examples/sandbox-vercel/.gitignore new file mode 100644 index 0000000000..e28f3d79db --- /dev/null +++ b/examples/sandbox-vercel/.gitignore @@ -0,0 +1,4 @@ +.actorcore +node_modules +dist +.vercel diff --git a/examples/sandbox-vercel/README.md b/examples/sandbox-vercel/README.md new file mode 100644 index 0000000000..f6374343f2 --- /dev/null +++ b/examples/sandbox-vercel/README.md @@ -0,0 +1,44 @@ +> **Note:** This is the Vercel-optimized version of the [sandbox](../sandbox) example. +> It uses the `hono/vercel` adapter and is configured for Vercel deployment. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Frivet-gg%2Frivet%2Ftree%2Fmain%2Fexamples%2Fsandbox-vercel&project-name=sandbox-vercel) + +# Sandbox + +Unified sandbox showcasing Rivet Actor features with a single registry, grouped navigation, and interactive demos. + +## Getting Started + +```bash +cd examples/sandbox +pnpm install +pnpm dev +``` + +## Features + +- Unified registry that aggregates actor fixtures and example actors +- Sidebar navigation grouped by core actor feature areas +- Action runner and event listener for quick experimentation +- Raw HTTP and WebSocket demos for handler-based actors +- Workflow and queue pattern coverage in a single sandbox + +## Prerequisites + +- OpenAI API key (set `OPENAI_API_KEY`) for the AI actor demo + +## Implementation + +The sandbox registry imports fixtures and example actors into one setup so each page can expose a curated subset. + +See the registry in [`src/actors.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/sandbox/src/actors.ts) and the UI in [`frontend/App.tsx`](https://github.com/rivet-dev/rivet/tree/main/examples/sandbox/frontend/App.tsx). + +## Resources + +Read more about [Rivet Actors](https://rivet.dev/docs/actors), +[actions](https://rivet.dev/docs/actors/actions), and +[connections](https://rivet.dev/docs/actors/connections). + +## License + +MIT diff --git a/examples/sandbox-vercel/api/index.ts b/examples/sandbox-vercel/api/index.ts new file mode 100644 index 0000000000..07a830391f --- /dev/null +++ b/examples/sandbox-vercel/api/index.ts @@ -0,0 +1,3 @@ +import app from "../src/server.ts"; + +export default app; diff --git a/examples/sandbox-vercel/frontend/App.tsx b/examples/sandbox-vercel/frontend/App.tsx new file mode 100644 index 0000000000..44b7231e27 --- /dev/null +++ b/examples/sandbox-vercel/frontend/App.tsx @@ -0,0 +1,981 @@ +import { createRivetKit } from "@rivetkit/react"; +import mermaid from "mermaid"; +import { Highlight, themes } from "prism-react-renderer"; +import { + Code, + Compass, + Database, + FlaskConical, + GitBranch, + Globe, + List, + Network, + Radio, + RefreshCw, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { registry } from "../src/actors.ts"; +import { + ACTION_TEMPLATES, + type ActionTemplate, + PAGE_GROUPS, + PAGE_INDEX, + type PageConfig, +} from "./page-data.ts"; + +type ActorName = (typeof registry)["config"]["use"] extends Record ? K & string : never; + +const GROUP_ICONS: Record> = { + compass: Compass, + code: Code, + database: Database, + radio: Radio, + globe: Globe, + "refresh-cw": RefreshCw, + list: List, + "git-branch": GitBranch, + network: Network, + "flask-conical": FlaskConical, +}; + +mermaid.initialize({ + startOnLoad: false, + theme: "dark", + themeVariables: { + darkMode: true, + background: "#0a0a0a", + primaryColor: "#1c1c1e", + primaryTextColor: "#ffffff", + primaryBorderColor: "#3a3a3c", + lineColor: "#3a3a3c", + secondaryColor: "#2c2c2e", + tertiaryColor: "#0f0f0f", + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif", + fontSize: "13px", + }, +}); + +function MermaidDiagram({ chart }: { chart: string }) { + const ref = useRef(null); + const [svg, setSvg] = useState(""); + + useEffect(() => { + let cancelled = false; + const id = `mermaid-${Math.random().toString(36).slice(2)}`; + mermaid.render(id, chart).then(({ svg: renderedSvg }) => { + if (!cancelled) setSvg(renderedSvg); + }); + return () => { cancelled = true; }; + }, [chart]); + + return
; +} + +const { useActor } = createRivetKit( + `${location.origin}/api/rivet`, +); + +type JsonResult = { ok: true; value: T } | { ok: false; error: string }; + +function parseJson(value: string): JsonResult { + try { + const parsed = JSON.parse(value) as T; + return { ok: true, value: parsed }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Invalid JSON", + }; + } +} + +function parseKey(value: string): JsonResult { + const trimmed = value.trim(); + if (trimmed.startsWith("[")) { + return parseJson(trimmed); + } + return { ok: true, value: trimmed || "demo" }; +} + +function formatJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +function usePersistedState(key: string, initial: T) { + const [state, setState] = useState(() => { + const stored = localStorage.getItem(key); + return stored ? (JSON.parse(stored) as T) : initial; + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)); + }, [key, state]); + + return [state, setState] as const; +} + +function resolvePage(pageId: string) { + return PAGE_INDEX.find((page) => page.id === pageId) ?? PAGE_INDEX[0]; +} + +function formatActorName(name: string) { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/-/g, " ") + .replace(/_/g, " ") + .replace(/^\w/, (char) => char.toUpperCase()); +} + +function getStateAction(actorName: string): string | undefined { + const templates = ACTION_TEMPLATES[actorName] ?? []; + return templates.find(t => t.args.length === 0)?.action; +} + +// ── Main App ────────────────────────────────────── + +export function App() { + const [activePageId, setActivePageId] = usePersistedState( + "sandbox:page", + PAGE_GROUPS[0].pages[0].id, + ); + const activePage = resolvePage(activePageId); + + return ( +
+ + +
+
+

{activePage.title}

+

{activePage.description}

+
+ + {activePage.id === "welcome" ? ( + + ) : ( + + )} +
+
+ ); +} + +// ── Demo Panel Router ───────────────────────────── + +function DemoPanel({ page }: { page: PageConfig }) { + if (page.demo === "diagram") { + return ; + } + if (page.actors.length === 0) { + return ; + } + if (page.demo === "raw-http") { + return ; + } + if (page.demo === "raw-websocket") { + return ; + } + return ; +} + +// ── Actor Demo Panel (tabs + view + code) ───────── + +function ActorDemoPanel({ page }: { page: PageConfig }) { + const [selectedIdx, setSelectedIdx] = useState(0); + + useEffect(() => { setSelectedIdx(0); }, [page.id]); + + const actorName = page.actors[selectedIdx] ?? page.actors[0]; + + return ( +
+ {page.actors.length > 1 && ( +
+ {page.actors.map((name, idx) => ( + + ))} +
+ )} + + + +
+
+ Source +
+ +
+
+ ); +} + +// ── Actor View (two-column: controls | inspector) ─ + +function ActorView({ actorName, page }: { actorName: string; page: PageConfig }) { + const [keyInput, setKeyInput] = usePersistedState( + `sandbox:${page.id}:${actorName}:key`, + `demo-${page.id}`, + ); + const [paramsInput, setParamsInput] = usePersistedState( + `sandbox:${page.id}:${actorName}:params`, + "{}", + ); + const [createInput, setCreateInput] = usePersistedState( + `sandbox:${page.id}:${actorName}:input`, + "{}", + ); + + const parsedKey = useMemo(() => parseKey(keyInput), [keyInput]); + const parsedParams = useMemo( + () => parseJson>(paramsInput), + [paramsInput], + ); + const parsedInput = useMemo( + () => parseJson(createInput), + [createInput], + ); + + const resolvedParams = + parsedParams.ok && paramsInput.trim() !== "{}" + ? parsedParams.value + : undefined; + const resolvedInput = + parsedInput.ok && createInput.trim() !== "{}" + ? parsedInput.value + : undefined; + + const actor = useActor({ + name: actorName as ActorName, + key: parsedKey.ok ? parsedKey.value : "demo", + params: resolvedParams, + createWithInput: resolvedInput, + }); + + const templates = ACTION_TEMPLATES[actorName] ?? []; + const stateAction = getStateAction(actorName); + + const [stateRefreshCounter, setStateRefreshCounter] = useState(0); + const triggerStateRefresh = useCallback( + () => setStateRefreshCounter(c => c + 1), + [], + ); + + return ( +
+
+
+ {page.actors.length === 1 && ( + {formatActorName(actorName)} + )} +
+ + {actor.connStatus ?? "idle"} +
+
+
+
+ + setKeyInput(e.target.value)} + placeholder="demo" + /> +
+
+ + setParamsInput(e.target.value)} + placeholder="{}" + /> +
+
+ + setCreateInput(e.target.value)} + placeholder="{}" + /> +
+
+
+ +
+
+
Actions
+ +
+ +
+ {stateAction && ( + + )} + +
+
+
+ ); +} + +// ── State Panel ─────────────────────────────────── + +function StatePanel({ + actor, + stateAction, + refreshTrigger, +}: { + actor: ReturnType; + stateAction: string; + refreshTrigger: number; +}) { + const [state, setState] = useState(""); + const [isRefreshing, setIsRefreshing] = useState(false); + const handleRef = useRef(actor.handle); + handleRef.current = actor.handle; + + const refresh = useCallback(async () => { + const handle = handleRef.current; + if (!handle) return; + setIsRefreshing(true); + try { + const result = await handle.action({ + name: stateAction, + args: [], + }); + setState(formatJson(result)); + } catch (err) { + setState(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setIsRefreshing(false); + } + }, [stateAction]); + + useEffect(() => { + if (actor.connStatus === "connected") { + refresh(); + } + }, [actor.connStatus, refresh]); + + useEffect(() => { + if (refreshTrigger > 0) { + refresh(); + } + }, [refreshTrigger, refresh]); + + return ( +
+
+ State + +
+
+ {actor.connStatus !== "connected" + ? "Connecting\u2026" + : state || "Loading\u2026"} +
+
+ ); +} + +// ── Events Panel ────────────────────────────────── + +function EventsPanel({ actor }: { actor: ReturnType }) { + const [eventName, setEventName] = useState(""); + const [events, setEvents] = useState>([]); + + useEffect(() => { + if (!eventName.trim() || !actor.connection) return; + + const stop = actor.connection.on(eventName, (...args: unknown[]) => { + const now = new Date(); + const time = [now.getHours(), now.getMinutes(), now.getSeconds()] + .map(n => n.toString().padStart(2, "0")) + .join(":"); + setEvents((prev) => [ + { time, data: formatJson(args.length === 1 ? args[0] : args) }, + ...prev.slice(0, 49), + ]); + }); + + return () => { stop(); }; + }, [actor.connection, eventName]); + + return ( +
+
+ Events +
+ setEventName(e.target.value)} + placeholder="event name" + className="inspector-input" + /> + {events.length > 0 && ( + + )} +
+
+
+ {events.length === 0 ? ( +
+ {eventName + ? "Waiting for events\u2026" + : "Enter an event name to listen"} +
+ ) : ( + events.map((entry, i) => ( +
+ {entry.time} + {entry.data} +
+ )) + )} +
+
+ ); +} + +// ── Code Block ──────────────────────────────────── + +function CodeBlock({ code }: { code: string }) { + return ( + + {({ tokens, getLineProps, getTokenProps }) => ( +
+					{tokens.map((line, i) => (
+						
+ {line.map((token, key) => ( + + ))} +
+ ))} +
+ )} +
+ ); +} + +// ── Action Runner ───────────────────────────────── + +function ActionRunner({ + actor, + templates, + onActionComplete, +}: { + actor: ReturnType; + templates: ActionTemplate[]; + onActionComplete?: () => void; +}) { + const [selectedIdx, setSelectedIdx] = useState(0); + const selectedTemplate = templates[selectedIdx]; + const [argsInput, setArgsInput] = useState( + selectedTemplate ? JSON.stringify(selectedTemplate.args) : "[]", + ); + const [result, setResult] = useState(""); + const [error, setError] = useState(""); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + setSelectedIdx(0); + if (templates[0]) { + setArgsInput(JSON.stringify(templates[0].args)); + } else { + setArgsInput("[]"); + } + }, [templates]); + + const selectTemplate = (idx: number) => { + setSelectedIdx(idx); + setArgsInput(JSON.stringify(templates[idx].args)); + setResult(""); + setError(""); + }; + + const parsedArgs = useMemo( + () => parseJson(argsInput), + [argsInput], + ); + + const runAction = async () => { + setError(""); + setResult(""); + const actionName = selectedTemplate?.action; + if (!actor.handle) { + setError("Actor handle is not ready."); + return; + } + if (!actionName) { + setError("Select an action to run."); + return; + } + if (!parsedArgs.ok) { + setError(parsedArgs.error); + return; + } + + setIsRunning(true); + try { + const response = await actor.handle.action({ + name: actionName, + args: parsedArgs.value, + }); + setResult(formatJson(response)); + onActionComplete?.(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsRunning(false); + } + }; + + if (templates.length === 0) { + return
No actions available for this actor.
; + } + + return ( +
+
+ {templates.map((template, idx) => ( + + ))} +
+ +
+
+ setArgsInput(event.target.value)} + placeholder="[]" + className="action-args-input" + /> + +
+ {!parsedArgs.ok &&
{parsedArgs.error}
} + {error &&
{error}
} + {result &&
{result}
} +
+
+ ); +} + +// ── Welcome / Diagram / Config ──────────────────── + +function WelcomePanel() { + return ( +
+

+ This sandbox lets you interact with every Rivet Actor feature + in one place. Pick a topic from the sidebar to connect to + live actors, invoke actions, and observe events in real time. +

+
+ ); +} + +function DiagramPanel({ page }: { page: PageConfig }) { + if (!page.diagram) return null; + + return ( +
+ +
+ ); +} + +function ConfigPlayground() { + const [jsonInput, setJsonInput] = useState( + '{\n "key": ["demo"],\n "params": {\n "region": "local"\n }\n}', + ); + const parsed = parseJson>(jsonInput); + + return ( +
+
+

Configuration Playground

+

+ Edit JSON to explore how actor configuration payloads are + shaped. +

+
+
+
+ +