branch:
ReadonlyDemo.tsx
11117 bytesRaw
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<Env> {
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<ReadonlyAgentState>(initialState);
const [toasts, setToasts] = useState<Toast[]>([]);
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<ReadonlyAgent, ReadonlyAgentState>({
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 (
<Surface className="relative p-5 rounded-lg ring ring-kumo-line space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
isReadonly
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
: "bg-green-500/10 text-green-600 dark:text-green-400"
}`}
>
{isReadonly
? `${isViewer ? "Viewer" : "Editor"} (readonly)`
: `${isViewer ? "Viewer" : "Editor"} (read-write)`}
</span>
{/* Readonly toggle — inline in the header so it adds no extra height */}
{isViewer && connected && (
<label className="inline-flex items-center gap-1.5 text-xs cursor-pointer select-none">
<input
type="checkbox"
checked={isReadonly}
onChange={handleToggleReadonly}
className="h-3.5 w-3.5 rounded border-kumo-line accent-amber-500"
/>
<span className="text-kumo-subtle">Lock</span>
</label>
)}
</div>
<ConnectionStatus status={connected ? "connected" : "connecting"} />
</div>
{/* Counter */}
<div className="text-center py-4">
<span className="tabular-nums">
<Text variant="heading1">{state.counter}</Text>
</span>
<p className="text-xs text-kumo-inactive mt-1">
{state.lastUpdatedBy
? `Last updated by: ${state.lastUpdatedBy}`
: "\u00A0"}
</p>
</div>
{/* Controls — grouped by mutation mechanism */}
<div className="space-y-3">
{/* Callable RPCs that call this.setState() internally */}
<div>
<p className="text-[0.65rem] uppercase tracking-wider text-kumo-inactive text-center mb-1.5">
via @callable()
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="secondary"
onClick={handleDecrement}
disabled={!connected}
>
−1
</Button>
<Button
variant="primary"
onClick={handleIncrement}
disabled={!connected}
>
+1
</Button>
<Button
variant="secondary"
onClick={handleReset}
disabled={!connected}
>
Reset
</Button>
</div>
</div>
{/* Client-side setState() */}
<div>
<p className="text-[0.65rem] uppercase tracking-wider text-kumo-inactive text-center mb-1.5">
via client setState()
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="secondary"
onClick={handleClientSetState}
disabled={!connected}
>
+10
</Button>
</div>
</div>
{/* Non-mutating RPC — always allowed */}
<div className="flex justify-center">
<Button
variant="secondary"
onClick={handleCheckPermissions}
disabled={!connected}
>
Check Permissions
</Button>
</div>
</div>
{/* Toasts — stacked bottom-right, absolutely positioned */}
{toasts.length > 0 && (
<div className="absolute right-3 bottom-3 z-10 flex flex-col items-end gap-1.5">
{toasts.map((t) => (
<div
key={t.id}
className={`rounded-lg px-3 py-1.5 text-xs shadow-lg backdrop-blur-sm animate-in fade-in slide-in-from-right-2 ${
t.kind === "info"
? "bg-blue-500/10 border border-blue-500/30 text-blue-600 dark:text-blue-400"
: "bg-kumo-danger-tint border border-kumo-danger text-kumo-danger"
}`}
>
{t.message}
</div>
))}
</div>
)}
{/* State JSON */}
<HighlightedJson data={state} />
</Surface>
);
}
export function ReadonlyDemo() {
return (
<DemoWrapper
title="Readonly Connections"
description={
<>
Connections can be marked as read-only by overriding{" "}
<code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
shouldConnectionBeReadonly
</code>
. Readonly connections still receive real-time state updates, but any
attempt to mutate state — whether via a{" "}
<code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
@callable
</code>{" "}
method or{" "}
<code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
agent.setState()
</code>{" "}
from the client — is blocked. Below, the Editor can write and the
Viewer can only watch. Toggle the lock to change permissions at
runtime.
</>
}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ConnectionPanel mode="edit" />
<ConnectionPanel mode="view" />
</div>
<CodeExplanation sections={codeSections} />
</DemoWrapper>
);
}