只读连接
只读连接(Readonly connections)限制特定 WebSocket 客户端修改 agent 状态,但仍允许它们接收状态更新并调用不会改变状态的 RPC 方法。
概述
当一个连接被标记为只读时:
- 它会接收来自服务器的状态更新
- 它可以调用不会修改状态的 RPC 方法
- 它不能调用
this.setState()—— 无论是通过客户端的setState(),还是通过内部调用this.setState()的@callable()方法
这适用于以下场景:
-
只读模式:用户只能查看,不能修改
-
基于角色的访问:根据用户角色限制状态修改
-
多租户场景:某些租户拥有只读权限
-
审计与监控连接:不应影响系统的观察者
JavaScript
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";
}
}
TypeScript
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";
}
}
JavaScript
// 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");
},
});
TypeScript
// 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");
},
});
将连接标记为只读
在连接时标记
重写 shouldConnectionBeReadonly,在每个连接首次接入时进行评估。返回 true 即将其标记为只读。
JavaScript
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";
}
}
TypeScript
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";
}
}
Explain Code
这个钩子在初始状态发送给客户端之前运行,因此连接从第一条消息起就是只读的。
任意时刻标记
使用 setConnectionReadonly 动态变更连接的只读状态:
JavaScript
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);
}
}
}
Explain Code
TypeScript
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);
}
}
}
Explain Code
让连接切换自身状态
连接可以通过一个 callable 切换自身的只读状态。这适用于锁定/解锁的 UI,即查看者可以选择进入编辑模式:
JavaScript
import { Agent, callable, getCurrentAgent } from "agents";
export class CollabAgent extends Agent {
@callable()
async setMyReadonly(readonly) {
const { connection } = getCurrentAgent();
if (connection) {
this.setConnectionReadonly(connection, readonly);
}
}
}
Explain Code
TypeScript
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);
}
}
}
Explain Code
在客户端:
JavaScript
// Toggle between readonly and writable
await agent.call("setMyReadonly", [true]); // lock
await agent.call("setMyReadonly", [false]); // unlock
TypeScript
// Toggle between readonly and writable
await agent.call("setMyReadonly", [true]); // lock
await agent.call("setMyReadonly", [false]); // unlock
检查状态
使用 isConnectionReadonly 检查连接当前的状态:
JavaScript
export class MyAgent extends Agent {
@callable()
async getPermissions() {
const { connection } = getCurrentAgent();
if (connection) {
return { canEdit: !this.isConnectionReadonly(connection) };
}
}
}
TypeScript
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 调用以错误拒绝。用try/catch包裹agent.call()来处理。
注意
当 validateStateChange 拒绝来自客户端的状态更新时(消息为 "State update rejected"),onStateUpdateError 也会触发。这使该回调可以处理任何被拒绝的状态写入,而不只是只读错误。
JavaScript
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"
}
Explain Code
TypeScript
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"
}
Explain Code
为了一开始就避免显示错误,在渲染编辑控件前先检查权限:
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>;
}
Explain Code
API 参考
shouldConnectionBeReadonly
可重写的钩子,用于决定连接接入时是否应被标记为只读。
| 参数 | 类型 | 说明 |
|---|---|---|
| connection | Connection | 接入的客户端 |
| ctx | ConnectionContext | 包含 upgrade 请求 |
| 返回值 | boolean | true 表示标记为只读 |
默认值:返回 false(所有连接均可写)。
setConnectionReadonly
将连接标记或取消标记为只读。可在任意时刻调用。
| 参数 | 类型 | 说明 |
|---|---|---|
| connection | Connection | 要更新的连接 |
| readonly | boolean | true 表示设为只读(默认值:true) |
isConnectionReadonly
检查连接当前是否为只读。
| 参数 | 类型 | 说明 |
|---|---|---|
| connection | Connection | 要检查的连接 |
| 返回值 | boolean | true 表示只读 |
onStateUpdateError(客户端)
AgentClient 与 useAgent 选项上的回调。当服务器拒绝状态更新时调用。
| 参数 | 类型 | 说明 |
|---|---|---|
| error | string | 来自服务器的错误信息 |
示例
基于查询参数的访问
JavaScript
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");
},
});
Explain Code
TypeScript
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");
},
});
Explain Code
基于角色的访问控制
JavaScript
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");
}
}
Explain Code
TypeScript
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");
}
}
Explain Code
管理员仪表板
JavaScript
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");
},
});
Explain Code
TypeScript
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");
},
});
Explain Code
动态权限变更
JavaScript
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,
};
}
}
Explain Code
TypeScript
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>
);
}
Explain Code
工作原理
只读状态保存在连接的 WebSocket attachment 中,会通过 WebSocket Hibernation API 保持持久化。该标志在内部使用了独立的命名空间,因此不会被 connection.setState() 意外覆盖。协议消息控制 也使用相同的机制 —— 两种标志可以安全地共存于 attachment 中。这意味着:
- 能在 hibernation 之间存活 —— agent 唤醒时,标志会被序列化并恢复
- 无需清理 —— 连接关闭时,连接状态会自动丢弃
- 零开销 —— 没有数据库表或查询,仅使用连接内置的 attachment
- 对用户代码安全 ——
connection.state与connection.setState()永远不会暴露或覆盖只读标志
当只读连接尝试修改状态时,无论写入来自客户端的 setState(),还是来自 @callable() 方法,服务器都会阻止它:
Client (readonly) Agent
│ │
│ setState({ count: 1 }) │
│ ─────────────────────────────▶ │ Check readonly → blocked
│ ◀─────────────────────────── │
│ cf_agent_state_error │
│ │
│ call("increment") │
│ ─────────────────────────────▶ │ increment() calls this.setState()
│ │ Check readonly → throw
│ ◀─────────────────────────── │
│ RPC error: "Connection is │
│ readonly" │
│ │
│ call("getPermissions") │
│ ─────────────────────────────▶ │ getPermissions() — no setState()
│ ◀─────────────────────────── │
│ RPC result: { canEdit: false }│
Explain Code
只读模式限制了什么、不限制什么
| 操作 | 是否允许 |
|---|---|
| 接收状态广播 | 是 |
| 调用不写入状态的 @callable() 方法 | 是 |
| 调用会调用 this.setState() 的 @callable() 方法 | 否 |
| 通过客户端 setState() 发送状态更新 | 否 |
强制检查发生在 setState() 内部。当一个 @callable() 方法尝试调用 this.setState(),且当前连接上下文为只读时,框架会抛出 Error("Connection is readonly")。这意味着你不需要在 RPC 方法中手动做权限检查 —— 任何写入状态的 callable 都会自动对只读连接被阻止。
注意事项
Callable 中的副作用仍会执行
只读检查发生在 this.setState() 内部,而不是在 callable 的开头。如果你的方法在写入状态前有副作用,这些副作用仍会执行:
JavaScript
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
}
}
TypeScript
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
}
}
为了避免这种情况,要么在副作用之前检查权限,要么调整代码结构让状态写入排在最前面:
JavaScript
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);
}
}
Explain Code
TypeScript
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);
}
}
Explain Code
最佳实践
与认证结合使用
JavaScript
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;
}
}
Explain Code
TypeScript
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;
}
}
Explain Code
提供清晰的用户反馈
JavaScript
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.");
}
},
});
Explain Code
TypeScript
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.");
}
},
});
Explain Code
在 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>;
}
Explain Code
记录访问尝试
JavaScript
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(),
});
}
}
}
Explain Code
TypeScript
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(),
});
}
}
}
Explain Code
局限性
- 只读状态只对使用
setState()的状态更新生效 - RPC 方法仍可被调用(如有需要,请自行实现检查)
- 只读是按连接(per-connection)的标志,与用户身份无关
相关资源
- 存储与同步状态
- 协议消息 —— 抑制 JSON 协议帧,适用于仅二进制的客户端(可与只读模式结合使用)
- WebSockets
- 可调用方法