Skip to content

3章_chat追加

チャット機能を追加する。

1. DO作成

Durable Objects とは

Durable Objects(DO)は、Cloudflare Workers の中で「同じ名前の部屋に同じ処理が必ず集まる」ようにしてくれる仕組みです。
チャットの例だと、同じ room の人は同じ DO インスタンスに接続し、そこで接続管理や配信を行います。

Worker と DO の役割分担

構造は次のイメージです。

Browser

Worker(index.ts)  ← 受付・振り分け

DurableObject(chatRoom.ts)  ← WebSocket終端・接続保持・配信

index.ts は HTTP を受けて room を見て、対応する DO に処理を渡す「振り分け係」です。
chatRoom.ts が実際の WebSocket 終端で、接続の保持と配信を担当します。

TypeScript で詰まりやすい点

DurableObjectNamespace が見つからない場合は型定義の読み込み不足です。
apps/chat/tsconfig.jsontypes: ["@cloudflare/workers-types"] があるか確認してください。

より確実にするなら apps/chat/src/index.ts の先頭に以下を追加します。

ts
/// <reference types="@cloudflare/workers-types" />

export / export default の使い分け

export default は「このファイルのメイン」を1つだけ指定します。
export は他のファイルから使う部品を公開します(複数OK)。

index.ts では export default { fetch() {} } が Worker 本体です。
chatRoom.tsexport class ChatRoom {} は DO のクラスを公開しています。

1-1. ディレクトリ作成

bash
mkdir -p apps/chat/src

1-2. apps/chat/package.json 作成

json
{
  "name": "@cf-nextjs-sample/chat",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "pnpm exec wrangler dev",
    "deploy": "pnpm exec wrangler deploy"
  }
}

1-3. 依存導入(ルートから)

bash
pnpm --filter ./apps/chat add -D @cloudflare/workers-types

1-4. apps/chat/tsconfig.json 作成

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "strict": true,
    "types": ["@cloudflare/workers-types"]
  },
  "include": ["src"]
}

1-5. apps/chat/wrangler.jsonc 作成

jsonc
{
  "name": "cf-nextjs-sample-chat-noimasaki",
  "main": "src/index.ts",
  "compatibility_date": "2026-02-11",

  // ローカルで ws://127.0.0.1:8788/ws が開くようにする(任意)
  // "dev": { "port": 8788 },

  // Durable Objects をこの Worker にバインド
  "durable_objects": {
    "bindings": [
      { "name": "CHAT_ROOM", "class_name": "ChatRoom" }
    ]
  },

  // DO を使う場合、初回は migrations が必須
  "migrations": [
    { "tag": "v1", "new_classes": ["ChatRoom"] }
  ]

  // 本番でドメインを割り当てるなら routes をここに追加
  // 例:
  // "routes": [
  //   { "pattern": "chat.noimk.com/ws", "zone_name": "noimk.com" }
  // ]
}

1-6. apps/chat/src/index.ts 作成

ts
import { ChatRoom } from "./chatRoom";

// Worker 本体は「/ws を受けて、room名に応じた DO に引き渡す」だけ。
// ここは “WebSocket終端” ではない。終端は DO が担当。

export interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    try {
      const url = new URL(req.url);

      // 生存確認用
      if (url.pathname === "/health") {
        return new Response("ok", { status: 200 });
      }

      // WS入口
      if (url.pathname !== "/ws") {
        return new Response("Not Found", { status: 404 });
      }

    // クエリの room で DO インスタンスを分ける
      const room = url.searchParams.get("room") ?? "default";
      
    // room名 → DOインスタンスID(同じroom名は同じDOに行く)
      const id = env.CHAT_ROOM.idFromName(room);
      const stub = env.CHAT_ROOM.get(id);

      // Upgrade request を DO に丸投げ
      return await stub.fetch(req);
    } catch (e) {
      console.error("index.fetch error:", e);
      return new Response("Internal Error", { status: 500 });
    }
  },
};

export { ChatRoom };

1-7. apps/chat/src/chatRoom.ts 作成

