跳转到内容
搜索文档

只读连接

最后更新 查看 MarkdownAgent 设置

只读连接限制部分 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>;
}

API 参考

shouldConnectionBeReadonly

可 override 的 hook,决定连接建立时是否应标记为 readonly。

参数 类型 描述
connection Connection 正在连接的 client
ctx ConnectionContext 含 upgrade request
返回值 boolean true 表示标记为 readonly

默认:返回 false(所有连接可写)。

setConnectionReadonly

随时标记或取消连接的 readonly 状态。

参数 类型 描述
connection Connection 要更新的连接
readonly boolean true 设为 readonly(默认:true

isConnectionReadonly

检查连接当前是否为 readonly。

参数 类型 描述
connection Connection 要检查的连接
返回值 boolean readonly 时为 true

onStateUpdateError(client)

AgentClientuseAgent 选项上的 callback。服务端拒绝 state 更新时调用。

参数 类型 描述
error string 来自服务端的错误消息

示例

基于 query 参数的访问

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.stateconnection.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 连接都会自动被阻止。

注意事项

callable 中的副作用仍会执行

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.");
		}
	},
});

在 UI 操作前检查权限

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,不与用户身份绑定

相关资源

这篇文档对您有帮助吗?