Use Cases (Diperbarui: 3/6/2026)

Membuat Chat WebSocket dengan Claude Code

Panduan chat WebSocket dengan Claude Code: autentikasi, reconnect, rate limit, dan kode Node.js yang bisa dijalankan.

Membuat Chat WebSocket dengan Claude Code

Chat WebSocket terlihat sederhana, tetapi langsung menyentuh masalah produksi: koneksi harus diautentikasi, klien harus bisa tersambung ulang setelah jaringan putus, pesan hanya boleh dikirim ke room yang benar, spam harus dibatasi, JSON harus divalidasi, dan perubahan berikutnya harus bisa diuji.

Panduan ini membuat contoh yang bisa disalin dan dijalankan dengan API WebSocket bawaan browser dan paket Node.js ws. WebSocket adalah koneksi panjang dua arah: setelah HTTP upgrade berhasil, browser dan server dapat saling mengirim pesan tanpa membuat request baru untuk setiap event. Untuk rujukan resmi, pakai MDN WebSocket API, Writing WebSocket client applications, RFC 6455, Node.js HTTP upgrade event, ws README, dan OWASP WebSocket testing guide. Untuk alur Claude Code, lihat dokumentasi resmi Claude Code.

Untuk konteks lanjutan, baca juga pengembangan API dengan Claude Code, code review dengan Claude Code, dan praktik keamanan Claude Code.

Tiga kasus penggunaan nyata

Pertama, chat kecil untuk komunitas atau dukungan pelanggan. Data awalnya hanya ruang, nama, teks, dan riwayat pendek. Tanpa autentikasi dan batas kirim, satu skrip bisa membanjiri server.

Kedua, dashboard real-time. Status build, pembayaran, stok, atau antrean kerja bisa dikirim langsung ke browser. Tantangannya adalah menentukan event lama mana yang boleh dibuang, karena API WebSocket browser tidak menyediakan backpressure otomatis yang kuat.

Ketiga, ruang tanya jawab setelah artikel, kursus, atau materi berbayar. Pembaca dapat berpindah dari membaca ke bertanya, lalu diarahkan ke template, produk, atau konsultasi. Ini bagus untuk monetisasi, tetapi aturan log, moderasi, dan data pribadi harus jelas.

KasusMengapa WebSocket cocokKeputusan awal
Chat kecilPesan dua arah berlatensi rendahAutentikasi, ruang, riwayat
Dashboard liveServer mendorong updatePembuangan event, reconnect
Dukungan belajarPercakapan mengikuti artikelLog, moderasi, CTA

Arsitektur

Fan-out berarti membagikan satu pesan masuk ke semua koneksi di room yang sama. Heartbeat berarti memeriksa secara berkala apakah koneksi panjang masih hidup.

flowchart LR
  BrowserA["Tab browser A"] -->|ws:// /chat| Node["Node.js server"]
  BrowserB["Tab browser 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"]

Prompt untuk Claude Code

Buat chat WebSocket minimal dengan Node.js 20+ dan paket ws.

Syarat:
- Hanya memakai server.js, index.html, smoke-client.mjs, dan package.json
- http://localhost:8080 harus menampilkan UI chat
- WebSocket upgrade di /chat harus memvalidasi token dan Origin
- Pesan dikirim berdasarkan room
- Simpan hanya 50 pesan terbaru di memori
- Batasi tiap koneksi menjadi 20 pesan per 10 detik
- Batasi teks pesan menjadi 500 karakter
- Klien browser reconnect dengan exponential backoff setelah close
- Periksa readyState dan bufferedAmount sebelum mengirim
- smoke-client.mjs harus connect, mengirim satu pesan, dan keluar sukses

Jangan:
- Mengganti ini dengan Socket.IO
- Menerima koneksi tanpa autentikasi
- Memakai JSON tanpa validasi
- Menyebut riwayat memori sebagai persistensi produksi

Menjalankan lokal

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

Ganti package.json:

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

server.js

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

index.html

<!doctype html>
<html lang="id">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Demo Chat WebSocket</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>Demo Chat WebSocket</h1>
    <p id="status" class="muted">offline</p>

    <label>Ruang <input id="room" value="lobby" /></label>
    <label>Nama <input id="name" value="masa" /></label>
    <label>Token <input id="token" value="dev-token" /></label>
    <button id="connect">Hubungkan</button>
    <button id="disconnect">Putuskan</button>

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

    <form id="form">
      <label>Pesan <input id="message" autocomplete="off" maxlength="500" /></label>
      <button type="submit">Kirim</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("Socket belum siap; pesan masuk antrean.", "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("menghubungkan");

        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(`tertutup ${event.code}`);
          if (!manuallyClosed) scheduleReconnect();
        });

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

      function scheduleReconnect() {
        const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
        reconnectAttempt += 1;
        setStatus(`reconnect dalam ${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(`${data.messages.length} pesan sebelumnya dimuat.`, "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

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

Kesalahan umum

KesalahanDampakPerbaikan
Tidak autentikasi saat upgradeSocket tidak tepercaya aktifTolak sebelum handleUpgrade
Tidak cek OriginRisiko koneksi dari situs lainGunakan daftar Origin yang diizinkan
Tidak ada rate limitSatu klien bisa menghabiskan CPU dan memoriBatasi per koneksi, user, IP
Riwayat memori tanpa batasProses makin beratBatasi jumlah, usia, dan penyimpanan
Reconnect langsungLonjakan traffic saat pemulihanExponential backoff dan jitter
Fan-out satu prosesServer lain tidak menerima pesanRedis Pub/Sub, NATS, atau antrean

Produksi dan monetisasi

Gunakan wss:// jika ada akun, pembayaran, dukungan pelanggan, atau data pribadi. Otorisasi harus dicek saat koneksi, masuk room, mengirim, menghapus, dan moderasi. Riwayat perlu pindah ke database; beberapa proses Node perlu Pub/Sub bersama.

Untuk belajar sendiri, mulai dari cheatsheet Claude Code gratis. Untuk prompt dan materi setup yang dapat dipakai ulang, lihat produk ClaudeCodeLab. Tim yang perlu menyusun autentikasi, audit log, Pub/Sub, CI, dan aturan review di repositori nyata dapat mulai dari training dan konsultasi Claude Code.

Hasil uji Masa: setelah menjalankan server.js, dua tab di http://localhost:8080 menerima pesan room lobby secara langsung. npm run smoke berhasil mengirim dan menerima hello from smoke test. Origin yang diubah menghasilkan 403, dan token yang salah menghasilkan 401. Menulis reconnect, rate limit, validasi Origin, dan batas 50 pesan sejak prompt awal membuat output Claude Code jauh lebih mudah direview.

#Claude Code #WebSocket #chat #waktu nyata #Node.js
Gratis

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.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.