ts
// Durable Object が WebSocket の終端になる。
// ここで接続を保持し、メッセージを配信する。
// (認証なし・永続化なし・最小ブロードキャスト)

export class ChatRoom implements DurableObject {
  // 接続中クライアント
  private clients = new Set<WebSocket>();

  constructor(private state: DurableObjectState) {}

  async fetch(req: Request): Promise<Response> {
    try {
      // WebSocket Upgrade じゃないなら拒否
      // Upgrade は “接続確立時” のヘッダ(メッセージの話じゃない)
      if (req.headers.get("Upgrade") !== "websocket") {
        return new Response("Expected websocket", { status: 426 });
      }

      // クライアント側とサーバ側の WS をペアで作る
      const pair = new WebSocketPair();
      const client = pair[0];
      const server = pair[1];

    // DO 側(server)を accept して接続として扱う
      server.accept();
      this.clients.add(server);

      // デバッグログ(wrangler側に出る)
      console.log("connected clients:", this.clients.size);

      // 動作確認用:接続したらメッセージを返す
      server.send(JSON.stringify({ type: "system", text: "connected" }));

    // 受信したら全員に配信
      server.addEventListener("message", (evt) => {
        // 今回はテキストのみ扱う(バイナリは無視)
        const msg = typeof evt.data === "string" ? evt.data : "";
        if (!msg) return;

        console.log("recv:", msg);

        // ブロードキャスト:部屋にいる全員に同じ msg を送る
        for (const ws of this.clients) {
          try { ws.send(msg); } catch (e) { console.log("send failed:", e); }
        }
      });

    // 切断したらSetから外す
      server.addEventListener("close", () => {
        this.clients.delete(server);
        console.log("closed clients:", this.clients.size);
      });

      // 101 + webSocket を返すのが “Upgrade成功”
      return new Response(null, { status: 101, webSocket: client });
    } catch (e) {
      console.error("ChatRoom.fetch error:", e);
      return new Response("Internal Error", { status: 500 });
    }
  }
}

chatRoom.ts 補足

WebSocketPair() は 2本セットのソケットを作ります。
client はブラウザに返す側、server は DO 内で扱う側です。
server は「サーバマシン」を指す言葉ではなく、DO側の WebSocket オブジェクト名です。

Upgrade は接続確立時に「HTTP から WebSocket に切り替える」ためのヘッダです。
req.headers.get("Upgrade") は「これは WebSocket を開きたいリクエストか?」を判定しています。

ブロードキャストはシンプルで、clients に入っている全員に send() するだけです。
この最小実装では送信者にも返るので、必要なら「送信者には送らない」条件を追加できます。

切断時に clients から外すのは、死んだソケットに送らないためです。
メモリ使用量や「接続数の正しさ」に直結します。

1-8. ルート package.json 追記

ルート package.json に追記する

json
{
  "scripts": {
    "dev:chat": "pnpm --filter ./apps/chat exec wrangler dev --port 8789",
    "dev": "bash -lc 'pnpm run dev:chat & pnpm run dev:api & pnpm run dev:web & wait'"
  }
}

1-9. 動作確認

まず、chatを起動

bash
pnpm install
pnpm run dev:chat

別ターミナルを開いて、疎通確認

bash
pnpm add -D ws
bash
node -e '
const WebSocket = require("ws");
const ws = new WebSocket("ws://localhost:8789/ws?room=demo");
ws.on("open", () => {
  console.log("open");
  ws.send(JSON.stringify({ type: "msg", text: "hello" }));
});
ws.on("message", (m) => console.log("recv:", m.toString()));
'

2. チャット画面作成(apps/web)

作ったDOへwsしてテキストを送るための画面そ作成する。

2-1. ディレクトリ作成

apps/web配下に新しいディレクトリ作成。

bash
mkdir -p apps/web/src/app/chat

2-2. apps/web/src/app/chat/page.tsx 作成

ts
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

type ChatEvent =
  | { type: "system"; text: string }
  | { type: "msg"; text: string };

