Use Cases (更新: 2026/6/7)

Claude CodeでWebSocketチャット:再接続とレート制限まで動く実装

「とりあえず作って」で出てくるWebSocketチャットは公開後に必ず壊れる。認証・再接続・レート制限まで動くNode.jsコードと、僕がやらかした落とし穴を共有します。

Claude CodeでWebSocketチャット:再接続とレート制限まで動く実装

「WebSocketチャット作って」とClaude Codeに頼んだら、5分で動くものが出てきました。ローカルで2タブ開いて、片方で打った文字がもう片方に出る。気持ちいい。

でも、そのまま社内Slack代わりに置いた翌週、サーバーが落ちました。誰かがブラウザのコンソールから無限ループで投稿を流していて、メモリが膨らんでいたんです。認証なし、送信制限なし、再接続は即時ループ。「動いて見える」と「運用できる」は、まったくの別物でした。

この記事は、その別物の差分を埋める話です。Claude Codeに丸投げするのではなく、最初に渡す仕様と、生成後に僕が必ず見る確認点を、動くコードつきでまとめます。

この記事の要点

  • WebSocketは「ブラウザとサーバーが1本の接続を張り、両方から送れる」仕組み。即時反映に強いが、接続が長く残るぶん雑に作ると認証・メモリ・連投・再接続ループで壊れる。
  • 事故の9割は最初の依頼文で防げる。「認証・再接続・レート制限・履歴上限を最初から入れて」と渡すだけで、後から足すより差分が小さくなる。
  • 認証は最初のメッセージではなく upgrade時 に。Originを許可リストで確認しないと、別サイトから勝手に接続される。
  • 再接続は 指数バックオフ+ジッター。即時ループは復旧中のサーバーをもう一度落とす。
  • 複数台に増やした瞬間、Mapによるfan-outは別プロセスへ届かない。そこからがRedis Pub/Subの出番。

WebSocketチャットは「ちょうどいい練習台」

WebSocketチャットは、Claude Codeの実力を測る題材としてサイズがちょうどいいです。画面はテキスト入力と表示だけ。でも実務の論点はほぼ全部出てきます。接続、認証、再接続、メッセージの配信、レート制限、ログ、障害時の復旧。CRUDのフォームを10枚作るより、よっぽど学びが濃い。

WebSocketは「ブラウザとサーバーが1本の接続を張りっぱなしにして、どちらからでもデータを送れる仕組み」です。HTTPのリクエスト/レスポンスだけだと、サーバー側から「今これが起きたよ」と押し出すのが苦手で、チャットやライブ通知のような即時反映には向きません。そこを埋めるのがWebSocketです。

裏返すと、接続が長く残るのが弱点になります。HTTPなら処理が終われば接続も消えますが、WebSocketは張りっぱなし。だから雑に作ると、メモリが増え続ける、認証をすり抜けられる、連投で詰まる、切断後に再接続が暴れる、といった問題が後から効いてきます。仕様の基準はブラウザ側ならMDNのWebSocket API、プロトコルそのものはIETF RFC 6455を見ておくと、Claude Codeの出力が正しいか判断しやすくなります。Claude Code自体の前提は公式ドキュメントに合わせます。

基礎を固めたいなら、API全体の組み方はClaude Codeで本番API開発、公開前の見落とし対策はClaude Codeコードレビュー、攻撃面の話はClaude Codeセキュリティベストプラクティスもつながります。

どんなチャットを作るかで「最初に決めること」が変わる

ひとくちにWebSocketチャットと言っても、用途で気をつける場所が変わります。僕がよく頼まれる3つを並べます。

1つ目は、社内サポートや小さなコミュニティのチャット。 本文・参加者名・部屋ID・直近履歴だけで始められて、いちばん手軽です。ただし匿名で何通でも送れる作りにすると、荒らしや自動投稿に一発で沈みます。最低限、接続時の認証、部屋ごとの権限、1ユーザーあたりの送信上限がいる。

2つ目は、ダッシュボードのライブ更新。 売上、在庫、ジョブの実行状況、CIの進捗を即時に画面へ流します。ここで効いてくるのは本文よりも「古いイベントをどう捨てるか」です。ブラウザのWebSocketには強いバックプレッシャー、つまり受け手が処理しきれない量を送信側に自然に抑えさせる仕組みがありません。MDNも、到着が速すぎるとメモリやCPUを圧迫すると注意しています。

