ルーム間の状態漏洩を診断する

CloudflareBeginner
オンラインで実践に進む

はじめに

Durable Object の名前は、アプリケーションのデータモデルの一部です。同じ名前を使用する呼び出しは、同じ論理オブジェクトとその SQLite データベースに到達します。一方、異なる名前を使用すると、別の調整単位が選択されます。そのため、Durable Object のクラスやストレージ処理が正しくても、ルーティングの回帰によって、あるルームの状態が別のルームの URL から見えてしまうことがあります。

この実験では、正常な planningsupport の履歴を持つ小さなルームジャーナルをデプロイします。その後、すべてのルームを planning オブジェクトへ送る不具合のあるリリースを再現し、ルート診断機能を使って不一致を見つけます。名前のマッピングだけを修正して再デプロイし、元の 2 つの履歴が保持されていることを確認します。最後に、付属の WebSocket プローブで同時更新、切断、再接続を行い、新しいルームが引き続き分離されていることを確認します。

このコースに直接アクセスした場合は、先に LabEx を Cloudflare アカウントに接続する を完了してください。この実験で使用する LabEx VM のターミナル、Wrangler のデバイス認証、アカウント確認、明示的なアカウント ID の設定について学べます。この新しい VM では、独自に認証する必要があります。

VM を認証し、ルームの名前空間を宣言する

このステップでは、新しい VM を認証し、専用の学習用アカウントを確認して、SQLite を使用する Durable Object の名前空間を 1 つ宣言します。

cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler のバージョンとして 4.132.0 が表示されることを確認します。表示された Cloudflare URL をブラウザーで開き、短いコードを入力し、対象の学習用アカウントを確認して認証します。デバイス認証を使うと、パスワードをターミナルへ送信せずに、この 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-o07-$(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": "ROOMS", "class_name": "RoomJournal" }
  ] },
  "exports": {
    "RoomJournal": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS は名前空間バインディングです。これを使って、多数の RoomJournal オブジェクトにアクセスできます。getByName() に渡すアプリケーション側の名前によって、どのオブジェクトの SQLite データベースとライブ接続に呼び出しを届けるかが決まります。

明示的なオブジェクト名でジャーナルを構築する

このステップでは、状態を保持するクラスを実装し、ID の選択を 1 つの小さなルーティング関数に集約します。診断時にはこの分離が重要です。呼び出し元が誤ったオブジェクトを選択していても、ストレージの動作自体は正常なままにできるからです。

最初は正しいマッパーを作成します。検証済みのルーム名は、安定した決定的なオブジェクト名として使用できます。

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
cat > test/router.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { objectNameFor } from "../src/router.js";

test("each validated room keeps its own object identity", () => {
  assert.equal(objectNameFor("planning"), "planning");
  assert.equal(objectNameFor("support"), "support");
  assert.notEqual(objectNameFor("planning"), objectNameFor("support"));
});
JS

Worker と Durable Object を作成します。ctx.id.name には、このオブジェクトへ到達するために使用された安定した名前が表示されます。ページのログには、要求されたルーム名と選択された合成ルーム名だけを記録し、ジャーナルの本文は意図的に記録しません。

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { objectNameFor } from "./router.js";

const ROOM = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const EVENT = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/;
const json = (value, status = 200) => Response.json(value, { status });
const safeRoom = value => ROOM.test(value || "") ? value : null;

export class RoomJournal extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS events (
        sequence INTEGER PRIMARY KEY AUTOINCREMENT,
        event_id TEXT NOT NULL UNIQUE,
        text TEXT NOT NULL
      )`);
    });
  }

  state() {
    return {
      objectName: this.ctx.id.name,
      events: this.ctx.storage.sql.exec(
        "SELECT sequence, event_id AS eventId, text FROM events ORDER BY sequence"
      ).toArray()
    };
  }

  append(eventId, text) {
    if (!EVENT.test(eventId || "") || typeof text !== "string" || text.length < 1 || text.length > 80) {
      throw new Error("invalid_event");
    }
    this.ctx.storage.sql.exec("INSERT OR IGNORE INTO events (event_id, text) VALUES (?, ?)", eventId, text);
    return this.state();
  }

  async fetch(request) {
    if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return json({ error: "upgrade_required" }, 426);
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);
    server.send(JSON.stringify({ type: "ready", ...this.state() }));
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(socket, raw) {
    try {
      const message = JSON.parse(raw);
      if (message.type !== "append") throw new Error("invalid_event");
      const state = this.append(message.eventId, message.text);
      const frame = JSON.stringify({ type: "event", ...state });
      for (const peer of this.ctx.getWebSockets()) peer.send(frame);
    } catch {
      socket.send(JSON.stringify({ type: "error", error: "invalid_event" }));
    }
  }
}

async function roomState(env, room) {
  return env.ROOMS.getByName(objectNameFor(room)).state();
}

function inspectPage(planning, support) {
  const rows = [planning, support].map(([requested, state]) => `<tr><td>${requested}</td><td>${state.objectName}</td><td>${state.events.map(x => x.eventId).join(", ")}</td></tr>`).join("");
  return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Room routing inspector</title>
  <style>body{font:18px system-ui;max-width:900px;margin:48px auto;color:#17212b}h1{color:#5b8c00}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccd5df;padding:14px;text-align:left}th{background:#eef7dc}.ok{padding:12px;background:#eef7dc;border-left:5px solid #78aa00}</style>
  <h1>Room routing inspector</h1><p class="ok">Each requested room resolves to the matching Durable Object name.</p>
  <table><thead><tr><th>Requested room</th><th>Object name</th><th>Preserved event IDs</th></tr></thead><tbody>${rows}</tbody></table></html>`;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/inspect") {
      const states = await Promise.all(["planning", "support"].map(async room => [room, await roomState(env, room)]));
      return new Response(inspectPage(...states), { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const debug = url.pathname.match(/^\/debug\/route\/([^/]+)$/);
    if (debug) {
      const room = safeRoom(debug[1]);
      if (!room) return json({ error: "invalid_room" }, 400);
      return json({ requestedRoom: room, objectName: objectNameFor(room) });
    }
    const match = url.pathname.match(/^\/rooms\/([^/]+)\/(events|connect)$/);
    if (!match) return json({ error: "not_found" }, 404);
    const room = safeRoom(match[1]);
    if (!room) return json({ error: "invalid_room" }, 400);
    const objectName = objectNameFor(room);
    console.log(JSON.stringify({ event: "routing_decision", requestedRoom: room, objectName, operation: match[2] }));
    const stub = env.ROOMS.getByName(objectName);
    if (match[2] === "connect") return stub.fetch(request);
    if (request.method === "GET") return json(await stub.state());
    if (request.method === "POST") {
      try {
        const body = await request.json();
        return json(await stub.append(body.eventId, body.text), 201);
      } catch (error) {
        return json({ error: error.message === "invalid_event" ? "invalid_event" : "invalid_json" }, 400);
      }
    }
    return json({ error: "method_not_allowed" }, 405);
  }
};
JS
npm test

テストでは、ID の境界を直接確認します。Durable Object は、ランタイムが管理する名前を診断に使用し、ジャーナルの行を SQLite に保存してから返します。

正常な 2 つのルーム履歴をデプロイする

このステップでは、まず正常なリリースをデプロイし、各ルームに識別しやすいイベントを 1 件ずつ作成します。これらの行が履歴保持の証拠です。後の修正が成功したといえるのは、両方の行が元のオブジェクトから返される場合だけです。

rm -f .labex/deploy.log .labex/app-url .labex/baseline.json
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/debug/route/planning" || true)"; test "$(jq -r '.objectName // empty' <<<"$READY" 2>/dev/null)" = planning && break; sleep 2; done
test "$(jq -r .objectName <<<"$READY")" = planning
sleep 5
curl --silent --fail -X POST "$APP_URL/rooms/planning/events" -H 'content-type: application/json' --data '{"eventId":"plan-start","text":"Planning kickoff"}' >/dev/null
curl --silent --fail -X POST "$APP_URL/rooms/support/events" -H 'content-type: application/json' --data '{"eventId":"support-start","text":"Support handoff"}' >/dev/null
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/baseline.json

2 つの objectName フィールドは異なっていなければなりません。planning には plan-start だけが含まれ、support には support-start だけが含まれます。Worker 名は一時的なものですが、これらのオブジェクト履歴はリリースの回帰と修正を経ても保持される必要があります。

不具合のあるリリースを再現して追跡する

このステップでは、実験に用意されているリリース回帰を再現します。不具合のある関数は引数を無視し、常に planning を返します。ID テストは失敗するはずです。この制御された失敗を記録すると、デプロイ前に不具合を可視化できます。

cp fixtures/router-bug.js src/router.js
rm -f .labex/bug-test.log .labex/bug.json
set -o pipefail
if npm test 2>&1 | tee .labex/bug-test.log; then TEST_STATUS=0; else TEST_STATUS=$?; fi
set +o pipefail
printf '%s\n' "$TEST_STATUS" > .labex/bug-test-status
test "$TEST_STATUS" -ne 0
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do
  BUG_ROUTE="$(curl --silent "$APP_URL/debug/route/support" || true)"
  BUG_READ="$(curl --silent "$APP_URL/rooms/support/events" || true)"
  test "$(jq -r '.objectName // empty' <<<"$BUG_ROUTE" 2>/dev/null)" = planning && test "$(jq -r '.objectName // empty' <<<"$BUG_READ" 2>/dev/null)" = planning && break
  sleep 2
done
test "$(jq -r .objectName <<<"$BUG_ROUTE")" = planning
test "$(jq -r .objectName <<<"$BUG_READ")" = planning
jq -n \
  --argjson planningRoute "$(curl --silent --fail "$APP_URL/debug/route/planning")" \
  --argjson supportRoute "$BUG_ROUTE" \
  --argjson supportRead "$BUG_READ" \
  '{planningRoute:$planningRoute,supportRoute:$supportRoute,supportRead:$supportRead}' | tee .labex/bug.json

診断では、要求されたルーム選択されたオブジェクト名を分けて表示します。support へのリクエストが、現在は objectName: planning を返し、plan-start を公開していることを確認します。元の support オブジェクトを削除したり上書きしたりしたわけではありません。不具合のあるリリースによって、単にそのオブジェクトが参照されなくなっただけです。

マッパーを修正し、再接続時の分離を確認する

このステップでは、ID のマッピングだけを修正します。元の名前付きオブジェクトはまだ存在するため、ストレージのリセットやデータの再生は必要ありません。

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
npm test
cat > tools/isolation.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const rooms = [`${prefix}-planning`, `${prefix}-support`];
const open = room => new Promise((resolve, reject) => {
  const ws = new WebSocket(`${wsBase}/rooms/${room}/connect`);
  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, eventId) => new Promise((resolve, reject) => {
  const timer = setTimeout(() => reject(new Error("event timeout")), 5000);
  const inspect = value => { if (value.type === "event" && value.events.some(x => x.eventId === eventId)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
  const listener = raw => inspect(JSON.parse(raw));
  client.ws.on("message", listener); client.inbox.forEach(inspect);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const [planning, support] = await Promise.all(rooms.map(open));
planning.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-plan`, text:"Plan update" }));
support.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-support`, text:"Support update" }));
await Promise.all([waitFor(planning, `${prefix}-plan`), waitFor(support, `${prefix}-support`)]);
await Promise.all([close(planning), close(support)]);
const [planningAgain, supportAgain] = await Promise.all(rooms.map(open));
const result = { planning:planningAgain.ready, support:supportAgain.ready };
console.log(JSON.stringify(result, null, 2));
await Promise.all([close(planningAgain), close(supportAgain)]);
JS
rm -f .labex/repaired.json .labex/reconnect.json
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do REPAIRED_READY="$(curl --silent "$APP_URL/debug/route/support" || true)"; test "$(jq -r '.objectName // empty' <<<"$REPAIRED_READY" 2>/dev/null)" = support && break; sleep 2; done
test "$(jq -r .objectName <<<"$REPAIRED_READY")" = support
sleep 5
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/repaired.json
node tools/isolation.mjs "$APP_URL" cloud | tee .labex/reconnect.json

修正後の読み取りで、両方の元のイベント ID が元のオブジェクトから見つかります。続いて WebSocket プローブが 2 つの新しいオブジェクトを同時に更新し、両方の接続を閉じて再接続します。それぞれの ready フレームに自身のイベントだけが含まれていれば、データベースをリセットせず、ルーティングを修正したことで漏洩が解消されたことを確認できます。

修正済みサービスを確認して再デプロイする

このステップでは、実行時の証拠を、初心者にも分かりやすいブラウザーと Dashboard の表示に結び付けます。.labex/app-url に保存されている URL の末尾に /inspect を付けて開きます。緑色のメッセージと表に、planning → planningsupport → support、および保持された 2 つのイベント ID が表示されるはずです。

各ルームを対応するオブジェクトと保持された履歴へマッピングする修正済みインスペクター

Workers & Pages を開き、.labex/run-name に保存されている正確な Worker 名を選択して、Bindings を開きます。ROOMSRoomJournal に接続されていることを確認します。

ROOMS バインディングが RoomJournal Durable Object を指している

Durable Objects を開き、<your-worker>_RoomJournal を選択して、Storage: SQL と表示されることを確認します。1 つの名前空間には多数の名前付きオブジェクトを含めることができます。名前によって、その中の分離されたオブジェクトが選択されます。

RoomJournal 名前空間が SQL ストレージを使用している

Workers & Pages で Worker に戻り、Observability を開いて、保存されたイベントから routing_decision を検索します。ルーム操作に対応するイベントを 1 つ展開します。この判断は、ステートレスな Worker が Durable Object を呼び出す前に記録するため、名前空間のログではなく Worker のログに表示されます。安全なフィールドには、ジャーナル本文を含めず、要求された合成ルーム名と選択された合成ルーム名が同じ値で表示されるはずです。

修正された ID マッピングを示す構造化ルーティング判断

最後に、コードを変更せずに再デプロイし、元のルームをもう一度読み取ります。

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/planning/events" | jq
curl --silent --fail "$APP_URL/rooms/support/events" | jq

元の 2 つの履歴が引き続き存在することを確認します。同じ検証済みの名前が同じ名前空間エントリを選択するため、変更していないデプロイによって新しいオブジェクト ID が作成されることはありません。

Room Journal 名前空間を削除する

このステップでは、VM の認証が有効なうちに、この実験用の Worker と生成された名前空間だけを削除します。宣言的な tombstone によってクラスの名前空間を削除対象としてから、Wrangler がスクリプトを削除します。

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o07-*) ;; *) 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": { "RoomJournal": { "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 が含まれていなければなりません。学習用アカウントはブラウザーでは引き続きサインインした状態です。

まとめ

正常なストレージをリセットするのではなく、ID ルーティングの境界で Durable Object の不具合を診断しました。制御された不具合リリースによって、supportplanning オブジェクトを選択していることを確認し、ルート診断によって要求された名前と選択された名前を可視化しました。直接的な名前マッピングを復元すると、両方の元の SQLite 履歴が直ちに復元されました。さらに、WebSocket の同時更新、切断、再接続、変更なしの再デプロイによって、新しいルームと既存のルームが引き続き分離されていることを確認しました。最後に、修正済みデプロイを確認し、一時的に作成した正確なリソースを削除してからログアウトしました。