function safeParse(s: string): ChatEvent | null {
  try {
    const v = JSON.parse(s);
    if (v?.type === "system" && typeof v.text === "string") return v;
    if (v?.type === "msg" && typeof v.text === "string") return v;
    return null;
  } catch {
    return null;
  }
}

export default function ChatPage() {
  // 最小:roomは固定でもいいけど、後で動的にするので state にしておく
  const room = "demo";

  const wsUrl = useMemo(() => {
    // ローカルで apps/chat を 8789 で起動している前提
    return `ws://localhost:8789/ws?room=${encodeURIComponent(room)}`;
  }, [room]);

  const wsRef = useRef<WebSocket | null>(null);

  const [status, setStatus] = useState<
    "idle" | "connecting" | "open" | "closed" | "error"
  >("idle");
  const [input, setInput] = useState("");
  const [logs, setLogs] = useState<string[]>([]);

  useEffect(() => {
    setStatus("connecting");
    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onopen = () => {
      setStatus("open");
      setLogs((p) => [...p, `opened: ${wsUrl}`]);
    };

    ws.onmessage = (e) => {
      const parsed = safeParse(String(e.data));
      if (parsed) {
        setLogs((p) => [...p, `${parsed.type}: ${parsed.text}`]);
      } else {
        // 最小実装は "生テキスト" でも出す
        setLogs((p) => [...p, `raw: ${String(e.data)}`]);
      }
    };

    ws.onerror = () => {
      setStatus("error");
      setLogs((p) => [...p, "ws error"]);
    };

    ws.onclose = () => {
      setStatus("closed");
      setLogs((p) => [...p, "closed"]);
    };

    return () => {
      ws.close();
      wsRef.current = null;
    };
  }, [wsUrl]);

  const send = () => {
    const ws = wsRef.current;
    if (!ws || ws.readyState !== WebSocket.OPEN) return;

    const text = input.trim();
    if (!text) return;

    // apps/chat の DO は「受け取った文字列をそのまま全員にブロードキャスト」するので
    // JSONで送ると見やすい
    ws.send(JSON.stringify({ type: "msg", text }));
    setInput("");
  };

  return (
    <div style={{ padding: 16, maxWidth: 720 }}>
      <h1>Chat</h1>

      <div style={{ marginBottom: 12 }}>
        <div>room: {room}</div>
        <div>status: {status}</div>
        <div style={{ fontSize: 12, opacity: 0.8 }}>ws: {wsUrl}</div>
      </div>

      <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
        <input
          style={{ flex: 1, padding: 8 }}
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter") send();
          }}
          placeholder="message..."
        />
        <button onClick={send} disabled={status !== "open"}>
          send
        </button>
      </div>

      <pre
        style={{
          whiteSpace: "pre-wrap",
          background: "#111",
          color: "#ddd",
          padding: 12,
          borderRadius: 8,
        }}
      >
        {logs.join("\n")}
      </pre>
    </div>
  );
}

TIP

TypeScript初学者向け補足(どの関数がいつ呼ばれるか)

この page.tsxReactコンポーネント です。読み方は次の順です。

  1. 画面を開くと ChatPage() が実行され、初回の画面が描画される
  2. useEffect(() => { ... }, [])初回描画の直後に1回だけ 実行される
  3. connect()useEffect から呼ばれて WebSocket接続を開始 する
  4. 受信イベント (ws.onmessage) が来るたびに setLogs(...) が動き、画面が再描画される
  5. 送信ボタンを押すと handleSend() が実行される

ざっくり言うと、初回表示→接続→受信で再描画→ボタンで送信 という流れです。

TIP

TypeScript初学者向け補足(useMemo / useEffect / useState / useRef)

  • useState は「画面に反映される変数」を作るフックです。
    例: const [logs, setLogs] = useState<string[]>([])
  • useEffect は「画面が描画された後に動く処理」を書く場所です。
    例: 接続開始、タイマー、イベント登録など
  • useRef は「再描画しても値を保持する箱」です。
    例: wsRef.current に WebSocket を保存する
  • useMemo は「重い計算の結果を使い回す」ためのメモ化です。
    例: 文字列の整形や配列の加工などを無駄に繰り返さない