3つ目は、学習サイトや有料教材の質問ルーム。 記事を読んだ直後の読者が質問して、運営が補足リンクや教材を案内できる。収益導線としては強いんですが、問い合わせフォームより個人情報が混ざりやすい。ログの保存期間、禁止語、管理者通知、削除手順を先に決めておかないと、後で困ります。

用途WebSocketが向く理由最初に決めること
小規模チャット双方向で遅延が少ない認証、部屋、履歴件数
ライブ通知サーバーから即時に押し出せる古いイベントの破棄、再送範囲
学習・サポート記事から会話へ移りやすい個人情報、ログ、CTAの位置

用語だけ先に2つ。fan-out は、1件のメッセージを同じ部屋にいる複数の接続へ配る処理のこと。heartbeat は、接続がまだ生きているかを定期的に確かめる仕組みのことです。どちらもこの後のコードに出てきます。

全体像をひと目で

最小構成はこうなります。ブラウザのタブとスモークテスト用クライアントが/chatにつなぎ、サーバー側は接続のたびに「Origin・トークンの確認 → 部屋へ登録 → fan-out」を回します。

flowchart LR
  BrowserA["Browser tab A"] -->|ws:// /chat| Node["Node.js server"]
  BrowserB["Browser tab B"] -->|ws:// /chat| Node
  Smoke["smoke-client.mjs"] -->|ws:// /chat| Node
  Node --> Auth["token and Origin check"]
  Node --> Rooms["room registry"]
  Rooms --> Fanout["fan-out to room peers"]
  Node --> History["last 50 messages"]
  Node --> Limit["rate limit and max payload"]

初心者が最初に転ぶのは、「接続できた」を「運用できる」と勘違いする点です。ローカルの1タブは、認証がなくても、履歴が無制限でも、再接続が乱暴でも、ちゃんと動いて見える。でも公開した瞬間に、別サイトからの接続・連投・スマホ回線の切断・複数サーバー構成が同時に襲ってきます。だから最小実装の段階で、小さな防御を織り込んでおきます。

Claude Codeに渡す依頼文(ここが9割)

僕の経験だと、事故の大半は最初の一行で決まります。「WebSocketチャット作って」とだけ頼むと、認証なし・再接続なし・サイズ制限なしのデモが出てくる。だから依頼の時点で、作る範囲と禁止事項をはっきり書きます。

Node.js 20以上とwsパッケージで、最小のWebSocketチャットを作ってください。

要件:
- server.js、index.html、smoke-client.mjs、package.jsonだけで動く
- ブラウザで http://localhost:8080 を開くとチャットできる
- /chat のWebSocket upgrade時に token と Origin を検証する
- 部屋ごとにfan-outする
- 直近50件だけメモリに保存する
- 1接続あたり10秒20件の送信制限を入れる
- 最大メッセージ長は500文字
- クライアントはclose時に指数バックオフで再接続する
- readyStateとbufferedAmountを見て送信キューを扱う
- smoke-client.mjsで接続と送信を検証できる

禁止:
- Socket.IOに置き換えない
- 認証なしの接続を許可しない
- 受信JSONを検証せずに使わない
- 本番向けと誤解される永続化コードを書かない

「禁止」を書くのが地味に効きます。放っておくとClaude Codeは気を利かせてSocket.IOに乗り換えたり、それっぽいDB保存コードを足したりする。それが悪いわけじゃないんですが、最小版の見通しが一気に悪くなるので、最初は止めておきます。

ローカルで動かす準備

次の4ファイルを同じディレクトリに置きます。依存はwsだけ。ブラウザを2つ開けば手で確認でき、別ターミナルでnpm run smokeを打てば最小の自動確認もできます。

mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start

package.jsonは次の内容に置き換えます。

{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "smoke": "node smoke-client.mjs"
  },
  "dependencies": {
    "ws": "^8.18.3"
  },
  "engines": {
    "node": ">=20"
  }
}

サーバー実装:認証はupgrade時に済ませる

