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

Claude CodeでWebSocketチャットを作る実践ガイド

Claude CodeでWebSocketチャットを安全に作る手順。認証、再接続、レート制限、動くNode.jsコードを解説。

Claude CodeでWebSocketチャットを作る実践ガイド

WebSocketチャットは、Claude Codeの実力を試す題材としてちょうどよいサイズです。画面はシンプルですが、実務で必要な論点は多く、接続、認証、再接続、メッセージの配信、レート制限、ログ、障害時の復旧まで一通り出てきます。

この記事では、Claude Codeに丸投げするのではなく、作らせる前に渡す仕様と、生成後に人間が見るべき確認点を整理します。WebSocketは「ブラウザとサーバーが1本の接続を張り、双方からデータを送れる仕組み」です。HTTPのリクエスト/レスポンスだけでは作りづらい即時反映に向いています。一方で、接続が長く残るため、雑に作るとメモリ、認証、スパム、再接続ループで壊れます。

仕様の確認は、ブラウザAPIならMDNのWebSocket API、基本的なクライアント実装ならWriting WebSocket client applications、プロトコルそのものはIETF RFC 6455を基準にします。Node.jsでHTTPからWebSocketへ切り替える部分はNode.js HTTPのupgradeイベント、今回使うライブラリはws公式READMEを参照します。Origin検証はOWASP Web Security Testing GuideのWebSockets項目も必ず見てください。Claude Code自体の前提はClaude Code公式ドキュメントに合わせます。

関連する基礎として、API全体の作り方はClaude Codeで本番API開発、公開前の見落とし対策はClaude Codeコードレビュー、セキュリティ観点はClaude Codeセキュリティベストプラクティスも合わせて読むとつながります。

WebSocketチャットで扱う3つの実務ユースケース

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

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

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

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

全体像

ここでいうfan-outは、1件のメッセージを同じ部屋にいる複数の接続へ配る処理です。heartbeatは、接続が生きているかを定期的に確かめる仕組みです。harnessという言葉が出る場合は「エージェントの足場」と考えると分かりやすく、Claude Codeがファイル編集やコマンド実行を扱うための作業環境を指します。

初心者が最初に迷いやすいのは、「接続できた」ことと「安全に運用できる」ことを同じ成果だと思ってしまう点です。ローカルの1タブで動くチャットは、認証がなくても、履歴を無制限に持っても、再接続が乱暴でも動いて見えます。しかし公開後は、別サイトからの接続、連投、スマホ回線の切断、複数サーバー構成が同時に来ます。だからこそ、最小実装の段階で小さな防御を入れておきます。

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"]

Claude Codeに渡す依頼文

いきなり「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を検証せずに使わない
- 本番向けと誤解される永続化コードを書かない

ローカルで動かす準備

次の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"
  }
}

サーバー実装

このサーバーはhttp://localhost:8080index.htmlを配信し、同じポートの/chatでWebSocket接続を受けます。CHAT_TOKENを指定しない場合、デモ用トークンはdev-tokenです。本番ではURLクエリに長寿命トークンを置かず、短命チケット、HTTP-only Cookie、またはリバースプロキシ側の認証と組み合わせます。

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;
  }

  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 });

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);
  });
});

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;
}

ブラウザクライアント

ブラウザのWebSocketコンストラクタは、URLとサブプロトコルを受け取ります。通常のfetchのように任意のAuthorizationヘッダーを渡す形ではありません。そのため、このデモではURLクエリのtokenを使います。公開環境では、ログに残りやすい長寿命トークンを避け、短命チケットやCookieベースのセッションに寄せます。

<!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>

スモークテスト用クライアント

手動確認だけだと、後からレート制限や認証を変更したときに壊れても気づきにくくなります。smoke-client.mjsは、同じ/chatへ接続し、1件送信し、自分が受け取れたら終了します。

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

実装の読みどころ

認証はupgrade時に行っています。HTTPの通常リクエストと同じ入口で、/chat以外を拒否し、Originを許可リストで確認し、トークンが合わなければ101 Switching Protocolsへ進ませません。OWASPが指摘する通り、WebSocketではサーバー側がOriginを見ないと、別サイトから勝手に接続される設計になりやすいです。

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

fan-outはroomsというMapで管理しています。同じ部屋のSetに接続を入れ、送信時に全員へJSONを配ります。1プロセスではこれで動きますが、Node.jsを複数台に増やすと別プロセスの接続には届きません。その段階ではRedis Pub/Sub、NATS、Kafka、クラウドのメッセージ基盤などを使い、各プロセスへイベントを配る設計に変えます。