2-3. apps/web/src/app/page.tsx追記

チャット画面へのリンクを追記する。

html
      <h2>チャット</h2>
      <a href="/chat">Go to Chat</a>

2-4. ルートpakacge.json更新

chat機能を起動できるように更新

TIP

--inspector-port とは、Node.js のデバッガが使う 検査用ポート です。
wrangler dev は内部で Node を使うため、複数の wrangler dev を同時に起動すると
既定のポートが衝突してデバッガが使えなくなることがあります。

この章では api / web / chat を同時に起動するので、
それぞれ別の --inspector-port を指定 して衝突を避けています。
デバッグしない場合でも、ポート競合回避のために付けておくのが安全です。

json
"scripts": {
  "dev:api": "pnpm --filter ./apps/api exec wrangler dev --port 8788  --inspector-port 9230",
  "dev:web": "pnpm --filter ./apps/web run build:cf && pnpm --filter ./apps/web exec wrangler dev --port 8787 --inspector-port 9231",
  "dev:chat": "pnpm --filter ./apps/chat exec wrangler dev --port 8789 --inspector-port 9232",
  "dev": "bash -lc 'pnpm run dev:api & pnpm run dev:chat & pnpm run dev:web & wait'"
}

2-5. 動作確認

ルートで

bash
pnpm run dev

ブラウザでlocalhost:8787を二つ開いて、それぞれのテキスト送信部分から送信して同じ内容がそれぞれに表示されることを確認する。

3. 見た目改善&DO storageで最新50件のメッセージを永続化

よく見る形のチャット画面にする。

3-1. apps/chat/src/index.ts

ここをクリックしてコードを表示
ts
/// <reference types="@cloudflare/workers-types" />

/**
 * index.ts = Worker本体(受付係)
 *
 * - /ws            : WebSocket の入口 → room に応じた DO に渡す
 * - /debug/history : 履歴確認用HTTP → room に応じた DO に渡す
 *
 * ※ DO は room ごとにインスタンスが分かれるので
 *    「room=demo」の履歴は「demo用DO」に入る
 */

import { ChatRoom } from "./chatRoom";

export interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    try {
      const url = new URL(req.url);

      if (url.pathname === "/health") {
        return new Response("ok", { status: 200 });
      }

      // room を指定しないと default 扱い
      const room = url.searchParams.get("room") ?? "default";

      // room名 → DOインスタンス(同じroomは同じDOへ)
      const id = env.CHAT_ROOM.idFromName(room);
      const stub = env.CHAT_ROOM.get(id);

      // WebSocket入口
      if (url.pathname === "/ws") {
        return await stub.fetch(req);
      }

      // デバッグ:履歴確認
      // 例: http://localhost:8789/debug/history?room=demo
      if (url.pathname === "/debug/history") {
        /**
         * DO側で判別しやすいように、DOへ渡すURLのパスを変える
         * (同じ Request をそのまま渡すと pathname が /debug/history のままなので)
         */
        const forwardUrl = new URL(req.url);
        forwardUrl.pathname = "/_debug/history";

        const forwardReq = new Request(forwardUrl.toString(), {
          method: "GET",
          headers: req.headers,
        });

        return await stub.fetch(forwardReq);
      }

      return new Response("Not Found", { status: 404 });
    } catch (e) {
      console.error("index.fetch error:", e);
      return new Response("Internal Error", { status: 500 });
    }
  },
};

// wrangler.jsonc で class_name 参照されるので export
export { ChatRoom };

3-2. apps/chat/src/chatRoom.ts

ここをクリックしてコードを表示
ts
/// <reference types="@cloudflare/workers-types" />

/**
 * chatRoom.ts = Durable Object(DO)本体
 *
 * 追加したこと:
 * 1) 受信した msg を DO storage に保存(最新50件)
 * 2) 新規接続時に履歴(最新50件)をその接続へ送る
 * 3) GET /_debug/history で履歴をJSONで返す(ローカル確認用)
 */

type ClientToServerMsg = {
  type: "msg";
  senderId: string;
  text: string;
  clientTs?: number;
};