このサーバーはhttp://localhost:8080index.htmlを配り、同じポートの/chatでWebSocket接続を受けます。CHAT_TOKENを指定しなければ、デモ用トークンはdev-tokenです。読みどころは1か所、server.on("upgrade", ...)の中。ここで/chat以外を弾き、Originを許可リストで確認し、トークンが合わなければ接続を成立させません。認証を「最初のメッセージ」まで遅らせると、その一瞬だけ未認証の接続が成立してしまう。だから入口で止めます。

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket, WebSocketServer } from "ws";

const PORT = Number(process.env.PORT ?? 8080);
const AUTH_TOKEN = process.env.CHAT_TOKEN ?? "dev-token";
const MAX_MESSAGE_LENGTH = 500;
const HISTORY_LIMIT = 50;
const RATE_WINDOW_MS = 10_000;
const RATE_LIMIT = 20;
const CLIENT_FILE = join(fileURLToPath(new URL(".", import.meta.url)), "index.html");
const ALLOWED_ORIGINS = new Set(
  (process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT},http://127.0.0.1:${PORT}`)
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean)
);

const rooms = new Map();
const histories = new Map();
const clients = new Map();

const server = createServer(async (request, response) => {
  if (request.url === "/" || request.url === "/index.html") {
    try {
      const html = await readFile(CLIENT_FILE, "utf8");
      response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      response.end(html);
    } catch {
      response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
      response.end("index.html was not found");
    }
    return;
  }

  // ロードバランサーからの生存確認用。WebSocketはHTTPログだけでは状態が見えない
  if (request.url === "/healthz") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ ok: true, clients: clients.size }));
    return;
  }

  response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
  response.end("not found");
});

const wss = new WebSocketServer({ noServer: true, maxPayload: 2048 });

// 門番:HTTPからWebSocketへ昇格する瞬間に、ここで全部チェックする
server.on("upgrade", (request, socket, head) => {
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
  if (url.pathname !== "/chat") {
    rejectUpgrade(socket, 404, "Not Found");
    return;
  }

  const origin = request.headers.origin ?? "";
  if (!ALLOWED_ORIGINS.has(origin)) {
    rejectUpgrade(socket, 403, "Forbidden");
    return;
  }

  const token = url.searchParams.get("token");
  if (token !== AUTH_TOKEN) {
    rejectUpgrade(socket, 401, "Unauthorized");
    return;
  }

  const context = {
    name: cleanName(url.searchParams.get("name")),
    room: cleanRoom(url.searchParams.get("room"))
  };

  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit("connection", ws, request, context);
  });
});

