跳转到内容
搜索文档

只读连接

最后更新 查看 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)

AgentClient 与 useAgent 选项上的 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.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 连接都会自动被阻止。

注意事项

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

相关资源

这篇文档对您有帮助吗?