import { useAgent } from "agents/react"; import { useState, useEffect, useCallback, useRef } from "react"; import { Button, Surface, Text } from "@cloudflare/kumo"; import { DemoWrapper } from "../../layout"; import { ConnectionStatus, CodeExplanation, HighlightedJson, type CodeSection } from "../../components"; import { useUserId } from "../../hooks"; import type { ReadonlyAgent, ReadonlyAgentState } from "./readonly-agent"; const codeSections: CodeSection[] = [ { title: "Control access with shouldConnectionBeReadonly", description: "Override this hook to inspect the incoming request and decide if a connection should be read-only. The framework will block state mutations from readonly connections.", code: `import { Agent, type Connection, type ConnectionContext } from "agents"; class ReadonlyAgent extends Agent { shouldConnectionBeReadonly( connection: Connection, ctx: ConnectionContext ): boolean { const url = new URL(ctx.request.url); return url.searchParams.get("mode") === "view"; } }` }, { title: "What readonly blocks", description: "Readonly connections can still call non-mutating @callable methods and receive state updates. But any attempt to call this.setState() — whether from a @callable or from the client via agent.setState() — is rejected.", code: ` // This works for readonly connections (no state mutation) @callable() getPermissions() { const { connection } = getCurrentAgent(); return { canEdit: !this.isConnectionReadonly(connection) }; } // This is blocked for readonly connections @callable() increment() { this.setState({ ...this.state, counter: this.state.counter + 1 }); }` }, { title: "Toggle readonly at runtime", description: "Use setConnectionReadonly() to change a connection's access level dynamically, without requiring a reconnect.", code: ` @callable() setMyReadonly(readonly: boolean) { const { connection } = getCurrentAgent(); this.setConnectionReadonly(connection, readonly); return { readonly }; }` } ]; const AGENT_NAME = "readonly-agent"; const initialState: ReadonlyAgentState = { counter: 0, lastUpdatedBy: null }; interface Toast { id: number; message: string; kind: "error" | "info"; } const MAX_TOASTS = 5; /** A single connection panel — editor or viewer depending on `mode`. */ function ConnectionPanel({ mode }: { mode: "edit" | "view" }) { const userId = useUserId(); const isViewer = mode === "view"; const [state, setState] = useState(initialState); const [toasts, setToasts] = useState([]); const [isReadonly, setIsReadonly] = useState(isViewer); const nextId = useRef(0); const addToast = useCallback((message: string, kind: "error" | "info") => { const id = nextId.current++; setToasts((prev) => { // Deduplicate: if the most recent toast has the same message & kind, skip const last = prev[prev.length - 1]; if (last && last.message === message && last.kind === kind) return prev; // Cap the list so it doesn't grow unbounded const next = [...prev, { id, message, kind }]; return next.length > MAX_TOASTS ? next.slice(-MAX_TOASTS) : next; }); setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, 2500); }, []); const agent = useAgent({ agent: AGENT_NAME, name: `readonly-demo-${userId}`, // The viewer connects with ?mode=view, which the agent checks in shouldConnectionBeReadonly query: isViewer ? { mode: "view" } : undefined, onStateUpdate: (newState) => { if (newState) setState(newState); }, onStateUpdateError: (error) => { addToast(error, "error"); } }); const connected = agent.readyState === WebSocket.OPEN; // Refresh permissions when connection opens const refreshPermissions = useCallback(async () => { if (!connected) return; try { const result = await agent.call("getPermissions"); setIsReadonly(!result.canEdit); } catch { // ignore — connection may not be ready yet } }, [agent, connected]); useEffect(() => { refreshPermissions(); }, [refreshPermissions]); const showError = (msg: string) => addToast(msg, "error"); const showInfo = (msg: string) => addToast(msg, "info"); const handleIncrement = async () => { try { await agent.call("increment"); } catch (e) { showError(e instanceof Error ? e.message : String(e)); } }; const handleDecrement = async () => { try { await agent.call("decrement"); } catch (e) { showError(e instanceof Error ? e.message : String(e)); } }; const handleReset = async () => { try { await agent.call("resetCounter"); } catch (e) { showError(e instanceof Error ? e.message : String(e)); } }; const handleClientSetState = () => { agent.setState({ ...state, counter: state.counter + 10, lastUpdatedBy: "client" }); }; const handleCheckPermissions = async () => { try { const result = await agent.call("getPermissions"); setIsReadonly(!result.canEdit); showInfo(`canEdit = ${result.canEdit}`); } catch (e) { showError(e instanceof Error ? e.message : String(e)); } }; const handleToggleReadonly = async () => { try { const result = await agent.call("setMyReadonly", [!isReadonly]); setIsReadonly(result.readonly); } catch (e) { showError(e instanceof Error ? e.message : String(e)); } }; return ( {/* Header */}
{isReadonly ? `${isViewer ? "Viewer" : "Editor"} (readonly)` : `${isViewer ? "Viewer" : "Editor"} (read-write)`} {/* Readonly toggle — inline in the header so it adds no extra height */} {isViewer && connected && ( )}
{/* Counter */}
{state.counter}

{state.lastUpdatedBy ? `Last updated by: ${state.lastUpdatedBy}` : "\u00A0"}

{/* Controls — grouped by mutation mechanism */}
{/* Callable RPCs that call this.setState() internally */}

via @callable()

{/* Client-side setState() */}

via client setState()

{/* Non-mutating RPC — always allowed */}
{/* Toasts — stacked bottom-right, absolutely positioned */} {toasts.length > 0 && (
{toasts.map((t) => (
{t.message}
))}
)} {/* State JSON */}
); } export function ReadonlyDemo() { return ( Connections can be marked as read-only by overriding{" "} shouldConnectionBeReadonly . Readonly connections still receive real-time state updates, but any attempt to mutate state — whether via a{" "} @callable {" "} method or{" "} agent.setState() {" "} from the client — is blocked. Below, the Editor can write and the Viewer can only watch. Toggle the lock to change permissions at runtime. } >
); }