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}
            >
              &minus;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>
  );
}