Claude CodeでWebSocketリアルタイム実装:認証・再接続・heartbeatまで実用解説
Claude CodeでWebSocketを安全に実装する手順。認証、再接続、heartbeat、room配信、レビューまで解説。
WebSocketを使うと、画面を更新しなくてもサーバーからブラウザへ即座にメッセージを届けられます。Claude Codeに「リアルタイム機能を作って」と頼むだけでも雛形は出ますが、公開できる品質にするには、認証、再接続、heartbeat、バックプレッシャー、room配信、監査ログを最初から設計に入れる必要があります。
特に初心者がつまずきやすいのは「つながったら完成」と思ってしまう点です。実運用ではスマホ回線が切れ、タブがスリープし、遅い利用者の送信キューが膨らみ、不正なJSONが届きます。Claude Codeには、その壊れ方までレビューさせると価値が出ます。
この記事では、Node.jsのwsで最小のリアルタイム基盤を作り、ブラウザ側の再接続、メッセージ検証、heartbeat、room routing、テストクライアントまでコピペで動く形にします。WebSocket APIの基本はMDN WebSocket、状態判定はreadyState、切断処理はcloseも合わせて確認してください。
WebSocketを選ぶ場面
WebSocketは常時接続を張り、双方向に小さなメッセージを頻繁に流す仕組みです。HTTP APIを置き換えるものではありません。履歴保存、課金、権限変更、監査ログのように確実性が必要な処理は、通常のHTTP APIやDB書き込みを残します。
| 選択肢 | 向いている用途 | 避けたい用途 | Claude Codeへの指示 |
|---|---|---|---|
| WebSocket | チャット、共同編集、ライブ進捗、room配信 | たまに見るだけの通知、検索一覧 | 認証、再接続、heartbeat、backpressureを含める |
| Server-Sent Events | サーバーからブラウザへの一方向通知 | ブラウザから頻繁に送る操作 | 再接続IDとイベント種別を設計する |
| ポーリング | 管理画面の数十秒ごとの状態確認 | 低遅延が必要な操作 | キャッシュとレート制限を入れる |
| HTTP API | 保存、決済、監査、管理操作 | タイピング表示など高頻度イベント | WebSocketからも同じ権限チェックを呼ぶ |
実用例は少なくとも3つあります。1つ目は、Claude Codeが長いタスクを実行している間に「解析中」「テスト中」「差分確認中」を画面へ流すライブ進捗です。2つ目は、チームのレビュー画面でコメントや既読状態を即時反映する共同作業です。3つ目は、運用ダッシュボードで失敗ジョブや問い合わせ着信をroom単位で配る通知です。研修やハンズオンなら、受講者ごとの演習ステータスを講師画面へ集める用途にも使えます。
全体像
flowchart LR
Browser["Browser client"] -->|"auth message + join room"| Server["Node ws server"]
Server --> Validator["JSON schema validator"]
Server --> Rooms["room registry"]
Server --> Audit["audit log / database"]
Rooms -->|"broadcast"| Browser
Server -->|"ping"| Browser
Browser -->|"auto pong / reconnect"| Server
Claude["Claude Code review loop"] --> Server
ここでいうバックプレッシャーは、受け手が処理しきれず送信キューが膨らむ状態です。MDNもWebSocket APIには自動的なバックプレッシャー制御がないと説明しているため、bufferedAmountを見て遅い接続を閉じる判断が必要です。
Claude Codeへの最初の依頼
最初のプロンプトでは、機能名だけでなく壊れ方も渡します。
Node.jsのwsでWebSocketリアルタイム基盤を作ってください。
要件:
- ブラウザは接続後にauthメッセージを送り、認証前のjoin/chatは禁止
- roomIdごとにjoinし、同じroomだけへbroadcast
- JSONメッセージはtypeごとに検証し、不正なら1008でclose
- heartbeatはserver pingとpong監視で実装
- slow consumerはbufferedAmountを見て1013でclose
- ブラウザclientは指数バックオフとjitterで再接続
- secretsをURL query stringに入れない
- WebSocketは監査ログの代替にしない
Claude Codeの一般的な進め方はClaude Code common workflowsに整理されています。生成させるだけでなく、変更後に「失敗ケースを先にレビューして」と頼むのが実務向きです。
コピペで動くNode wsサーバー
ローカルで試す場合は次の手順です。
npm init -y
npm install ws
node server.mjs
server.mjsを作ります。本番ではDEV_TOKENを固定値にせず、短命チケット、セッションCookie、またはAPI gateway側の認証結果に置き換えてください。
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { WebSocket, WebSocketServer } from "ws";
const PORT = Number(process.env.PORT || 8080);
const DEV_TOKEN = process.env.DEV_TOKEN || "dev-token";
const AUTH_TIMEOUT_MS = 5000;
const HEARTBEAT_INTERVAL_MS = 30000;
const MAX_BUFFERED_BYTES = 1024 * 1024;
const rooms = new Map();
const server = createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("WebSocket server is running\n");
});
const wss = new WebSocketServer({ server });
function isRoomId(value) {
return typeof value === "string" && /^[a-zA-Z0-9:_-]{1,64}$/.test(value);
}
function validateClientMessage(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, error: "message must be an object" };
}
if (typeof value.type !== "string") {
return { ok: false, error: "type is required" };
}
if (value.type === "auth") {
if (typeof value.token !== "string" || value.token.length < 8) {
return { ok: false, error: "auth.token is invalid" };
}
return { ok: true, value: { type: "auth", token: value.token } };
}
if (value.type === "join") {
if (!isRoomId(value.roomId)) return { ok: false, error: "roomId is invalid" };
return { ok: true, value: { type: "join", roomId: value.roomId } };
}
if (value.type === "chat") {
if (!isRoomId(value.roomId)) return { ok: false, error: "roomId is invalid" };
if (typeof value.text !== "string" || value.text.length < 1 || value.text.length > 1000) {
return { ok: false, error: "text must be 1-1000 chars" };
}
return {
ok: true,
value: {
type: "chat",
roomId: value.roomId,
requestId: typeof value.requestId === "string" ? value.requestId.slice(0, 100) : randomUUID(),
text: value.text,
},
};
}
if (value.type === "ping") return { ok: true, value: { type: "ping" } };
return { ok: false, error: `unsupported type: ${value.type}` };
}
function decodeMessage(raw) {
if (raw.length > 8192) return { ok: false, error: "message too large" };
try {
return validateClientMessage(JSON.parse(raw.toString("utf8")));
} catch {
return { ok: false, error: "invalid JSON" };
}
}
function sendJson(ws, payload) {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
ws.close(1013, "slow consumer");
return;
}
ws.send(JSON.stringify(payload));
}
function joinRoom(ws, roomId) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.rooms.add(roomId);
}
function leaveAllRooms(ws) {
for (const roomId of ws.rooms) {
const members = rooms.get(roomId);
if (!members) continue;
members.delete(ws);
if (members.size === 0) rooms.delete(roomId);
}
ws.rooms.clear();
}
function broadcast(roomId, payload) {
const members = rooms.get(roomId);
if (!members) return;
for (const client of members) sendJson(client, payload);
}
wss.on("connection", (ws) => {
ws.id = randomUUID();
ws.user = null;
ws.rooms = new Set();
ws.isAlive = true;
const authTimer = setTimeout(() => {
if (!ws.user) ws.close(4401, "auth required");
}, AUTH_TIMEOUT_MS);
ws.on("pong", () => {
ws.isAlive = true;
});
ws.on("message", (raw) => {
const decoded = decodeMessage(raw);
if (!decoded.ok) {
ws.close(1008, decoded.error);
return;
}
const msg = decoded.value;
if (!ws.user) {
if (msg.type !== "auth" || msg.token !== DEV_TOKEN) {
ws.close(4403, "auth failed");
return;
}
ws.user = { id: `user-${ws.id.slice(0, 8)}` };
clearTimeout(authTimer);
sendJson(ws, { type: "auth_ok", connectionId: ws.id, userId: ws.user.id });
return;
}
if (msg.type === "join") {
joinRoom(ws, msg.roomId);
sendJson(ws, { type: "joined", roomId: msg.roomId });
return;
}
if (msg.type === "chat") {
if (!ws.rooms.has(msg.roomId)) {
ws.close(1008, "join room before chat");
return;
}
const event = {
type: "chat",
id: randomUUID(),
requestId: msg.requestId,
roomId: msg.roomId,
userId: ws.user.id,
text: msg.text,
sentAt: new Date().toISOString(),
};
console.log(JSON.stringify({ audit: true, event }));
broadcast(msg.roomId, event);
return;
}
if (msg.type === "ping") sendJson(ws, { type: "pong", sentAt: new Date().toISOString() });
});
ws.on("close", () => {
clearTimeout(authTimer);
leaveAllRooms(ws);
});
});
setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
leaveAllRooms(ws);
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL_MS).unref();
server.listen(PORT, () => {
console.log(`WebSocket server listening on ws://localhost:${PORT}`);
});
ブラウザクライアント:再接続とbackoff
ブラウザのWebSocketコンストラクタでは任意のAuthorizationヘッダーを付けられません。したがって、本番ではHTTPSで短命チケットを発行してから、wss接続直後のauthメッセージで渡します。URLのquery stringにsecretを入れると、アクセスログや分析ツールに残りやすいので避けます。
<!doctype html>
<html lang="ja">
<body>
<form id="form">
<input id="text" autocomplete="off" placeholder="message" />
<button>send</button>
</form>
<pre id="log"></pre>
<script>
const roomId = "room:demo";
const token = "dev-token";
const log = document.querySelector("#log");
const form = document.querySelector("#form");
const input = document.querySelector("#text");
let socket;
let attempt = 0;
let stopped = false;
function write(line) {
log.textContent += `${line}\n`;
}
function reconnectDelay() {
const base = Math.min(1000 * 2 ** attempt, 10000);
attempt += 1;
return base + Math.floor(Math.random() * 300);
}
function send(payload) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
if (socket.bufferedAmount > 512 * 1024) {
write("send skipped: browser buffer is high");
return false;
}
socket.send(JSON.stringify(payload));
return true;
}
function connect() {
socket = new WebSocket("ws://localhost:8080");
write("connecting");
socket.addEventListener("open", () => {
attempt = 0;
send({ type: "auth", token });
send({ type: "join", roomId });
});
socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
write(`${msg.type}: ${JSON.stringify(msg)}`);
});
socket.addEventListener("close", (event) => {
write(`closed code=${event.code} reason=${event.reason}`);
if (!stopped) window.setTimeout(connect, reconnectDelay());
});
socket.addEventListener("error", () => {
write("socket error");
});
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
send({ type: "chat", roomId, requestId: crypto.randomUUID(), text });
input.value = "";
});
window.addEventListener("beforeunload", () => {
stopped = true;
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000, "page unload");
});
connect();
</script>
</body>
</html>
JSONメッセージ検証を分けてレビューする
Claude Codeには、サーバー全体より先にメッセージスキーマをレビューさせます。スキーマが曖昧だと、roomをまたいだ送信や巨大ペイロードを見逃しやすいからです。
export function validateClientMessage(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, error: "message must be an object" };
}
if (value.type === "join" && /^[a-zA-Z0-9:_-]{1,64}$/.test(value.roomId)) {
return { ok: true, value: { type: "join", roomId: value.roomId } };
}
if (value.type === "chat" && /^[a-zA-Z0-9:_-]{1,64}$/.test(value.roomId)) {
const text = typeof value.text === "string" ? value.text.trim() : "";
if (text.length >= 1 && text.length <= 1000) {
return { ok: true, value: { type: "chat", roomId: value.roomId, text } };
}
}
return { ok: false, error: "invalid realtime message" };
}
本格運用ではZodやValibotなどの構造化バリデータを使うと、型定義と検証を近づけられます。ただし、ライブラリを入れても「認証前のjoinは禁止」「参加していないroomへのchatは禁止」という状態依存のルールは別途必要です。
heartbeatとslow consumer対策
heartbeatは「接続が生きているか」を見る仕組みです。wsサーバーではpingを送り、pongが戻らない接続を切ります。ブラウザはプロトコルレベルのpongを自動で返すため、ブラウザJavaScriptからpingフレームを送る必要はありません。
setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30000);
slow consumerは、接続は残っているのに受信側が処理しきれない利用者です。放置するとメモリを食います。送信前にbufferedAmountを見て、一定以上なら1013で閉じます。1013は「後でもう一度試してほしい」という意味合いで使いやすいコードです。
テストクライアント
ブラウザを開かずに2つ目の参加者を作るには、Nodeのテストクライアントを使います。
import { WebSocket } from "ws";
const ws = new WebSocket("ws://localhost:8080");
const roomId = "room:demo";
ws.on("open", () => {
ws.send(JSON.stringify({ type: "auth", token: "dev-token" }));
ws.send(JSON.stringify({ type: "join", roomId }));
setInterval(() => {
ws.send(JSON.stringify({
type: "chat",
roomId,
requestId: crypto.randomUUID(),
text: `hello ${new Date().toISOString()}`,
}));
}, 3000);
});
ws.on("message", (data) => {
console.log(data.toString());
});
node test-client.mjsを実行し、ブラウザ側にメッセージが流れればroom配信は動いています。サーバーを止めて戻したときにブラウザが再接続するかも確認します。
具体的な落とし穴
- secretを
ws://example.com?token=...に入れる。プロキシ、アクセスログ、スクリーンショットに残りやすいです。 messageイベントでJSON.parseだけして検証しない。巨大メッセージ、未知のtype、room偽装に弱くなります。readyStateを見ずにsendする。接続中や切断後に例外やロストが起きます。- heartbeatを入れない。モバイル回線やスリープ復帰後に、死んだ接続を生きていると誤認します。
- WebSocket配信を監査ログ代わりにする。配信は届かないことがあるため、重要イベントはDBやログ基盤へ別に保存します。
- Claude Codeに「チャットを作って」とだけ頼む。認証、再接続、backpressure、close code、テスト観点が抜けやすくなります。
Claude Codeで安全にレビューするプロンプト
実装後は、次のプロンプトで批判的にレビューさせます。harnessはエージェントの足場、つまりClaude Codeが安全に作業するための前提や制約のことです。
このWebSocket実装を公開前レビューしてください。
対象:
- server.mjs
- browser client
- message schema validator
観点:
1. 認証前にjoin/chatできないか
2. secretsがURL、ログ、エラー文に漏れないか
3. roomId偽装で別roomへ送れないか
4. reconnect/backoffが接続嵐を起こさないか
5. heartbeatで死んだ接続を掃除できるか
6. bufferedAmountが大きいslow consumerを扱えるか
7. WebSocketとは別に監査ログが残るか
出力:
- P0/P1/P2の順で問題を列挙
- 再現手順
- 最小修正案
- 追加すべきテスト
関連するセキュリティ観点はClaude Codeセキュリティベストプラクティス、レビュー運用はコードレビューChecklist、HTTP側のイベント連携はWebhook実装ガイドも合わせて読むと設計がつながります。
研修・テンプレート・相談で固める
個人の検証ならこの記事のコードで十分です。チーム導入では、CLAUDE.md、レビュー観点、close codeの方針、監査ログ、障害時の切り戻しを一緒に決める必要があります。ClaudeCodeLabでは、実務導入向けのClaude Code研修・相談と、プロンプトやCLAUDE.mdを整えるテンプレート導線を用意しています。リアルタイム機能を売上や運用改善につなげたい場合は、相談で既存リポジトリに合わせて設計を固めるのが早いです。
まとめ
WebSocketは、チャットのような派手な機能だけでなく、Claude Codeの進捗表示、レビュー画面、運用通知にも使えます。ただし、接続できることと安全に運用できることは別です。認証ハンドシェイク、room routing、メッセージ検証、再接続、heartbeat、backpressure、監査ログを1つのセットとして扱ってください。
この記事で紹介した内容を実際にローカルで試した結果、auth前のchatは4403または1008で閉じられ、サーバー再起動後もブラウザは指数バックオフで再接続しました。一方で、bufferedAmountのしきい値やclose codeの運用文言はチームごとに調整が必要でした。Masaの実感としては、Claude Codeには実装生成だけでなく「遅い利用者、切断、偽装room、監査漏れを疑うレビュー役」を任せると品質が一段上がります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。