再接続はクライアント側で指数バックオフにしています。切断直後に全クライアントが一斉に再接続すると、復旧中のサーバーをさらに落とすことがあります。待ち時間を少しずつ伸ばし、ランダムな揺らぎを入れるのが実務では重要です。

readyStatebufferedAmountも見ています。readyStateOPENでなければ送れません。bufferedAmountが増え続ける場合、ネットワークや受け手が詰まっている可能性があります。このデモでは64KBを超えたらキューへ回しますが、本番では古い通知を捨てる、ユーザーへオフライン表示を出す、接続を閉じるなど、用途に合わせた判断が必要です。

Claude Codeに追加作業を頼むときは、接続管理、画面、保存先、監視を一度に広げすぎないことも大切です。たとえば「PostgreSQLに保存して、Redisで配信して、管理画面も作って」とまとめると、動いていた認証や再接続が差分の中で見えにくくなります。まずはこの最小版で、接続が拒否される条件、送信できる条件、切断後に戻る条件を固定します。そのうえで、保存、検索、管理者機能、通知メールのように分けて依頼します。小さく頼むほど、Claude Codeの出力は読みやすく、人間のレビューも具体的になります。

ログの粒度も早めに決めます。本文そのものを常にログへ出すと、個人情報や有料サポートの相談内容が残りすぎます。一方で、何も記録しないと障害時に原因を追えません。実務では、接続ID、ユーザーID、部屋ID、close code、拒否理由、レート制限の回数を中心に残し、本文は保存先と保持期間を明確にします。Claude Codeには「ログに本文を出さない」「トークンを出力しない」と明示しておくと、公開前レビューで余計な修正を減らせます。

失敗例と落とし穴

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

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

まずwss://を使います。ログイン済みユーザーや個人情報を扱うならTLSなしのws://は避けます。次に、認証を「接続時だけ」で終わらせず、部屋参加、投稿、削除、管理者操作ごとに権限を確認します。チャットは長くつながるので、接続中に権限が変わる場合もあります。

公開前には、正常系よりも異常系を多めに触ります。トークンなし、別Origin、長すぎる本文、空文字、連投、サーバー再起動、スマホ回線の切り替えを順番に試します。Claude Codeにレビューだけを頼む場合も、「動くか」ではなく「どの失敗が見えるか」を聞くと、抜けている保護が見つかりやすくなります。小さな表で結果を残すだけでも、後日の修正判断が速くなります。記事公開前の品質確認にも同じ記録が役立ちます。確認記録は読者への信頼にもつながります。必須です。

保存はメモリではなくDBに移します。ただし、全メッセージを同期的にDBへ書いてから配信すると遅延が増えます。小規模なら先にDBへ書いてから配信、速度優先ならキューへ入れて非同期保存など、用途に合わせて一貫性を決めます。監査が必要なサポートチャットなら、削除や編集の履歴も残します。

監視では、接続数、1秒あたりのメッセージ数、close code、エラー数、bufferedAmountが増えた回数、レート制限発火数を見ます。WebSocketはHTTPのアクセスログだけでは状態が見えません。/healthzのような軽いHTTPエンドポイントも用意し、ロードバランサーから生存確認できるようにします。

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

収益導線としての使い方

WebSocketチャットは、記事の末尾に置くだけの飾りではありません。読者が「このコードを自分のサービスに入れるならどこが危ないか」と考えた瞬間に、次の行動を出すと自然です。個人開発者なら無料チートシートで日常コマンドと確認項目を押さえ、テンプレート化したい人は商品一覧のClaude Code教材へ進めます。チームで認証、監査ログ、レート制限、Pub/Sub、CIまで設計したい場合はClaude Code研修・導入相談が現実的です。

AdSense向けにも、単なる「AIで作れます」では弱いです。実際に動くコード、失敗例、公式リンク、内部リンク、収益CTA、検証結果まで揃えると、検索流入の読者にとっても広告審査にとっても価値が伝わりやすくなります。

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

Masaの検証では、server.jsを起動してhttp://localhost:8080を2つのブラウザタブで開くと、同じlobbyルームに送ったメッセージが即時に両方へ表示されました。npm run smokeでもhello from smoke testを送受信でき、Originを変えると403、トークンを変えると401で接続が拒否されることを確認しました。最初にClaude Codeへ「再接続、レート制限、Origin検証、履歴50件」と条件を渡すと、後から安全対策を足すより差分が小さく、レビューもしやすくなります。

#Claude Code #WebSocket #チャット #リアルタイム #Node.js
無料

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

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

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

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

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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