Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

読み取り専用接続

最終更新 Markdown で表示Agent セットアップ

読み取り専用接続は、特定の WebSocket クライアントがエージェント状態を変更できないようにしつつ、状態更新の受信と、状態を変更しない RPC メソッドの呼び出しは許可します。

概要

接続が読み取り専用とマークされると、次のようになります。

  • サーバーからの状態更新を受信します
  • 状態を変更しない RPC メソッドを呼び出せます
  • this.setState()呼べません。クライアント側の setState() でも、内部で this.setState() を呼ぶ @callable() メソッドでも同様です

次のような場面で役立ちます。

  • 閲覧専用モード: 観察だけして、変更してはいけないユーザー
  • ロールベースのアクセス: ユーザーロールに応じて状態変更を制限する
  • マルチテナント: 一部テナントは読み取り専用
  • 監査と監視の接続: システムに影響を与えてはいけない観測者
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);
		}
	}
}

接続自身に状態を切り替えさせる

接続は、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) };
		}
	}
}

クライアント側でエラーを処理する

書き込みの試み方によって、エラーは 2 通りに現れます。

  • クライアント側の 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

接続時に、その接続を読み取り専用にするかを決める、オーバーライド可能なフックです。

パラメーター 説明
connection Connection 接続しようとしているクライアント
ctx ConnectionContext アップグレードリクエストを含みます
戻り値 boolean true で読み取り専用にします

デフォルト: false を返します(すべての接続が書き込み可能です)。

setConnectionReadonly

接続を読み取り専用にする、または解除します。いつでも呼べます。

パラメーター 説明
connection Connection 更新する接続
readonly boolean true で読み取り専用にします(デフォルト: true

isConnectionReadonly

接続が現在読み取り専用かを確認します。

パラメーター 説明
connection Connection 確認する接続
戻り値 boolean 読み取り専用なら true

onStateUpdateError(クライアント)

AgentClientuseAgent オプションのコールバックです。サーバーが状態更新を拒否したときに呼ばれます。

パラメーター 説明
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>
	);
}

仕組み

読み取り専用フラグは、接続の WebSocket attachment に保存されます。WebSocket Hibernation API を通じて持続します。フラグは内部で名前空間化されているため、connection.setState() で誤って上書きされません。プロトコルメッセージ制御 も同じ仕組みを使うため、両方のフラグは attachment 内で安全に共存します。つまり次のとおりです。

  • ハイバネーションを生き延びます — フラグはシリアライズされ、エージェント起床時に復元されます
  • クリーンアップは不要です — 接続が閉じると、接続状態は自動で破棄されます
  • オーバーヘッドはゼロです — データベーステーブルもクエリもなく、接続組み込みの 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 }│

読み取り専用が制限するもの、しないもの

操作 許可されるか
状態ブロードキャストの受信 はい
状態を書き込まない @callable() メソッドの呼び出し はい
this.setState() を呼ぶ @callable() メソッドの呼び出し いいえ
クライアント側 setState() による状態更新の送信 いいえ

強制は setState() 自身の内部で行われます。@callable() メソッドが this.setState() を呼ぼうとし、現在の接続コンテキストが読み取り専用だと、フレームワークは Error("Connection is readonly") を投げます。そのため、RPC メソッド内で手動の権限チェックは不要です。状態を書き込む callable は、読み取り専用接続では自動でブロックされます。

注意点

callable 内の副作用は実行されます

読み取り専用チェックは、callable の先頭ではなく、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.");
		}
	},
});

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

制限事項

  • 読み取り専用状態は、setState() を使う状態更新にだけ適用されます
  • RPC メソッドは引き続き呼べます(必要なら独自のチェックを実装してください)
  • 読み取り専用は接続ごとのフラグであり、ユーザー識別子には結びつきません

関連リソース

役に立ちましたか?