WebSocket realtime dengan Claude Code: auth, reconnect, heartbeat, dan rooms
Bangun WebSocket realtime yang aman dengan Claude Code: auth, reconnect, heartbeat, rooms, validasi, dan review.
WebSocket cocok saat layar harus berubah begitu server mengetahui sesuatu: job Claude Code masuk tahap test, komentar review baru muncul, pengguna bergabung ke room, atau alert operasional harus tampil segera. Claude Code bisa membuat draft awal dengan cepat, tetapi fitur realtime yang siap produksi membutuhkan lebih dari sekadar onmessage yang berjalan.
Bagian yang sering rusak adalah authentication handshake, reconnect storm, koneksi mobile yang mati diam-diam, JSON tidak valid, slow consumer, kebocoran antar-room, dan audit log yang tidak ada. Artikel ini memberi contoh Node.js ws, client browser dengan backoff, validator schema pesan, heartbeat, test client, dan prompt review untuk Claude Code.
Untuk dasar API, buka MDN WebSocket, readyState, dan close. Untuk disiplin kerja Claude Code, lihat Claude Code common workflows.
Kapan WebSocket Tepat Dipakai
WebSocket adalah channel dua arah yang hidup lama. Ia tidak menggantikan HTTP API, queue, penulisan database, billing flow, atau audit log. Pakai WebSocket untuk koordinasi cepat; event bisnis penting tetap harus disimpan lewat jalur server yang durable.
| Pilihan | Cocok untuk | Hindari untuk | Instruksi ke Claude Code |
|---|---|---|---|
| WebSocket | Chat, UI kolaboratif, progress live, broadcast per room | Notifikasi jarang, riwayat yang harus dicari | Sertakan auth, reconnect, heartbeat, backpressure |
| Server-Sent Events | Update satu arah dari server ke browser | Aksi browser yang sering ke server | Desain event ID dan event type |
| Polling | Admin panel yang refresh tiap 15-60 detik | UX latensi rendah | Tambahkan cache dan rate limit |
| HTTP API | Save, payment, audit, admin changes | Typing indicator atau presence tinggi | Pakai ulang authorization check yang sama |
Use case praktisnya minimal tiga. Pertama, live progress untuk task Claude Code yang lama: analyzing, testing, checking diff. Kedua, review room tempat komentar dan read state muncul langsung. Ketiga, dashboard operasional yang mengirim alert per customer atau team. Untuk training, instruktur juga bisa melihat status latihan setiap peserta secara realtime.
Arsitektur
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
Backpressure berarti penerima tidak mampu memproses cukup cepat sehingga buffer pengirim membesar. MDN mencatat bahwa WebSocket API klasik tidak otomatis menerapkan backpressure, jadi implementasi perlu memantau bufferedAmount.
Masukkan Failure Mode ke Prompt
Jangan hanya meminta “buat chat realtime”. Berikan batasan sejak awal.
Create a Node.js ws realtime WebSocket foundation.
Requirements:
- Browser sends an auth message after connect; join/chat are forbidden before auth
- Clients join by roomId and broadcasts only go to the same room
- Validate JSON messages by type and close invalid messages with 1008
- Implement heartbeat with server ping and pong tracking
- Close slow consumers with 1013 when bufferedAmount is too high
- Browser client uses exponential backoff with jitter
- Do not put secrets in URL query strings
- Do not treat WebSocket delivery as an audit log replacement
Server Node ws yang Bisa Dijalankan
Jalankan lokal:
npm init -y
npm install ws
node server.mjs
Buat server.mjs. Di production, ganti DEV_TOKEN dengan short-lived ticket, session cookie, atau identity yang sudah diverifikasi 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}`);
});
Client Browser dengan Reconnect dan Backoff
Constructor WebSocket di browser tidak bisa menambahkan header Authorization sembarang. Untuk aplikasi browser, ambil short-lived ticket melalui HTTPS, lalu kirim sebagai message auth pertama di atas wss. Jangan simpan secret di query string.
<!doctype html>
<html lang="id">
<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>
Review Schema Pesan Secara Terpisah
Minta Claude Code meninjau schema sebelum seluruh server. Kontrak yang kabur membuat payload besar, type tidak dikenal, dan roomId palsu mudah lolos.
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" };
}
Heartbeat dan Slow Consumer
Heartbeat memeriksa apakah koneksi masih hidup. Dengan ws, server mengirim ping dan browser menjawab pong otomatis pada level protokol.
setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30000);
Slow consumer tetap tersambung tetapi tidak memproses pesan cukup cepat. Sebelum mengirim, cek bufferedAmount; jika terlalu tinggi, tutup dengan 1013 agar client mencoba lagi nanti.
Test Client
Gunakan ini sebagai peserta kedua tanpa membuka browser lain.
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());
});
Jalankan node test-client.mjs, restart server, lalu pastikan browser reconnect dengan backoff.
Kesalahan Umum
- Menaruh secret di
ws://example.com?token=..., sehingga bocor ke log dan screenshot. - Hanya memakai
JSON.parsetanpa memvalidasi type, ukuran, room, dan panjang teks. - Mengirim tanpa mengecek
readyState. - Tidak ada heartbeat, sehingga koneksi mobile yang mati tetap dianggap hidup.
- Menganggap broadcast WebSocket sebagai audit log. Event penting tetap perlu storage yang durable.
- Meminta Claude Code “buat chat” tanpa menyebut auth, reconnect, backpressure, close code, dan test.
Prompt Review untuk Claude Code
Review this WebSocket implementation before release.
Scope:
- server.mjs
- browser client
- message schema validator
Check:
1. Can a client join/chat before auth?
2. Can secrets leak into URLs, logs, or error messages?
3. Can a client spoof roomId and send to another room?
4. Can reconnect/backoff create a connection storm?
5. Does heartbeat remove dead connections?
6. Are slow consumers handled with bufferedAmount?
7. Is there an audit log outside WebSocket delivery?
Return:
- P0/P1/P2 findings first
- Reproduction steps
- Minimal patch
- Missing tests
Untuk topik terkait, baca Claude Code security best practices, code review checklist, dan webhook implementation.
Training, Template, dan Konsultasi
Contoh ini cukup untuk PoC lokal. Untuk tim, Anda juga perlu CLAUDE.md, aturan review, kebijakan close code, penyimpanan audit, dan rollback. ClaudeCodeLab menyediakan training and consultation serta templates and product guides untuk membawa realtime automation ke workflow nyata.
Kesimpulan
WebSocket cocok untuk progress stream Claude Code, review room, alat kolaboratif, dan alert operasional. Namun koneksi yang berhasil bukan berarti aman. Auth handshake, room routing, validation, reconnect, heartbeat, backpressure, dan audit logging harus dirancang sebagai satu paket.
Setelah implementasi artikel ini dicoba secara lokal, chat sebelum auth ditolak, browser reconnect setelah server restart, dan broadcast tetap berada di room yang benar. Yang masih perlu disesuaikan per tim adalah threshold buffer, wording close code, dan lokasi penyimpanan audit. Pelajaran praktis dari Masa: Claude Code paling berguna ketika diminta meninjau failure mode, bukan hanya membuat happy path.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.