type StoredMsg = {
  senderId: string;
  text: string;
  ts: number; // サーバ側で付与
};

type ServerToClientMsg =
  | { type: "system"; text: string }
  | { type: "msg"; senderId: string; text: string; ts: number };

const HISTORY_KEY = "history";
const HISTORY_LIMIT = 50;

function isClientMsg(v: unknown): v is ClientToServerMsg {
  if (!v || typeof v !== "object") return false;
  const o = v as any;
  return (
    o.type === "msg" &&
    typeof o.senderId === "string" &&
    typeof o.text === "string" &&
    o.senderId.length > 0 &&
    o.text.length > 0
  );
}

export class ChatRoom implements DurableObject {
  private clients = new Set<WebSocket>();

  constructor(private state: DurableObjectState) {}

  /**
   * 履歴を読み出す(なければ空)
   */
  private async loadHistory(): Promise<StoredMsg[]> {
    const history = await this.state.storage.get<StoredMsg[]>(HISTORY_KEY);
    return Array.isArray(history) ? history : [];
  }

  /**
   * 履歴に追加して、最新50件に丸めて保存
   */
  private async appendHistory(msg: StoredMsg): Promise<void> {
    const history = await this.loadHistory();
    history.push(msg);

    // 最新50件だけ残す
    const trimmed = history.slice(-HISTORY_LIMIT);
    await this.state.storage.put(HISTORY_KEY, trimmed);
  }

  /**
   * 全クライアントへ配信
   */
  private broadcast(payload: ServerToClientMsg | string) {
    const text = typeof payload === "string" ? payload : JSON.stringify(payload);
    for (const ws of this.clients) {
      try {
        ws.send(text);
      } catch {
        // closeイベントで掃除される想定なので、ここでは無視
      }
    }
  }

  async fetch(req: Request): Promise<Response> {
    try {
      const url = new URL(req.url);

      /**
       * デバッグ用:履歴を返す
       * index.ts から /debug/history を /_debug/history に変えて渡している
       */
      if (url.pathname === "/_debug/history") {
        const history = await this.loadHistory();
        return new Response(JSON.stringify({ count: history.length, history }, null, 2), {
          status: 200,
          headers: { "content-type": "application/json; charset=utf-8" },
        });
      }

      // WebSocket Upgrade でないなら拒否
      if (req.headers.get("Upgrade") !== "websocket") {
        return new Response("Expected websocket", { status: 426 });
      }

      // WebSocketPair を作って、clientを返し、serverをDO側で扱う
      const pair = new WebSocketPair();
      const client = pair[0];
      const server = pair[1];

      server.accept();
      this.clients.add(server);
      console.log("connected clients:", this.clients.size);

      // まず接続したことを通知
      server.send(JSON.stringify({ type: "system", text: "connected" } satisfies ServerToClientMsg));

      /**
       * 接続時に履歴を流す(この接続にだけ送る)
       * - UI側は普通の msg として描画できる
       */
      const history = await this.loadHistory();
      for (const h of history) {
        server.send(
          JSON.stringify({ type: "msg", senderId: h.senderId, text: h.text, ts: h.ts } satisfies ServerToClientMsg)
        );
      }

      // 受信したら保存して配信
      server.addEventListener("message", (evt) => {
        const raw = typeof evt.data === "string" ? evt.data : "";
        if (!raw) return;

        console.log("recv:", raw);

        // JSON { type:"msg", senderId, text } を想定
        let parsed: unknown;
        try {
          parsed = JSON.parse(raw);
        } catch {
          // JSONじゃない → デバッグとしてそのまま配信(保存はしない)
          this.broadcast(raw);
          return;
        }

        if (!isClientMsg(parsed)) {
          // 形式が違う → デバッグとしてそのまま配信(保存はしない)
          this.broadcast(raw);
          return;
        }

        // サーバ側で時刻を確定
        const stored: StoredMsg = {
          senderId: parsed.senderId,
          text: parsed.text,
          ts: Date.now(),
        };

        /**
         * ここで await が使えない(イベントリスナーは sync)
         * なので waitUntil で “裏で保存” させる(失敗しても接続は続く)
         */
        this.state.waitUntil(this.appendHistory(stored));

        // クライアントへ配信する形も正規化する(ts付き)
        const outgoing: ServerToClientMsg = {
          type: "msg",
          senderId: stored.senderId,
          text: stored.text,
          ts: stored.ts,
        };

        this.broadcast(outgoing);
      });

      // 切断したらSetから外す
      server.addEventListener("close", () => {
        this.clients.delete(server);
        console.log("closed clients:", this.clients.size);
      });

      // 101 Switching Protocols で WebSocket 開始
      return new Response(null, { status: 101, webSocket: client });
    } catch (e) {
      console.error("ChatRoom.fetch error:", e);
      return new Response("Internal Error", { status: 500 });
    }
  }
}

