只读连接限制部分 WebSocket 客户端修改 Agent state,同时仍允许其接收 state 更新并调用不修改 state 的 RPC 方法。
当连接被标记为只读时:
- 接收来自服务端的 state 更新
- 可以调用不修改 state 的 RPC 方法
- 不能调用
this.setState()——无论是通过客户端setState(),还是通过内部调用this.setState()的@callable()方法
适用于以下场景:
- 仅查看模式:用户只能观察,不能修改
- 基于角色的访问:根据用户角色限制 state 修改
- 多租户场景:部分租户仅有只读访问权限
- 审计与监控连接:观察者不应影响系统
import { Agent } from "agents";
export class DocAgent extends Agent {
shouldConnectionBeReadonly(connection, ctx) {
const url = new URL(ctx.request.url);
return url.searchParams.get("mode") === "view";
}
}import { Agent, type Connection, type ConnectionContext } from "agents";
export class DocAgent extends Agent<Env, DocState> {
shouldConnectionBeReadonly(connection: Connection, ctx: ConnectionContext) {
const url = new URL(ctx.request.url);
return url.searchParams.get("mode") === "view";
}
}// Client - view-only mode
const agent = useAgent({
agent: "DocAgent",
name: "doc-123",
query: { mode: "view" },
onStateUpdateError: (error) => {
toast.error("You're in view-only mode");
},
});// Client - view-only mode
const agent = useAgent({
agent: "DocAgent",
name: "doc-123",
query: { mode: "view" },
onStateUpdateError: (error) => {
toast.error("You're in view-only mode");
},
});override shouldConnectionBeReadonly,在连接首次建立时评估每个连接。返回 true 将其标记为只读。
export class MyAgent extends Agent {
shouldConnectionBeReadonly(connection, ctx) {
const url = new URL(ctx.request.url);
const role = url.searchParams.get("role");
return role === "viewer" || role === "guest";
}
}export class MyAgent extends Agent<Env, State> {
shouldConnectionBeReadonly(
connection: Connection,
ctx: ConnectionContext,
): boolean {
const url = new URL(ctx.request.url);
const role = url.searchParams.get("role");
return role === "viewer" || role === "guest";
}
}此 hook 在初始 state 发送给客户端之前运行,因此连接从第一条消息起即为只读。
使用 setConnectionReadonly 动态更改连接的只读状态:
export class GameAgent extends Agent {
@callable()
async startSpectating() {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, true);
}
}
@callable()
async joinAsPlayer() {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, false);
}
}
}export class GameAgent extends Agent<Env, GameState> {
@callable()
async startSpectating() {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, true);
}
}
@callable()
async joinAsPlayer() {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, false);
}
}
}连接可通过 callable 切换自身只读状态。适用于锁定/解锁 UI,让查看者选择进入编辑模式:
import { Agent, callable, getCurrentAgent } from "agents";
export class CollabAgent extends Agent {
@callable()
async setMyReadonly(readonly) {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, readonly);
}
}
}import { Agent, callable, getCurrentAgent } from "agents";
export class CollabAgent extends Agent<Env, State> {
@callable()
async setMyReadonly(readonly: boolean) {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, readonly);
}
}
}客户端:
// Toggle between readonly and writable
await agent.call("setMyReadonly", [true]); // lock
await agent.call("setMyReadonly", [false]); // unlock// Toggle between readonly and writable
await agent.call("setMyReadonly", [true]); // lock
await agent.call("setMyReadonly", [false]); // unlock使用 isConnectionReadonly 检查连接的当前状态:
export class MyAgent extends Agent {
@callable()
async getPermissions() {
const { connection } = getCurrentAgent();
if (connection) {
return { canEdit: !this.isConnectionReadonly(connection) };
}
}
}export class MyAgent extends Agent<Env, State> {
@callable()
async getPermissions() {
const { connection } = getCurrentAgent();
if (connection) {
return { canEdit: !this.isConnectionReadonly(connection) };
}
}
}错误根据写入尝试方式有两种表现:
- 客户端
setState()— 服务端发送cf_agent_state_error消息。使用onStateUpdateError回调处理。 @callable()方法 — RPC 调用以错误拒绝。在agent.call()外使用try/catch处理。
const agent = useAgent({
agent: "MyAgent",
name: "instance",
// Fires when client-side setState() is blocked
onStateUpdateError: (error) => {
setError(error);
},
});
// Fires when a callable that writes state is blocked
try {
await agent.call("updateSettings", [newSettings]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e)); // "Connection is readonly"
}const agent = useAgent({
agent: "MyAgent",
name: "instance",
// Fires when client-side setState() is blocked
onStateUpdateError: (error) => {
setError(error);
},
});
// Fires when a callable that writes state is blocked
try {
await agent.call("updateSettings", [newSettings]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e)); // "Connection is readonly"
}为避免一开始就显示错误,在渲染编辑控件前先检查权限:
function Editor() {
const [canEdit, setCanEdit] = useState(false);
const agent = useAgent({ agent: "MyAgent", name: "instance" });
useEffect(() => {
agent.call("getPermissions").then((p) => setCanEdit(p.canEdit));
}, []);
return <button disabled={!canEdit}>{canEdit ? "Edit" : "View Only"}</button>;
}可 override 的 hook,决定连接建立时是否应标记为 readonly。
| 参数 | 类型 | 描述 |
|---|---|---|
connection |
Connection |
正在连接的 client |
ctx |
ConnectionContext |
含 upgrade request |
| 返回值 | boolean |
true 表示标记为 readonly |
默认:返回 false(所有连接可写)。
随时标记或取消连接的 readonly 状态。
| 参数 | 类型 | 描述 |
|---|---|---|
connection |
Connection |
要更新的连接 |
readonly |
boolean |
true 设为 readonly(默认:true) |
检查连接当前是否为 readonly。
| 参数 | 类型 | 描述 |
|---|---|---|
connection |
Connection |
要检查的连接 |
| 返回值 | boolean |
readonly 时为 true |
AgentClient 与 useAgent 选项上的 callback。服务端拒绝 state 更新时调用。
| 参数 | 类型 | 描述 |
|---|---|---|
error |
string |
来自服务端的错误消息 |
export class DocumentAgent extends Agent {
shouldConnectionBeReadonly(connection, ctx) {
const url = new URL(ctx.request.url);
const mode = url.searchParams.get("mode");
return mode === "view";
}
}
// Client connects with readonly mode
const agent = useAgent({
agent: "DocumentAgent",
name: "doc-123",
query: { mode: "view" },
onStateUpdateError: (error) => {
toast.error("Document is in view-only mode");
},
});export class DocumentAgent extends Agent<Env, DocumentState> {
shouldConnectionBeReadonly(
connection: Connection,
ctx: ConnectionContext,
): boolean {
const url = new URL(ctx.request.url);
const mode = url.searchParams.get("mode");
return mode === "view";
}
}
// Client connects with readonly mode
const agent = useAgent({
agent: "DocumentAgent",
name: "doc-123",
query: { mode: "view" },
onStateUpdateError: (error) => {
toast.error("Document is in view-only mode");
},
});export class CollaborativeAgent extends Agent {
shouldConnectionBeReadonly(connection, ctx) {
const url = new URL(ctx.request.url);
const role = url.searchParams.get("role");
return role === "viewer" || role === "guest";
}
onConnect(connection, ctx) {
const url = new URL(ctx.request.url);
const userId = url.searchParams.get("userId");
console.log(
`User ${userId} connected (readonly: ${this.isConnectionReadonly(connection)})`,
);
}
@callable()
async upgradeToEditor() {
const { connection } = getCurrentAgent();
if (!connection) return;
// Check permissions (pseudo-code)
const canUpgrade = await checkUserPermissions();
if (canUpgrade) {
this.setConnectionReadonly(connection, false);
return { success: true };
}
throw new Error("Insufficient permissions");
}
}export class CollaborativeAgent extends Agent<Env, CollabState> {
shouldConnectionBeReadonly(
connection: Connection,
ctx: ConnectionContext,
): boolean {
const url = new URL(ctx.request.url);
const role = url.searchParams.get("role");
return role === "viewer" || role === "guest";
}
onConnect(connection: Connection, ctx: ConnectionContext) {
const url = new URL(ctx.request.url);
const userId = url.searchParams.get("userId");
console.log(
`User ${userId} connected (readonly: ${this.isConnectionReadonly(connection)})`,
);
}
@callable()
async upgradeToEditor() {
const { connection } = getCurrentAgent();
if (!connection) return;
// Check permissions (pseudo-code)
const canUpgrade = await checkUserPermissions();
if (canUpgrade) {
this.setConnectionReadonly(connection, false);
return { success: true };
}
throw new Error("Insufficient permissions");
}
}export class MonitoringAgent extends Agent {
shouldConnectionBeReadonly(connection, ctx) {
const url = new URL(ctx.request.url);
// Only admins can modify state
return url.searchParams.get("admin") !== "true";
}
onStateChanged(state, source) {
if (source !== "server") {
// Log who modified the state
console.log(`State modified by connection ${source.id}`);
}
}
}
// Admin client (can modify)
const adminAgent = useAgent({
agent: "MonitoringAgent",
name: "system",
query: { admin: "true" },
});
// Viewer client (readonly)
const viewerAgent = useAgent({
agent: "MonitoringAgent",
name: "system",
query: { admin: "false" },
onStateUpdateError: (error) => {
console.log("Viewer cannot modify state");
},
});export class MonitoringAgent extends Agent<Env, SystemState> {
shouldConnectionBeReadonly(
connection: Connection,
ctx: ConnectionContext,
): boolean {
const url = new URL(ctx.request.url);
// Only admins can modify state
return url.searchParams.get("admin") !== "true";
}
onStateChanged(state: SystemState, source: Connection | "server") {
if (source !== "server") {
// Log who modified the state
console.log(`State modified by connection ${source.id}`);
}
}
}
// Admin client (can modify)
const adminAgent = useAgent({
agent: "MonitoringAgent",
name: "system",
query: { admin: "true" },
});
// Viewer client (readonly)
const viewerAgent = useAgent({
agent: "MonitoringAgent",
name: "system",
query: { admin: "false" },
onStateUpdateError: (error) => {
console.log("Viewer cannot modify state");
},
});export class GameAgent extends Agent {
@callable()
async startSpectatorMode() {
const { connection } = getCurrentAgent();
if (!connection) return;
this.setConnectionReadonly(connection, true);
return { mode: "spectator" };
}
@callable()
async joinAsPlayer() {
const { connection } = getCurrentAgent();
if (!connection) return;
const canJoin = this.state.players.length < 4;
if (canJoin) {
this.setConnectionReadonly(connection, false);
return { mode: "player" };
}
throw new Error("Game is full");
}
@callable()
async getMyPermissions() {
const { connection } = getCurrentAgent();
if (!connection) return null;
return {
canEdit: !this.isConnectionReadonly(connection),
connectionId: connection.id,
};
}
}export class GameAgent extends Agent<Env, GameState> {
@callable()
async startSpectatorMode() {
const { connection } = getCurrentAgent();
if (!connection) return;
this.setConnectionReadonly(connection, true);
return { mode: "spectator" };
}
@callable()
async joinAsPlayer() {
const { connection } = getCurrentAgent();
if (!connection) return;
const canJoin = this.state.players.length < 4;
if (canJoin) {
this.setConnectionReadonly(connection, false);
return { mode: "player" };
}
throw new Error("Game is full");
}
@callable()
async getMyPermissions() {
const { connection } = getCurrentAgent();
if (!connection) return null;
return {
canEdit: !this.isConnectionReadonly(connection),
connectionId: connection.id,
};
}
}客户端 React 组件:
function GameComponent() {
const [canEdit, setCanEdit] = useState(false);
const agent = useAgent({
agent: "GameAgent",
name: "game-123",
onStateUpdateError: (error) => {
toast.error("Cannot modify game state in spectator mode");
},
});
useEffect(() => {
agent.call("getMyPermissions").then((perms) => {
setCanEdit(perms?.canEdit ?? false);
});
}, [agent]);
return (
<div>
<button onClick={() => agent.call("joinAsPlayer")} disabled={canEdit}>
Join as Player
</button>
<button
onClick={() => agent.call("startSpectatorMode")}
disabled={!canEdit}
>
Switch to Spectator
</button>
<div>{canEdit ? "You can modify the game" : "You are spectating"}</div>
</div>
);
}只读状态存储在连接的 WebSocket 附件中,经 WebSocket Hibernation API 持久化。该标志在内部命名空间化,因此不会被 connection.setState() 意外覆盖。与 协议消息控制 使用相同机制——两个标志在附件中安全共存。这意味着:
- 经受休眠 — 标志序列化并在 agent 唤醒时恢复
- 无需清理 — 连接关闭时连接状态自动丢弃
- 零开销 — 无数据库表或查询,仅用连接内置附件
- 用户代码安全 —
connection.state与connection.setState()不会暴露或覆盖只读标志
当只读连接尝试修改状态时,服务端会阻止——无论写入来自客户端 setState() 还是 @callable() 方法:
Client(readonly) Agent
│ │
│ setState({ count: 1 }) │
│ ─────────────────────────────▶ │ 检查 readonly → 阻止
│ ◀─────────────────────────── │
│ cf_agent_state_error │
│ │
│ call("increment") │
│ ─────────────────────────────▶ │ increment() 调用 this.setState()
│ │ 检查 readonly → 抛出
│ ◀─────────────────────────── │
│ RPC error: "Connection is │
│ readonly" │
│ │
│ call("getPermissions") │
│ ─────────────────────────────▶ │ getPermissions() — 无 setState()
│ ◀─────────────────────────── │
│ RPC result: { canEdit: false }│| 操作 | 允许? |
|---|---|
| 接收状态广播 | 是 |
调用不写入状态的 @callable() 方法 |
是 |
调用会调用 this.setState() 的 @callable() 方法 |
否 |
通过 client 侧 setState() 发送 state 更新 |
否 |
强制发生在 setState() 内部。当 @callable() 方法尝试调用 this.setState() 且当前 connection 上下文为 readonly 时,框架会抛出 Error("Connection is readonly")。因此无需在 RPC 方法中手动检查权限——任何写入 state 的 callable 对 readonly 连接都会自动被阻止。
readonly 检查发生在 this.setState() 内部,而非 callable 开头。若方法在 state 写入前有副作用,这些仍会执行:
export class MyAgent extends Agent {
@callable()
async processOrder(orderId) {
await sendConfirmationEmail(orderId); // runs even for readonly connections
await chargePayment(orderId); // runs too
this.setState({ ...this.state, orders: [...this.state.orders, orderId] }); // throws
}
}export class MyAgent extends Agent<Env, State> {
@callable()
async processOrder(orderId: string) {
await sendConfirmationEmail(orderId); // runs even for readonly connections
await chargePayment(orderId); // runs too
this.setState({ ...this.state, orders: [...this.state.orders, orderId] }); // throws
}
}为避免此问题,可在副作用前检查权限,或将 state 写入放在前面:
export class MyAgent extends Agent {
@callable()
async processOrder(orderId) {
// Write state first — throws immediately for readonly connections
this.setState({ ...this.state, orders: [...this.state.orders, orderId] });
// Side effects only run if setState succeeded
await sendConfirmationEmail(orderId);
await chargePayment(orderId);
}
}export class MyAgent extends Agent<Env, State> {
@callable()
async processOrder(orderId: string) {
// Write state first — throws immediately for readonly connections
this.setState({ ...this.state, orders: [...this.state.orders, orderId] });
// Side effects only run if setState succeeded
await sendConfirmationEmail(orderId);
await chargePayment(orderId);
}
}export class SecureAgent extends Agent {
shouldConnectionBeReadonly(connection, ctx) {
const url = new URL(ctx.request.url);
const token = url.searchParams.get("token");
// Verify token and get permissions
const permissions = this.verifyToken(token);
return !permissions.canWrite;
}
}export class SecureAgent extends Agent<Env, State> {
shouldConnectionBeReadonly(
connection: Connection,
ctx: ConnectionContext,
): boolean {
const url = new URL(ctx.request.url);
const token = url.searchParams.get("token");
// Verify token and get permissions
const permissions = this.verifyToken(token);
return !permissions.canWrite;
}
}const agent = useAgent({
agent: "MyAgent",
name: "instance",
onStateUpdateError: (error) => {
// User-friendly messages
if (error.includes("readonly")) {
showToast("You are in view-only mode. Upgrade to edit.");
}
},
});const agent = useAgent({
agent: "MyAgent",
name: "instance",
onStateUpdateError: (error) => {
// User-friendly messages
if (error.includes("readonly")) {
showToast("You are in view-only mode. Upgrade to edit.");
}
},
});function EditButton() {
const [canEdit, setCanEdit] = useState(false);
const agent = useAgent({
/* ... */
});
useEffect(() => {
agent.call("checkPermissions").then((perms) => {
setCanEdit(perms.canEdit);
});
}, []);
return <button disabled={!canEdit}>{canEdit ? "Edit" : "View Only"}</button>;
}export class AuditedAgent extends Agent {
onStateChanged(state, source) {
if (source !== "server") {
this.audit({
action: "state_update",
connectionId: source.id,
readonly: this.isConnectionReadonly(source),
timestamp: Date.now(),
});
}
}
}export class AuditedAgent extends Agent<Env, State> {
onStateChanged(state: State, source: Connection | "server") {
if (source !== "server") {
this.audit({
action: "state_update",
connectionId: source.id,
readonly: this.isConnectionReadonly(source),
timestamp: Date.now(),
});
}
}
}- Readonly 状态仅适用于使用
setState()的 state 更新 - RPC 方法仍可调用(如需请自行实现检查)
- Readonly 是按连接(per-connection)的 flag,不与用户身份绑定
- 存储与同步 state
- 协议消息 — 为纯 binary client 抑制 JSON protocol 帧(可与 readonly 组合)
- WebSockets
- 可调用方法