← Создание агентов на Cloudflare / agents / runtime / communication
Подключения только для чтения
Подключения только для чтения запрещают отдельным WebSocket-клиентам изменять состояние агента, но позволяют получать обновления состояния и вызывать неизменяющие RPC-методы.
Обзор
Когда соединение помечено как доступное только для чтения:
- Это получает обновления состояния от сервера
- Это может вызывать RPC-методы, которые не изменяют состояние
- Это не может вызов
this.setState(): ни через клиентскую сторонуsetState()ни через@callable()метод, который вызываетthis.setState()внутренне
Это полезно для таких сценариев, как:
- Режимы только для просмотра: Пользователи, которые должны только наблюдать, но не изменять
- Доступ на основе ролей: Ограничение изменений состояния в зависимости от роли пользователя
- Мультитенантные сценарии: У некоторых арендаторов доступ только для чтения
- Подключения для аудита и мониторинга: Наблюдатели, которые не должны влиять на систему
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");
},
});Пометка подключений как доступных только для чтения
При подключении
Переопределить 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";
}
}Этот хук выполняется ещё до отправки начального состояния клиенту, поэтому подключение доступно только для чтения начиная с самого первого сообщения.
В любой момент
Используйте 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);
}
}
}Разрешение соединению самостоятельно переключать свой статус
Соединение может переключать собственный статус readonly через callable. Это полезно для интерфейсов блокировки и разблокировки, где зрители могут по желанию переходить в режим редактирования:
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 завершается ошибкой. Обработайте её с помощьюtry/catchвокругagent.call().
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
Переопределяемый хук, который определяет, следует ли помечать подключение как доступное только для чтения в момент подключения.
| Параметр | Тип | Описание |
|---|---|---|
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 |
Сообщение об ошибке от сервера |
Примеры
Доступ на основе параметров запроса
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>
);
}Как это работает
Статус readonly хранится во вложении WebSocket-подключения, которое сохраняется благодаря WebSocket Hibernation API. Флаг использует внутреннее пространство имён, поэтому его нельзя случайно перезаписать через connection.setState(). Тот же механизм используется управление сообщениями протокола : оба флага безопасно сосуществуют во вложении. Это означает:
- Переживает гибернацию : флаг сериализуется и восстанавливается при пробуждении агента
- Очистка не требуется : состояние соединения автоматически удаляется при закрытии соединения
- Нулевые накладные расходы : ни таблиц базы данных, ни запросов, только встроенное вложение соединения
- Защищено от пользовательского кода,
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 }│Что ограничивает readonly, а что нет
| Действие | Разрешено? |
|---|---|
| Получайте трансляции состояния | Да |
Вызовите @callable() методы, которые не записывают состояние |
Да |
Вызовите @callable() методы, которые вызывают this.setState() |
Нет |
Отправляйте обновления состояния через клиентский setState() |
Нет |
Применение правил происходит внутри setState() сам по себе. Когда @callable() метод пытается вызвать this.setState() и текущий контекст соединения доступен только для чтения, фреймворк выбрасывает Error("Connection is readonly"). Это означает, что вам не нужны ручные проверки прав доступа в методах RPC: любой вызываемый метод, изменяющий состояние, автоматически блокируется для readonly-подключений.
Важные замечания
Побочные эффекты в вызываемых объектах всё равно выполняются
Проверка readonly выполняется внутри this.setState(), а не в начале вызываемой функции. Если ваш метод содержит побочные эффекты перед записью состояния, они всё равно выполнятся:
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
}
}Чтобы этого избежать, либо проверяйте права доступа перед побочными эффектами, либо стройте код так, чтобы запись состояния выполнялась первой:
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() - RPC-методы по-прежнему можно вызывать (при необходимости реализуйте собственные проверки)
- Флаг readonly привязан к подключению, а не к идентификации пользователя
Дополнительные материалы
- Хранение и синхронизация состояния
- Сообщения протокола : подавляет фреймы протокола JSON для клиентов, работающих только с двоичными данными (можно сочетать с readonly)
- WebSockets
- Вызываемые методы