3-3. apps/web/src/app/chat/page.tsx

ここをクリックしてコードを表示
ts
"use client";

/**
 * /chat 画面(BFF配下の Next.js ページ)
 *
 * 重要ポイント:
 * - window / localStorage は “ブラウザでだけ” 使える
 * - なので senderId の生成は useEffect(マウント後)で行う
 *   → next build の prerender 時に window が無くて落ちるのを防ぐ
 */

import { useEffect, useMemo, useRef, useState } from "react";

type IncomingMsg = {
  type: "msg";
  senderId: string;
  text: string;
  ts?: number;
};

type IncomingSystem = {
  type: "system";
  text: string;
};

type Incoming = IncomingMsg | IncomingSystem;

type UiMessage = {
  id: string;
  kind: "system" | "msg";
  text: string;
  senderId?: string;
  ts?: number;
};

function safeParseIncoming(s: string): Incoming | null {
  try {
    const v = JSON.parse(s);
    if (v?.type === "system" && typeof v.text === "string")
      return v as IncomingSystem;
    if (
      v?.type === "msg" &&
      typeof v.senderId === "string" &&
      typeof v.text === "string"
    ) {
      return v as IncomingMsg;
    }
    return null;
  } catch {
    return null;
  }
}

function getOrCreateSenderId(): string {
  // ここは “ブラウザでだけ呼ばれる前提” にする(useEffectから呼ぶ)
  const key = "chat_sender_id";
  const existing = window.localStorage.getItem(key);
  if (existing) return existing;

  const id = crypto.randomUUID();
  window.localStorage.setItem(key, id);
  return id;
}

