はじめに
ライブ WebSocket 接続は、メモリ上の 1 つの JavaScript オブジェクトよりもはるかに長く存続することがあります。Cloudflare は、通信のない Durable Object をハイバネーションできます。クライアントはネットワークエッジで接続されたままですが、オブジェクトのメモリ上のフィールドは消えます。その後メッセージが届くと、新しいクラスインスタンスが起動します。これによりアイドル時間に対する課金を抑えられますが、クライアント名やロールを通常のインメモリマップだけで管理することは信頼できません。
Hibernation WebSocket API は、このライフサイクルの問題を 2 つの機能で解決します。ctx.acceptWebSocket(server) は、オブジェクトをメモリ上に固定せずに接続を登録します。serializeAttachment() は、その接続に小さな structured clone 値を保存し、再構築後は deserializeAttachment() で復元します。ctx.getWebSockets() を使うと、新しいコンストラクターから、接続されたままのソケットを列挙できます。
この実験では、検証済みのクライアント ID、表示名、ルーム名をすべてのソケットにアタッチする、プレゼンス対応のルームサービスを構築します。制御された再構築テストでは、既存の fake socket を囲む新しいクラスインスタンスを作成し、アタッチメントからセッションマップを復元できることを確認します。さらに、実際のローカルおよびデプロイ済み WebSocket を使い、ブラウザクライアントを 1 つ切断して再接続し、ルームの動作が正しく保たれることを確認します。実際の本番環境でハイバネーションが発生するタイミングは Cloudflare が決めるため、この実験や採点では、必要なタイミングでエビクションを強制できるとは想定していません。
このコースを直接始める前に、LabEx を Cloudflare アカウントに接続する を完了してください。 新しい VM では、それぞれ Wrangler の認証が必要です。O01〜O05 の内容を通じて、名前付き Durable Object、SQLite を使用した状態管理、ルーム単位の WebSocket ブロードキャストをすでに理解していることを前提とします。
セットアップでは、Node.js 22.22.0、プロジェクトローカルの Wrangler 4.132.0、固定バージョンの WebSocket クライアントを /home/labex/project/connection-context にインストールします。ブラウザー用およびテスト用の fixture も用意されますが、Cloudflare の認証、Durable Object の実装、ソケットの受け付け、Worker のデプロイは行いません。
VM を認証し、プレゼンス用の名前空間を宣言する
このステップでは、新しい VM を認証し、専用の学習用アカウントを選択して、プレゼンス用ルームを管理する SQLite-backed Durable Object クラスを 1 つ宣言します。
cd /home/labex/project/connection-context
npx wrangler --version
npx wrangler login --device --browser=false
Wrangler のバージョンとして 4.132.0 が表示されることを確認します。表示された Cloudflare URL をブラウザーで開き、短いコードを入力して、使用する学習用アカウントを確認し、認証を許可します。ブラウザーから Wrangler にアクセス権が付与されます。パスワードが VM に送信されることはありません。
安全な ID 情報だけを読み取り、使い捨て用の一意な Worker 名を作成します。
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o06-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"durable_objects": { "bindings": [
{ "name": "PRESENCE", "class_name": "PresenceRoom" }
] },
"exports": {
"PresenceRoom": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
PRESENCE は、Worker からルームオブジェクトへアクセスするためのルートです。ルーム名を固定すると、あるルームの接続と履歴を別のルームから分離できます。クラスの export によって各ルームに専用の SQLite ストレージが割り当てられます。デプロイするまでは、クラウド上のリソースは作成されません。
ハイバネーションに対応した接続コンテキストを実装する
このステップでは、安全な接続メタデータをライブソケットオブジェクトから分離し、Cloudflare が新しいオブジェクトインスタンスを作成するたびに、Hibernation WebSocket API を使ってそのメタデータを復元します。
アタッチメントは、1 つの WebSocket と一緒に保存される小さな structured clone 値です。接続が正常な状態で維持されている間だけハイバネーションをまたいで保持されます。永続的なルーム履歴は引き続き SQLite に保存します。検証と再構築用のヘルパーを作成します。
cat > src/context.js <<'JS'
const TOKEN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
export function connectionContext(url) {
const room = url.pathname.match(/^\/rooms\/([^/]+)\/connect$/)?.[1] ?? "";
const clientId = url.searchParams.get("clientId") ?? "";
const displayName = (url.searchParams.get("name") ?? "").trim();
if (!TOKEN.test(room) || !TOKEN.test(clientId)) return null;
if (displayName.length < 1 || displayName.length > 32) return null;
return { room, clientId, displayName };
}
export function validAttachment(value) {
return Boolean(value && typeof value === "object" && TOKEN.test(value.room) &&
TOKEN.test(value.clientId) && typeof value.displayName === "string" &&
value.displayName.length >= 1 && value.displayName.length <= 32);
}
export function restoreSessions(sockets) {
const sessions = new Map();
for (const socket of sockets) {
const attachment = socket.deserializeAttachment();
if (validAttachment(attachment)) sessions.set(socket, attachment);
}
return sessions;
}
JS
Durable Object と、外部から受け付ける Worker を作成します。
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { connectionContext, restoreSessions } from "./context.js";
const json = (body, status = 200) => Response.json(body, { status });
export class PresenceRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sessions = restoreSessions(ctx.getWebSockets());
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS announcements (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
display_name TEXT NOT NULL,
text TEXT NOT NULL
)
`);
});
}
async fetch(request) {
const context = connectionContext(new URL(request.url));
if (!context) return json({ error: "invalid_connection_context" }, 400);
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server, [`room:${context.room}`]);
server.serializeAttachment(context);
this.sessions.set(server, context);
server.send(JSON.stringify({ type: "ready", context, connected: this.sessions.size }));
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(socket, raw) {
const context = socket.deserializeAttachment();
if (!context || !this.sessions.has(socket)) {
socket.send(JSON.stringify({ type: "error", code: "missing_context" }));
return;
}
let message;
try { message = JSON.parse(raw); } catch { message = null; }
const text = typeof message?.text === "string" ? message.text.trim() : "";
if (message?.type !== "announce" || text.length < 1 || text.length > 80 || Object.keys(message).length !== 2) {
socket.send(JSON.stringify({ type: "error", code: "invalid_message" }));
return;
}
const row = this.ctx.storage.sql.exec(`
INSERT INTO announcements (client_id, display_name, text)
VALUES (?, ?, ?) RETURNING sequence
`, context.clientId, context.displayName, text).one();
const update = JSON.stringify({ type: "announcement", sequence: row.sequence,
clientId: context.clientId, displayName: context.displayName, text });
for (const peer of this.ctx.getWebSockets(`room:${context.room}`)) peer.send(update);
console.log(JSON.stringify({ event: "presence_announcement", sequence: row.sequence,
clientId: context.clientId, connected: this.ctx.getWebSockets().length }));
}
webSocketClose(socket) {
this.sessions.delete(socket);
}
async getState() {
const announcements = this.ctx.storage.sql.exec(`
SELECT sequence, client_id AS clientId, display_name AS displayName, text
FROM announcements ORDER BY sequence
`).toArray();
return { messageCount: announcements.length, announcements };
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
if (!match) return json({ error: "not_found" }, 404);
const room = match[1];
if (match[2] === "connect") return env.PRESENCE.getByName(room).fetch(request);
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
return json({ room, ...await env.PRESENCE.getByName(room).getState() });
}
};
JS
ctx.acceptWebSocket() は server.accept() とイベントリスナーの代わりに使用します。これ以降、メッセージはクラスレベルの webSocketMessage() ハンドラーに届きます。コンストラクターは、ランタイムが所有するソケットとそのアタッチメントから sessions を再構築します。以前の JavaScript Map が残っていることは前提にしません。
強制的にエビクションしたと偽らず、コンテキストの再構築を検証する
このステップでは、再構築の境界を直接テストします。アイドル状態の本番オブジェクトをいつハイバネーションするかは Cloudflare が決めるため、決定的な実験で強制エビクションを待ったり、発生したと主張したりしてはいけません。代わりに、以前のインスタンスが書き込んだアタッチメントを持つ、ランタイム所有の fake socket を新しい PresenceRoom インスタンスに渡します。
cat > test/context.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { connectionContext, restoreSessions, validAttachment } from "../src/context.js";
import { PresenceRoom } from "../src/index.js";
const attachment = (room, clientId, displayName) => ({ room, clientId, displayName });
const socket = value => ({ deserializeAttachment: () => value });
test("connection input becomes a bounded attachment", () => {
const url = new URL("https://example.test/rooms/planning/connect?clientId=alice-1&name=Alice");
assert.deepEqual(connectionContext(url), attachment("planning", "alice-1", "Alice"));
assert.equal(connectionContext(new URL("https://example.test/rooms/Bad!/connect?clientId=a&name=A")), null);
});
test("attachment validation rejects incomplete context", () => {
assert.equal(validAttachment(attachment("planning", "alice-1", "Alice")), true);
assert.equal(validAttachment({ room: "planning", clientId: "alice-1" }), false);
});
test("controlled reconstruction restores only valid socket context", () => {
const alice = socket(attachment("planning", "alice-1", "Alice"));
const bob = socket(attachment("planning", "bob-1", "Bob"));
const broken = socket(null);
const restored = restoreSessions([alice, bob, broken]);
assert.equal(restored.size, 2);
assert.equal(restored.get(alice).displayName, "Alice");
assert.equal(restored.get(bob).clientId, "bob-1");
});
test("a new Durable Object constructor rebuilds its session map", () => {
const sockets = [socket(attachment("planning", "alice-1", "Alice")), socket(attachment("planning", "bob-1", "Bob"))];
const ctx = {
getWebSockets: () => sockets,
blockConcurrencyWhile: fn => fn(),
storage: { sql: { exec: () => ({}) } }
};
const room = new PresenceRoom(ctx, {});
assert.equal(room.sessions.size, 2);
assert.deepEqual([...room.sessions.values()].map(value => value.displayName), ["Alice", "Bob"]);
});
JS
npm test
4 つのテストがすべて成功することを確認します。これらのテストによって、アタッチメントからコンテキストを再構築できることがわかります。その後のライブチェックでは実際のソケット動作を確認しますが、どちらも、特定の本番オブジェクトが要求どおりにエビクションされた証拠とはみなしません。
クライアントを再接続し、ルームの動作を維持する
このステップでは、実際のローカルソケットを使用します。再接続すると新しいソケットが作成されるため、新しいアタッチメントも作成されます。一方、永続的なアナウンスは SQLite に残ります。
cat > tools/reconnect.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const room = `${prefix}-planning`, other = `${prefix}-support`;
const open = (roomName, id, name) => new Promise((resolve, reject) => {
const ws = new WebSocket(`${wsBase}/rooms/${roomName}/connect?clientId=${id}&name=${encodeURIComponent(name)}`);
const inbox = [];
ws.on("message", raw => { const value = JSON.parse(raw); inbox.push(value); if (value.type === "ready") resolve({ ws, inbox, ready: value }); });
ws.on("error", reject);
});
const waitFor = (client, predicate) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("message timeout")), 5000);
const check = value => { if (predicate(value)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
const listener = raw => check(JSON.parse(raw)); client.ws.on("message", listener); client.inbox.forEach(check);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const alice = await open(room, `${prefix}-alice`, "Alice");
const bob = await open(room, `${prefix}-bob`, "Bob");
const carol = await open(other, `${prefix}-carol`, "Carol");
alice.ws.send(JSON.stringify({ type: "announce", text: "First update" }));
await Promise.all([waitFor(alice, x => x.sequence === 1), waitFor(bob, x => x.sequence === 1)]);
await close(alice);
const reconnected = await open(room, `${prefix}-alice`, "Alice");
reconnected.ws.send(JSON.stringify({ type: "announce", text: "Back online" }));
const [again, peer] = await Promise.all([waitFor(reconnected, x => x.sequence === 2), waitFor(bob, x => x.sequence === 2)]);
await new Promise(resolve => setTimeout(resolve, 300));
const state = await fetch(`${base}/rooms/${room}/state`).then(r => r.json());
const otherState = await fetch(`${base}/rooms/${other}/state`).then(r => r.json());
console.log(JSON.stringify({ restoredName: again.displayName, peerName: peer.displayName,
otherAnnouncements: carol.inbox.filter(x => x.type === "announcement").length, state, otherState }, null, 2));
await Promise.all([reconnected, bob, carol].map(close));
JS
rm -f .labex/local.json .labex/dev.log .labex/dev.pid
mkdir -p .labex/local-state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
LOCAL_READY="$(curl --silent http://127.0.0.1:8787/rooms/probe/state || true)"
test "$(jq -r '.messageCount // -1' <<<"$LOCAL_READY" 2>/dev/null)" = 0 && break
sleep 1
done
test "$(jq -r .messageCount <<<"$LOCAL_READY")" = 0
sleep 2
node tools/reconnect.mjs http://127.0.0.1:8787 local | tee .labex/local.json
Alice は新しいソケットで再接続しますが、2 回目のメッセージにも displayName: Alice が含まれます。Bob はそのメッセージを受け取り、Carol は別のルームにいるため影響を受けません。ルームに 2 件の永続的なアナウンスが残ることから、ソケットの存続期間とルーム履歴の存続期間が異なることがわかります。
デプロイし、再接続の契約を繰り返す
このステップでは、現在のローカルジョブを停止し、デプロイして、実際に状態を保持するルートの準備を待ち、一意なクラウドルームでライブクライアントの契約を繰り返します。
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
rm -f .labex/cloud.json .labex/deploy.log .labex/app-url
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
for attempt in $(seq 1 30); do READY="$(curl --silent "$APP_URL/rooms/cloud-probe/state" || true)"; test "$(jq -r '.messageCount // -1' <<<"$READY" 2>/dev/null)" = 0 && break; sleep 2; done
test "$(jq -r .messageCount <<<"$READY")" = 0
sleep 5
node tools/reconnect.mjs "$APP_URL" cloud | tee .labex/cloud.json
Cloudflare 上でも同じ結果になれば、接続を受け付けた後、および Alice が再接続した後に、デプロイ済みサービスがシリアライズされたアタッチメントを使用していることを確認できます。ただし、この限られた実行中に、プラットフォームが実際にハイバネーションしたとは主張しません。
ハイバネーションに対応したデプロイを確認する
このステップでは、ランタイムの証拠を Cloudflare Dashboard と、変更していないコードの再デプロイに関連付けます。Workers & Pages を開き、.labex/run-name に記録された正確な名前を選択して、Bindings を開きます。PRESENCE が PresenceRoom を指していることを確認します。

Durable Objects を開き、<your-worker>_PresenceRoom を選択して、Storage: SQL と表示されることを確認します。このページで確認できるのはクラスの名前空間です。アタッチメントの値は表示されません。

Logs を開き、成功した presence_announcement の行を確認します。この行には合成クライアント ID とシーケンスが含まれますが、アナウンスの本文は含まれません。Dashboard にトラフィックが表示されるまで、レスポンス後しばらくかかる場合があります。そのため、ライブクライアントとバックエンドのチェックを正式な判定材料とします。

コードを変更せずに再デプロイし、同じクラウドルームを読み取ります。
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/cloud-planning/state" | jq
curl --silent --fail "$APP_URL/rooms/cloud-support/state" | jq
planning ルームには 2 件のアナウンスが残り、support ルームは空のままであることを確認します。再デプロイによって、Worker の新しいバージョンに移行しても永続的な履歴が残ることがわかります。アタッチメントの再構築については、制御されたコンストラクターテストで別に確認しています。
プレゼンス名前空間を削除する
このステップでは、VM の認証を維持したまま、この実験で生成した Worker と名前空間だけを削除します。
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o06-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
JS
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"exports": { "PresenceRoom": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc
確認プロンプトに正確な $RUN が表示されることを確認し、y を入力します。Successfully deleted と表示されれば成功です。下記のチェックが終わるまで、VM の認証は解除しないでください。
npx wrangler whoami --json | jq '{loggedIn, authType}'
JSON に "loggedIn": true が含まれている必要があります。認証エラーやネットワークエラーは、削除が完了した証拠ではありません。
この VM の Wrangler 認証を解除する
このステップでは、削除が独立して確認された後、この VM に付与された OAuth 認証だけを削除します。
npx wrangler logout
npx wrangler whoami --json
最後の JSON に "loggedIn": false が含まれている必要があります。学習用アカウントはブラウザーでログインしたままです。
まとめ
通常の受け付け済みソケットを Hibernation WebSocket API に置き換え、クライアントコンテキストを制限付きのシリアライズされたアタッチメントに保存し、ランタイムが所有するソケットからインメモリのセッションマップを再構築しました。制御された新規インスタンステストでは、本番エビクションを強制できると偽ることなく、再構築を確認しました。その後、実際のローカルおよびクラウドのクライアントを切断・再接続し、SQLite に永続的なアナウンスを保持したまま、ルームの動作が維持されることを確認しました。最後に、デプロイを確認し、正確な使い捨てリソースを削除してからログアウトしました。



