Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

只读连接

只读连接(Readonly connections)限制特定 WebSocket 客户端修改 agent 状态,但仍允许它们接收状态更新并调用不会改变状态的 RPC 方法。

概述

当一个连接被标记为只读时:

  • 会接收来自服务器的状态更新
  • 可以调用不会修改状态的 RPC 方法
  • 不能调用 this.setState() —— 无论是通过客户端的 setState(),还是通过内部调用 this.setState()@callable() 方法

这适用于以下场景:

  • 只读模式:用户只能查看,不能修改

  • 基于角色的访问:根据用户角色限制状态修改

  • 多租户场景:某些租户拥有只读权限

  • 审计与监控连接:不应影响系统的观察者

  • JavaScript

  • TypeScript

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

可重写的钩子,用于决定连接接入时是否应被标记为只读。

参数类型说明
connectionConnection接入的客户端
ctxConnectionContext包含 upgrade 请求
返回值booleantrue 表示标记为只读

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

setConnectionReadonly

将连接标记或取消标记为只读。可在任意时刻调用。

参数类型说明
connectionConnection要更新的连接
readonlybooleantrue 表示设为只读(默认值:true)

isConnectionReadonly

检查连接当前是否为只读。

参数类型说明
connectionConnection要检查的连接
返回值booleantrue 表示只读

onStateUpdateError(客户端)

AgentClientuseAgent 选项上的回调。当服务器拒绝状态更新时调用。

参数类型说明
errorstring来自服务器的错误信息

示例

基于查询参数的访问

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.stateconnection.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)的标志,与用户身份无关

相关资源