export default function ChatPage() {
  const room = "demo";

  // ★ SSR / build 時点では senderId はまだ無い(window を触らないため)
  const [senderId, setSenderId] = useState<string | null>(null);

  // chat worker が 8789 で動いている前提
  const wsUrl = useMemo(() => {
    return `ws://localhost:8789/ws?room=${encodeURIComponent(room)}`;
  }, [room]);

  const wsRef = useRef<WebSocket | null>(null);
  const bottomRef = useRef<HTMLDivElement | null>(null);

  const [status, setStatus] = useState<
    "idle" | "connecting" | "open" | "closed" | "error"
  >("idle");
  const [input, setInput] = useState("");
  const [messages, setMessages] = useState<UiMessage[]>([]);

  // ★ ブラウザでマウントされた後に senderId を確定させる
  useEffect(() => {
    setSenderId(getOrCreateSenderId());
  }, []);

  // 新着が来たら末尾にスクロール
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages.length]);

  // WS接続(senderId の有無に依存しないのでそのままでOK)
  useEffect(() => {
    setStatus("connecting");

    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onopen = () => {
      setStatus("open");
      setMessages((p) => [
        ...p,
        {
          id: crypto.randomUUID(),
          kind: "system",
          text: `connected (${room})`,
        },
      ]);
    };

    ws.onmessage = (e) => {
      const raw = String(e.data);
      const parsed = safeParseIncoming(raw);

      if (!parsed) {
        setMessages((p) => [
          ...p,
          { id: crypto.randomUUID(), kind: "system", text: `raw: ${raw}` },
        ]);
        return;
      }

      if (parsed.type === "system") {
        setMessages((p) => [
          ...p,
          { id: crypto.randomUUID(), kind: "system", text: parsed.text },
        ]);
        return;
      }

      setMessages((p) => [
        ...p,
        {
          id: crypto.randomUUID(),
          kind: "msg",
          text: parsed.text,
          senderId: parsed.senderId,
          ts: parsed.ts,
        },
      ]);
    };

    ws.onerror = () => {
      setStatus("error");
      setMessages((p) => [
        ...p,
        { id: crypto.randomUUID(), kind: "system", text: "ws error" },
      ]);
    };

    ws.onclose = () => {
      setStatus("closed");
      setMessages((p) => [
        ...p,
        { id: crypto.randomUUID(), kind: "system", text: "closed" },
      ]);
    };

    return () => {
      ws.close();
      wsRef.current = null;
    };
  }, [wsUrl, room]);

  const send = () => {
    const ws = wsRef.current;
    if (!ws || ws.readyState !== WebSocket.OPEN) return;

    // senderId が未確定なら送れない(マウント直後の一瞬だけ)
    if (!senderId) return;

    const text = input.trim();
    if (!text) return;

    ws.send(
      JSON.stringify({ type: "msg", senderId, text, clientTs: Date.now() }),
    );
    setInput("");
  };

  const canSend = status === "open" && !!senderId;

  return (
    <div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
      {/* Header */}
      <div
        style={{
          padding: "12px 16px",
          borderBottom: "1px solid #e5e7eb",
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          background: "#fff",
        }}
      >
        <div>
          <div style={{ fontWeight: 700 }}>Chat</div>
          <div style={{ fontSize: 12, opacity: 0.7 }}>room: {room}</div>
        </div>
        <div style={{ fontSize: 12, opacity: 0.8 }}>status: {status}</div>
      </div>

      {/* Messages */}
      <div
        style={{
          flex: 1,
          overflowY: "auto",
          padding: 16,
          background: "#f6f7f9",
        }}
      >
        {messages.map((m) => {
          if (m.kind === "system") {
            return (
              <div
                key={m.id}
                style={{
                  textAlign: "center",
                  margin: "10px 0",
                  fontSize: 12,
                  opacity: 0.7,
                }}
              >
                {m.text}
              </div>
            );
          }

          // senderId が null の間は “自分判定できない” → 全部左扱いでOK
          const mine = !!senderId && m.senderId === senderId;

          return (
            <div
              key={m.id}
              style={{
                display: "flex",
                justifyContent: mine ? "flex-end" : "flex-start",
                margin: "8px 0",
              }}
            >
              <div
                style={{
                  maxWidth: "78%",
                  padding: "10px 12px",
                  borderRadius: 14,
                  background: mine ? "#2563eb" : "#ffffff",
                  color: mine ? "#ffffff" : "#111827",
                  boxShadow: "0 1px 2px rgba(0,0,0,0.08)",
                  whiteSpace: "pre-wrap",
                  wordBreak: "break-word",
                }}
              >
                {m.text}
              </div>
            </div>
          );
        })}
        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <div
        style={{
          padding: 12,
          borderTop: "1px solid #e5e7eb",
          background: "#fff",
        }}
      >
        <div style={{ display: "flex", gap: 8 }}>
          <input
            style={{
              flex: 1,
              padding: "10px 12px",
              border: "1px solid #d1d5db",
              borderRadius: 10,
              outline: "none",
            }}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") send();
            }}
            placeholder="メッセージを入力..."
          />
          <button
            onClick={send}
            disabled={!canSend}
            style={{
              padding: "10px 14px",
              borderRadius: 10,
              border: "1px solid #d1d5db",
              background: canSend ? "#111827" : "#e5e7eb",
              color: canSend ? "#ffffff" : "#6b7280",
              cursor: canSend ? "pointer" : "not-allowed",
            }}
          >
            送信
          </button>
        </div>

        <div style={{ fontSize: 11, opacity: 0.55, marginTop: 6 }}>
          ws: {wsUrl}
        </div>
      </div>
    </div>
  );
}