Durable Objects
WebSocket Hibernation と SQLite バックエンドの Durable Objects
Durable Object(DO)は、独自の永続ストレージを持つ単一インスタンスでアドレス指定可能な Worker である。名前付きの各インスタンスは調整ポイントであり、特定の ID に対するすべての リクエストは同じオブジェクトにルーティングされる。そのため、リアルタイムなファンアウト (WebSocket)、正確でなければならないカウンター、単一の信頼できる情報源を必要とする あらゆる状態にとって自然な置き場所となる。
このページでは、DO を本番環境で動かすうえで自明でない 2 つの部分、すなわち WebSocket Hibernation API と SQLite バックエンドのストレージ に焦点を当てる。 接続中のエディターへファイル変更をブロードキャストする実在の同期サーバーを題材にしている。
WebSocket Hibernation: ソケットは保持せず再取得する
従来の DO の WebSocket モデルでは server.accept() と
addEventListener("message", ...) を使い、接続が続くあいだオブジェクトをメモリに
固定し続けていた。一方 Hibernation API では、WebSocket 接続を開いたまま、
メッセージとメッセージのあいだにランタイムがオブジェクトをメモリから退避させ、
必要時に再構築できる。アイドル状態の接続ではなく、実際の CPU 時間に対してのみ課金される。
この契約には 3 つの要素がある。
server.accept()ではなくstate.acceptWebSocket(server, [tag])でソケットを 受け入れる。webSocketMessage/webSocketCloseをaddEventListenerのコールバックではなく DO クラスのメソッド として実装する。- ソケットが必要になるたびに
state.getWebSockets(tag)でライブなソケットを再取得する。
export class SyncRoom implements DurableObject {
private state: DurableObjectState;
constructor(state: DurableObjectState, _env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
// Handle WebSocket upgrade
if (request.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Accept and tag with metadata for hibernation API
this.state.acceptWebSocket(server, ["vault"]);
const socketCount = this.state.getWebSockets("vault").length;
log.info("WebSocket connected", { socketCount });
return new Response(null, { status: 101, webSocket: client });
}
// Handle POST /notify — called by file handlers to broadcast changes
if (request.method === "POST") {
const data = await request.json();
this.broadcast(data);
return new Response("ok");
}
return new Response("Not Found", { status: 404 });
}
// WebSocket Hibernation API handlers
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
try {
const msg = JSON.parse(message as string);
if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
} catch (error) {
log.warn("malformed WebSocket message", {
error: error instanceof Error ? error.message : String(error),
});
}
}
async webSocketClose(_ws: WebSocket): Promise<void> {
const socketCount = this.state.getWebSockets("vault").length;
log.info("WebSocket disconnected", { socketCount });
}
private broadcast(data: unknown, exclude?: WebSocket): void {
const sockets = this.state.getWebSockets("vault");
const msg = JSON.stringify(data);
let sentCount = 0;
for (const ws of sockets) {
if (ws !== exclude) {
try {
ws.send(msg);
sentCount++;
} catch {
// Socket closed — hibernation API will clean it up
}
}
}
log.debug("broadcast", { recipients: sentCount, totalSockets: sockets.length });
}
}
アップグレードのハンドシェイク
WebSocket のアップグレードは手作業で完了させる。new WebSocketPair() はパイプの両端を
返す。server 側は DO 内に保持し、client 側は特別な 101 Switching Protocols
ステータスと Response の webSocket フィールドとともに呼び出し元へ返す。
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.state.acceptWebSocket(server, ["vault"]);
return new Response(null, { status: 101, webSocket: client });
["vault"] という配列は タグ のリストである。タグを使うとソケットをグループ化して
あとから問い合わせられる。ここではルーム内のすべての接続が vault でタグ付けされている。
なぜ getWebSockets(tag) でソケットを再取得しなければならないのか
これは従来モデルから移行した人がつまずくルールだ。ランタイムは
メッセージとメッセージのあいだにオブジェクトをメモリから退避させ、あとで再構築できる
ため、インスタンスフィールドに溜め込んだもの(Set<WebSocket>、Map、カウンターなど)は
生き残らない。再構築後、フィールドはコンストラクターのデフォルト値に戻っているが、
WebSocket 接続は依然として開いている。
ライブな接続への唯一の永続的なハンドルはランタイム自身である。現在のセットが必要になる
たびに、つまり接続時、クローズ時、そしてブロードキャストのたびに
state.getWebSockets("vault") を呼び出す。
const sockets = this.state.getWebSockets("vault");
⚠️ ソケットをインスタンスフィールドにキャッシュしない
DO はメッセージとメッセージのあいだにメモリから退避され、次のメッセージで再構築される
ことがある。インスタンスフィールドはそのサイクルをまたいでコンストラクターの値に
リセットされるため、this.sockets に格納したソケット一覧は、接続が生きていても再構築後は
空になる。参照を保持するのではなく、必ず state.getWebSockets(tag) で再取得すること。
ブロードキャストは送信エラーを意図的に握りつぶす
メッセージをファンアウトするとき、オブジェクトがまだ通知を受け取っていない形でソケットが
すでにクローズされていることがある。ブロードキャストループは生存確認を自前で行うのではなく、
send のエラーをあえて握りつぶす。生存状態の信頼できる情報源は Hibernation ランタイムで
あり、死んだソケットはランタイムが回収する。
try {
ws.send(msg);
sentCount++;
} catch {
// Socket closed — hibernation API will clean it up
}
SQLite バックエンドのストレージと migrations の罠
DO はストレージバックエンドを wrangler.toml の migration で一度だけ宣言する必要が
ある。new_sqlite_classes ディレクティブは、そのクラスを SQLite バックエンドの
ストレージ(同期的な state.storage.sql API も使えるようになる、現在のデフォルト)に
登録する。
[durable_objects]
bindings = [
{ name = "SYNC_ROOM", class_name = "SyncRoom" }
]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["SyncRoom"]
[durable_objects] bindingsは、そのクラスをSYNC_ROOMという名前で Worker に 公開する(スタブを得るにはenv.SYNC_ROOMとしてアクセスする)。[[migrations]]はテーブルの TOML 配列である。各エントリは一意のtagと、その エントリが導入またはリネームするクラスを持つ。new_sqlite_classesはSyncRoomを SQLite バックエンドの DO としてマークする。これらの migration はwrangler deployで適用される。
🚨 [[migrations]] は wrangler d1 migrations ではない
Cloudflare では「migration」という語がまったく無関係な 2 つの意味を持ち、両者を混同するのは よくある罠だ。
wrangler.tomlの[[migrations]]は Durable Object の クラス の変更 (クラスの作成、ストレージバックエンドの切り替え、リネーム、削除)を宣言する。これらはwrangler deploy時に自動的に適用される。wrangler durable-objects migrationsという コマンドは存在せず、TOML を編集してデプロイするだけだ。wrangler d1 migrationsは、D1 データベースに対するバージョン管理された SQL スキーマ 変更のための別個の CLI ワークフローである(wrangler d1 migrations create/apply)。Durable Objects とは何の関係もない。
SQLite にデータを格納する DO も、wrangler d1 migrations ではなく [[migrations]] を
通じて設定する。
いつ Durable Object を使うか
- リアルタイムなファンアウト — チャットルーム、共同編集エディター、ライブ ダッシュボード。1 ルームにつき 1 つの DO を置き、各メンバーの WebSocket をタグ付けして ブロードキャストする。
- 正確な調整 — 厳密でなければならないカウンター、レート制限、ロック。KV は結果整合性で あり、同時実行下では数件のリクエストが通過してしまう。DO は単一インスタンスであり アクセスを直列化する。
- エンティティごとの状態 — ユーザー、ドキュメント、ゲームセッションごとに 1 つの オブジェクトを置き、それぞれが独自のプライベートなストレージと単一スレッドの実行を持つ。
近似的で結果整合な値だけで足りるなら、KV のほうが安くシンプルだ。「単一の信頼できる情報源」 や「すべての接続がいま見えている」ことが必須要件であるときに DO を選ぶ。