wss.on("connection", (ws, request, context) => {
  const client = {
    id: randomUUID(),
    name: context.name,
    room: context.room,
    isAlive: true,
    rateResetAt: Date.now() + RATE_WINDOW_MS,
    messagesInWindow: 0
  };

  clients.set(ws, client);
  joinRoom(ws, client.room);

  send(ws, { type: "system", message: `connected as ${client.name}`, clientId: client.id });
  send(ws, { type: "history", messages: histories.get(client.room) ?? [] });
  broadcast(client.room, { type: "presence", message: `${client.name} joined`, online: roomSize(client.room) });

  ws.on("pong", () => {
    client.isAlive = true;
  });

  ws.on("message", (raw, isBinary) => {
    if (isBinary) {
      ws.close(1003, "text messages only");
      return;
    }

    if (!consumeQuota(client)) {
      send(ws, { type: "error", code: "rate_limited", message: "Too many messages. Wait a moment." });
      return;
    }

    const textBody = raw.toString("utf8");
    if (textBody.length > MAX_MESSAGE_LENGTH) {
      send(ws, { type: "error", code: "too_large", message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less.` });
      return;
    }

    let event;
    try {
      event = JSON.parse(textBody);
    } catch {
      send(ws, { type: "error", code: "bad_json", message: "Send JSON such as {\"type\":\"message\",\"text\":\"hi\"}." });
      return;
    }

    if (event.type !== "message") {
      send(ws, { type: "error", code: "bad_type", message: "Only message events are accepted." });
      return;
    }

    const text = String(event.text ?? "").trim();
    if (!text) {
      send(ws, { type: "error", code: "empty", message: "Message text is required." });
      return;
    }

    // 送信者名はクライアントからではなく、接続に紐づいたサーバー側の値を使う
    const message = {
      id: randomUUID(),
      room: client.room,
      from: client.name,
      text,
      sentAt: new Date().toISOString()
    };

    remember(client.room, message);
    broadcast(client.room, { type: "message", message });
  });

  ws.on("close", () => {
    leaveRoom(ws);
    clients.delete(ws);
    broadcast(client.room, { type: "presence", message: `${client.name} left`, online: roomSize(client.room) });
  });

  ws.on("error", (error) => {
    console.error("websocket error", error);
  });
});

// heartbeat:返事(pong)のない接続は死んだとみなして片付ける
const heartbeat = setInterval(() => {
  for (const ws of wss.clients) {
    const client = clients.get(ws);
    if (!client) continue;
    if (!client.isAlive) {
      ws.terminate();
      continue;
    }
    client.isAlive = false;
    ws.ping();
  }
}, 30_000);

server.listen(PORT, () => {
  console.log(`Chat demo: http://localhost:${PORT}`);
  console.log(`Token: ${AUTH_TOKEN}`);
});

process.on("SIGINT", () => {
  clearInterval(heartbeat);
  wss.close();
  server.close(() => process.exit(0));
});

function rejectUpgrade(socket, statusCode, message) {
  socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`);
  socket.destroy();
}

function cleanName(value) {
  const name = String(value ?? "guest").trim().slice(0, 24);
  return /^[\w -]+$/.test(name) ? name : "guest";
}

function cleanRoom(value) {
  const room = String(value ?? "lobby").trim().slice(0, 32);
  return /^[a-zA-Z0-9_-]+$/.test(room) ? room : "lobby";
}

function joinRoom(ws, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);
}

function leaveRoom(ws) {
  const client = clients.get(ws);
  if (!client) return;
  const peers = rooms.get(client.room);
  if (!peers) return;
  peers.delete(ws);
  if (peers.size === 0) rooms.delete(client.room);
}

function roomSize(room) {
  return rooms.get(room)?.size ?? 0;
}

function send(ws, payload) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(payload));
  }
}

function broadcast(room, payload) {
  const peers = rooms.get(room);
  if (!peers) return;
  const body = JSON.stringify(payload);
  for (const peer of peers) {
    if (peer.readyState === WebSocket.OPEN) {
      peer.send(body);
    }
  }
}

function remember(room, message) {
  const history = histories.get(room) ?? [];
  history.push(message);
  while (history.length > HISTORY_LIMIT) history.shift();
  histories.set(room, history);
}

function consumeQuota(client) {
  const now = Date.now();
  if (now > client.rateResetAt) {
    client.rateResetAt = now + RATE_WINDOW_MS;
    client.messagesInWindow = 0;
  }
  client.messagesInWindow += 1;
  return client.messagesInWindow <= RATE_LIMIT;
}

ひとつ補足。トークンをURLクエリに置くのはデモだからです。本番でこれをやると、アクセスログや中間プロキシにトークンが残ります。公開環境では、短命チケット、HTTP-only Cookie、リバースプロキシ側の認証のどれかに寄せてください。

ブラウザクライアント:再接続は指数バックオフで

ブラウザのWebSocketコンストラクタは、URLとサブプロトコルしか受け取りません。fetchのようにAuthorizationヘッダーを自由に付ける形ではないので、このデモではURLクエリのtokenを使っています。読みどころはscheduleReconnect。切断されても即時に再接続せず、待ち時間を少しずつ伸ばしながらランダムな揺らぎ(ジッター)を足しています。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>WebSocket Chat Demo</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 760px; margin: 32px auto; padding: 0 16px; }
      label { display: block; margin-top: 12px; }
      input, button { font: inherit; padding: 8px; }
      #log { border: 1px solid #ccc; min-height: 240px; padding: 12px; overflow: auto; }
      .message { margin: 0 0 8px; }
      .muted { color: #666; }
      .error { color: #b00020; }
    </style>
  </head>
  <body>
    <h1>WebSocket Chat Demo</h1>
    <p id="status" class="muted">offline</p>

    <label>Room <input id="room" value="lobby" /></label>
    <label>Name <input id="name" value="masa" /></label>
    <label>Token <input id="token" value="dev-token" /></label>
    <button id="connect">Connect</button>
    <button id="disconnect">Disconnect</button>

    <div id="log" aria-live="polite"></div>

    <form id="form">
      <label>Message <input id="message" autocomplete="off" maxlength="500" /></label>
      <button type="submit">Send</button>
    </form>

    <script>
      const statusEl = document.querySelector("#status");
      const logEl = document.querySelector("#log");
      const formEl = document.querySelector("#form");
      const roomEl = document.querySelector("#room");
      const nameEl = document.querySelector("#name");
      const tokenEl = document.querySelector("#token");
      const messageEl = document.querySelector("#message");
      const connectEl = document.querySelector("#connect");
      const disconnectEl = document.querySelector("#disconnect");

      let socket;
      let reconnectTimer;
      let reconnectAttempt = 0;
      let manuallyClosed = false;
      const pendingMessages = [];

      connectEl.addEventListener("click", connect);
      disconnectEl.addEventListener("click", () => {
        manuallyClosed = true;
        clearTimeout(reconnectTimer);
        if (socket) socket.close(1000, "user disconnected");
      });

      formEl.addEventListener("submit", (event) => {
        event.preventDefault();
        const text = messageEl.value.trim();
        if (!text) return;
        const payload = JSON.stringify({ type: "message", text });

        // 送れる状態でなければキューに退避し、再接続後にまとめて流す
        if (socket?.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
          socket.send(payload);
        } else {
          pendingMessages.push(payload);
          writeLog("Queued because the socket is not ready.", "muted");
        }

        messageEl.value = "";
      });

      function connect() {
        manuallyClosed = false;
        clearTimeout(reconnectTimer);
        if (socket && socket.readyState === WebSocket.OPEN) return;

        const params = new URLSearchParams({
          token: tokenEl.value,
          name: nameEl.value,
          room: roomEl.value
        });
        const protocol = location.protocol === "https:" ? "wss:" : "ws:";
        socket = new WebSocket(`${protocol}//${location.host}/chat?${params.toString()}`);
        setStatus("connecting");

        socket.addEventListener("open", () => {
          reconnectAttempt = 0;
          setStatus("online");
          flushPending();
        });

        socket.addEventListener("message", (event) => {
          const data = JSON.parse(event.data);
          renderEvent(data);
        });

        socket.addEventListener("close", (event) => {
          setStatus(`closed ${event.code}`);
          if (!manuallyClosed) scheduleReconnect();
        });

        socket.addEventListener("error", () => {
          writeLog("WebSocket error. Check the server log.", "error");
        });
      }

      // 指数バックオフ+ジッター:一斉再接続でサーバーを二度殺さないため
      function scheduleReconnect() {
        const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
        reconnectAttempt += 1;
        setStatus(`reconnecting in ${delay}ms`);
        reconnectTimer = setTimeout(connect, delay);
      }

      function flushPending() {
        while (pendingMessages.length > 0 && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
          socket.send(pendingMessages.shift());
        }
      }

      function renderEvent(data) {
        if (data.type === "message") {
          writeLog(`${data.message.from}: ${data.message.text}`);
        } else if (data.type === "history") {
          writeLog(`Loaded ${data.messages.length} previous messages.`, "muted");
          data.messages.forEach((message) => writeLog(`${message.from}: ${message.text}`));
        } else if (data.type === "error") {
          writeLog(`${data.code}: ${data.message}`, "error");
        } else {
          writeLog(data.message ?? JSON.stringify(data), "muted");
        }
      }

      function setStatus(value) {
        statusEl.textContent = value;
      }

      function writeLog(value, className = "message") {
        const line = document.createElement("p");
        line.className = className;
        line.textContent = value;
        logEl.append(line);
        logEl.scrollTop = logEl.scrollHeight;
      }

      connect();
    </script>
  </body>
</html>

スモークテスト:壊れたら自分で気づける

手で2タブ開く確認だけだと、後でレート制限や認証をいじったときに壊れても気づけません。smoke-client.mjsは、同じ/chatへ接続して1件送り、自分が受け取れたら成功で抜けます。CIに入れておくと、保護を足したつもりが他を壊した、という事故を早く拾えます。

import { WebSocket } from "ws";

const url = new URL("ws://localhost:8080/chat");
url.searchParams.set("token", process.env.CHAT_TOKEN ?? "dev-token");
url.searchParams.set("name", "smoke");
url.searchParams.set("room", "lobby");

const ws = new WebSocket(url, {
  headers: {
    Origin: "http://localhost:8080"
  }
});

const timeout = setTimeout(() => {
  console.error("smoke test timed out");
  ws.terminate();
  process.exit(1);
}, 5000);

ws.on("open", () => {
  ws.send(JSON.stringify({ type: "message", text: "hello from smoke test" }));
});

ws.on("message", (raw) => {
  const data = JSON.parse(raw.toString("utf8"));
  console.log(data);
  if (data.type === "message" && data.message.text === "hello from smoke test") {
    clearTimeout(timeout);
    ws.close(1000, "done");
  }
});

ws.on("close", (code, reason) => {
  console.log(`closed ${code} ${reason.toString()}`);
  process.exit(code === 1000 ? 0 : 1);
});

ws.on("error", (error) => {
  clearTimeout(timeout);
  console.error(error);
  process.exit(1);
});

サーバーを起動したまま、別ターミナルで実行します。

npm run smoke

実装の読みどころと、本番で変わる場所

ここまでの最小版は1プロセス前提で組んでいます。本番に寄せると変わる場所を、要点だけ押さえます。

認証 はupgrade時に1回。でもチャットは長くつながるので、接続中に権限が変わることもあります。部屋への参加、投稿、削除、管理者操作のたびに権限を見直す設計にしておくと安全です。

レート制限 は接続ごとに10秒20件。これは完全な防御ではなく、Claude Codeに「制限を入れる」という具体的な足場を渡すための最小版です。本番ではユーザーID・IP・部屋ID・組織IDなど複数の単位でかけ、Redisのような共有ストアへ移します。

fan-outroomsというMapで管理しています。同じ部屋のSetに接続を入れ、送信時に全員へ配るだけ。1プロセスならこれで十分ですが、Node.jsを2台に増やした瞬間、別プロセスにつながっている人へは届きません。ここがスケールの最初の壁で、Redis Pub/Sub、NATS、Kafka、クラウドのメッセージ基盤などで各プロセスにイベントを配る形に変えます。

bufferedAmount も見ています。これは「まだ送り切れずに溜まっている量」です。増え続けるなら、ネットワークか受け手が詰まっているサイン。このデモは64KBを超えたらキューに回しますが、本番では古い通知を捨てる・オフライン表示を出す・接続を閉じるなど、用途で判断を変えます。

ログの粒度 は早めに決めます。本文を常に残すと個人情報や有料サポートの相談が溜まりすぎ、逆に何も残さないと障害時に追えません。実務では接続ID・ユーザーID・部屋ID・close code・拒否理由・レート制限の発火回数を中心に残し、本文は保存先と保持期間を明記します。Claude Codeには「ログに本文を出さない」「トークンを出力しない」と先に伝えておくと、レビューでの手戻りが減ります。

失敗例と落とし穴

僕や周りが実際に踏んだものを表にします。冒頭の「コンソールから連投でサーバーが落ちた」は、下の表でいう上から3行目です。

失敗例起きること対策
認証を最初のメッセージだけで見る未認証接続が一瞬成立するupgrade時に拒否し、必要なら接続後も権限確認する
Originを確認しない別サイトからCookie付きで接続される許可Originを環境変数で固定する
送信制限がない1接続の連投でCPUとメモリが詰まる接続、ユーザー、IP単位で制限する
履歴を無制限にメモリ保存する長時間稼働でメモリが増え続ける件数、期間、DB保存方針を決める
再接続を即時ループにする障害復旧時に再接続嵐になる指数バックオフとジッターを使う
複数台構成を考えないAサーバーの利用者にしか届かないPub/Subやメッセージキューでfan-outする
message.fromをクライアントから信じるなりすまし投稿ができる接続に紐づいたサーバー側のユーザー名を使う

特に2行目のOriginは見落とされがちです。WebSocketはサーバー側がOriginを見ないと、別サイトに置かれた悪意のスクリプトからCookie付きで接続される設計になりやすい。MDNのWebSocket APIとあわせて、ここは必ず潰してください。

本番に近づけるチェックリスト

公開前に、僕は正常系より異常系を多めに触ります。順番はだいたい決まっていて、トークンなし → 別Origin → 長すぎる本文 → 空文字 → 連投 → サーバー再起動 → スマホ回線の切り替え。それぞれ「ちゃんと拒否されるか」「再接続で戻るか」を見ます。

  • wss://を使う。ログイン済みユーザーや個人情報を扱うなら、TLSなしのws://は避ける。
  • 認証を接続時だけで終わらせず、部屋参加・投稿・削除・管理者操作ごとに権限を確認する。
  • 保存はメモリからDBへ。同期で書いてから配信すると遅延が出るので、小規模なら先に保存→配信、速度優先ならキューで非同期保存、と用途で一貫性を決める。
  • 監視は接続数・1秒あたりのメッセージ数・close code・エラー数・bufferedAmountが増えた回数・レート制限の発火数。/healthzでロードバランサーから生存確認できるようにする。

Claude Codeに追加実装を頼むときは、1回の依頼を小さく切ります。「Redis Pub/Subへ移す」「PostgreSQLに履歴保存する」「Playwrightで2タブの送受信をテストする」のように分ける。大きく一気に頼むと、動いていた認証やCTAが差分の中で静かに消えることがあるからです。公開前レビューの型はClaude Codeレビューゲートにまとめています。

よくある質問

Q. Socket.IOとwsはどっちを使うべき? 学習や最小実装ならwsで十分です。Socket.IOは再接続やルーム機能を最初から持っていて便利ですが、独自プロトコルが乗るぶん、WebSocketそのものの挙動が見えにくくなります。まずwsで仕組みを理解してから、必要に応じてSocket.IOを検討する順番をおすすめします。

Q. なぜ認証を「最初のメッセージ」でやってはいけないの? upgradeを許してから最初のメッセージで認証すると、その間だけ未認証の接続が成立します。攻撃側はその一瞬で接続を張りっぱなしにできる。だからHTTPからWebSocketへ昇格するupgradeの段階で、Originとトークンをまとめて確認します。

Q. サーバーを複数台にしたら、別サーバーの人にメッセージが届きません。 正常です。このコードはMapで接続を持っているので、別プロセスの接続は知りません。Redis Pub/SubやNATSなどでプロセス間にイベントを配り、各サーバーが受け取って自分の接続へfan-outする形に変えてください。

Q. 再接続でメッセージが二重に届くことはある? あり得ます。送信側がキューを持っていて、サーバーが受け取った後に切断・再送した場合などです。本番ではメッセージにIDを振り、受信側で重複IDを捨てる(冪等にする)と安全です。この記事のコードもメッセージにidを持たせてあります。

Q. WebSocketとSSE(Server-Sent Events)の使い分けは? サーバーからの一方通行の通知だけなら、SSEのほうが軽くて再接続も標準で面倒を見てくれます。クライアントからも頻繁に送る双方向のチャットなら、WebSocketが向いています。「双方向か、片方向か」で選ぶのが分かりやすいです。

この記事で紹介した内容を実際に試した結果

server.jsを起動してhttp://localhost:8080を2タブで開くと、同じlobbyに送ったメッセージが両方へ即時に出ました。npm run smokeでもhello from smoke testの送受信が通り、Originを変えると403、トークンを変えると401で接続が弾かれるのを確認しています。

いちばん効いたのは、最初の依頼に「再接続・レート制限・Origin検証・履歴50件」を全部書いておくことでした。後から安全対策を足すより差分がずっと小さく、レビューも具体的になる。冒頭でサーバーを落とした僕が言うので間違いないです。最初の一行に保護を書く、それだけで事故の景色が変わります。

もし自分のサービスに組み込む前提で、認証・監査ログ・レート制限・Pub/Sub・CIまでチームで設計したいなら、Claude Code研修・導入相談で実際の構成に合わせて相談できます。まずは手元でこの最小版を動かし、異常系を一通り触ってみてください。

#Claude Code #WebSocket #チャット #再接続 #Node.js
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。