Web Socket #

WebSocket adalah protokol komunikasi yang dibangun di atas TCP dan dimulai dengan HTTP upgrade handshake — klien mengirim request HTTP biasa, server merespons dengan “101 Switching Protocols”, dan setelah itu koneksi dialihfungsikan menjadi saluran full-duplex persisten. Berbeda dari artikel Socket sebelumnya yang membahas TCP socket tingkat rendah dan Socket.io, artikel ini fokus pada WebSocket murni menggunakan library ws di Node.js — pilihan yang tepat saat kamu butuh WebSocket tanpa overhead Socket.io, misalnya untuk API gateway, komunikasi antar microservice, atau aplikasi yang perlu protokol WebSocket standar tanpa fallback HTTP long-polling. TypeScript membuat WebSocket lebih aman dengan discriminated union untuk tipe pesan dan type guard yang memvalidasi data masuk sebelum diproses.

WebSocket vs HTTP — Kapan Memilih WebSocket #

flowchart TD
    A{Pola Komunikasi?} --> B[Server push sesekali\nnotifikasi, alert]
    A --> C[Client request\nserver response]
    A --> D[Full-duplex persisten\nbolak-balik terus-menerus]
    A --> E[Server streaming\nsatu arah berkelanjutan]

    B --> F[Server-Sent Events\nlebih sederhana]
    C --> G[HTTP REST/GraphQL\nsudah cukup]
    D --> H[WebSocket\npilihan tepat]
    E --> I[SSE atau HTTP streaming\nlebih sederhana]

    style F fill:#fcc419,color:#000
    style G fill:#51cf66,color:#fff
    style H fill:#339af0,color:#fff
    style I fill:#fcc419,color:#000

Setup Proyek #

npm install ws
npm install --save-dev @types/ws typescript ts-node
// tsconfig.json — konfigurasi minimal untuk project WebSocket
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Tipe Pesan — Fondasi Type Safety #

Langkah pertama sebelum menulis server atau client adalah mendefinisikan semua tipe pesan yang mungkin terjadi. File ini diimpor oleh keduanya:

// src/types/ws-messages.ts

// Pesan dari Client ke Server
export type PesanMasuk =
  | { tipe: "AUTH"; token: string }
  | { tipe: "LANGGANAN"; topik: string }
  | { tipe: "BATAL_LANGGANAN"; topik: string }
  | { tipe: "KIRIM"; topik: string; isi: unknown }
  | { tipe: "PING" };

// Pesan dari Server ke Client
export type PesanKeluar =
  | { tipe: "AUTH_OK"; sessionId: string }
  | { tipe: "AUTH_GAGAL"; alasan: string }
  | { tipe: "PESAN"; topik: string; dari: string; isi: unknown; waktu: string }
  | { tipe: "ERROR"; kode: string; pesan: string }
  | { tipe: "PONG" }
  | { tipe: "DITUTUP"; alasan: string };

// Status koneksi client yang disimpan server
export interface StatusKoneksi {
  sessionId: string;
  penggunaId: string | null;
  terautentikasi: boolean;
  topikDilanggani: Set<string>;
  terhubungPada: Date;
  lastPingPada: Date;
}

// Type guard — validasi data dari jaringan sebelum diproses
export function isPesanMasuk(data: unknown): data is PesanMasuk {
  if (typeof data !== "object" || data === null) return false;
  const d = data as Record<string, unknown>;
  if (typeof d.tipe !== "string") return false;

  switch (d.tipe) {
    case "AUTH":
      return typeof d.token === "string";
    case "LANGGANAN":
    case "BATAL_LANGGANAN":
      return typeof d.topik === "string" && d.topik.length > 0;
    case "KIRIM":
      return typeof d.topik === "string" && "isi" in d;
    case "PING":
      return true;
    default:
      return false;
  }
}

WebSocket Server dengan ws #

// src/server/ws-server.ts
import { WebSocketServer, WebSocket, RawData } from "ws";
import { IncomingMessage } from "http";
import { parse as parseUrl } from "url";
import type { PesanMasuk, PesanKeluar, StatusKoneksi } from "../types/ws-messages";
import { isPesanMasuk } from "../types/ws-messages";

// Perluas WebSocket dengan metadata tambahan
interface WebSocketDenganMeta extends WebSocket {
  status: StatusKoneksi;
  isAlive: boolean; // Untuk heartbeat ping-pong
}

class WebSocketServerApp {
  private wss: WebSocketServer;
  private koneksi = new Map<string, WebSocketDenganMeta>();
  private intervalHeartbeat: NodeJS.Timer | undefined;

  constructor(private readonly port: number) {
    this.wss = new WebSocketServer({
      port,
      // Verifikasi client saat handshake — tolak sebelum koneksi terbentuk
      verifyClient: this.verifikasiClient.bind(this),
    });

    this.wss.on("connection", this.tanganiKoneksiBaru.bind(this));
    this.wss.on("error", (err) => console.error("[WS Server] Error:", err));

    this.mulaiHeartbeat();
    console.log(`[WS Server] Berjalan di ws://localhost:${port}`);
  }

  // Verifikasi saat HTTP upgrade — bisa cek Origin, rate limit, dll
  private verifikasiClient(
    info: { origin: string; req: IncomingMessage; secure: boolean },
    callback: (result: boolean, code?: number, message?: string) => void
  ): void {
    const allowedOrigins = [
      "http://localhost:3000",
      "https://muslimapps.id",
    ];

    if (!allowedOrigins.includes(info.origin)) {
      callback(false, 403, "Origin tidak diizinkan");
      return;
    }

    callback(true);
  }

  private tanganiKoneksiBaru(ws: WebSocket, req: IncomingMessage): void {
    const wsMeta = ws as WebSocketDenganMeta;
    const sessionId = crypto.randomUUID();
    const { query } = parseUrl(req.url ?? "", true);

    wsMeta.status = {
      sessionId,
      penggunaId: null,
      terautentikasi: false,
      topikDilanggani: new Set(),
      terhubungPada: new Date(),
      lastPingPada: new Date(),
    };
    wsMeta.isAlive = true;

    this.koneksi.set(sessionId, wsMeta);
    console.log(`[WS Server] Klien terhubung: ${sessionId} (${req.socket.remoteAddress})`);

    // Tangani pesan masuk
    wsMeta.on("message", (data: RawData) => {
      this.prosesPesanMasuk(wsMeta, data);
    });

    // Heartbeat — respons terhadap pong
    wsMeta.on("pong", () => {
      wsMeta.isAlive = true;
      wsMeta.status.lastPingPada = new Date();
    });

    wsMeta.on("close", (code, reason) => {
      console.log(
        `[WS Server] Klien terputus: ${sessionId} (kode: ${code}, alasan: ${reason.toString()})`
      );
      this.koneksi.delete(sessionId);
    });

    wsMeta.on("error", (err) => {
      console.error(`[WS Server] Error pada ${sessionId}:`, err.message);
      this.koneksi.delete(sessionId);
    });
  }

  private prosesPesanMasuk(ws: WebSocketDenganMeta, rawData: RawData): void {
    // Parse dan validasi
    let data: unknown;
    try {
      data = JSON.parse(rawData.toString());
    } catch {
      this.kirim(ws, { tipe: "ERROR", kode: "PARSE_ERROR", pesan: "Data bukan JSON yang valid" });
      return;
    }

    if (!isPesanMasuk(data)) {
      this.kirim(ws, { tipe: "ERROR", kode: "INVALID_MESSAGE", pesan: "Format pesan tidak dikenali" });
      return;
    }

    // Routing berdasarkan tipe pesan
    switch (data.tipe) {
      case "AUTH":
        this.tanganiAuth(ws, data.token);
        break;

      case "LANGGANAN":
        if (!ws.status.terautentikasi) {
          this.kirim(ws, { tipe: "ERROR", kode: "UNAUTH", pesan: "Autentikasi diperlukan" });
          return;
        }
        ws.status.topikDilanggani.add(data.topik);
        console.log(`[WS Server] ${ws.status.sessionId} berlangganan: ${data.topik}`);
        break;

      case "KIRIM":
        if (!ws.status.terautentikasi) {
          this.kirim(ws, { tipe: "ERROR", kode: "UNAUTH", pesan: "Autentikasi diperlukan" });
          return;
        }
        this.siarkanKeTopic(data.topik, ws.status.penggunaId!, data.isi);
        break;

      case "PING":
        this.kirim(ws, { tipe: "PONG" });
        break;

      case "BATAL_LANGGANAN":
        ws.status.topikDilanggani.delete(data.topik);
        break;
    }
  }

  private tanganiAuth(ws: WebSocketDenganMeta, token: string): void {
    // Simulasi verifikasi token
    try {
      const payload = verifikasiJWT(token);
      ws.status.penggunaId = payload.sub;
      ws.status.terautentikasi = true;
      this.kirim(ws, { tipe: "AUTH_OK", sessionId: ws.status.sessionId });
      console.log(`[WS Server] Autentikasi berhasil: ${payload.sub}`);
    } catch {
      this.kirim(ws, { tipe: "AUTH_GAGAL", alasan: "Token tidak valid atau kedaluwarsa" });
      ws.close(1008, "Autentikasi gagal"); // 1008 = Policy Violation
    }
  }

  // Siaran ke semua subscriber topik tertentu
  private siarkanKeTopic(topik: string, dariPenggunaId: string, isi: unknown): void {
    const pesan: PesanKeluar = {
      tipe: "PESAN",
      topik,
      dari: dariPenggunaId,
      isi,
      waktu: new Date().toISOString(),
    };

    let jumlahPenerima = 0;
    for (const koneksi of this.koneksi.values()) {
      if (
        koneksi.status.topikDilanggani.has(topik) &&
        koneksi.readyState === WebSocket.OPEN
      ) {
        this.kirim(koneksi, pesan);
        jumlahPenerima++;
      }
    }

    console.log(`[WS Server] Siaran ke topik "${topik}": ${jumlahPenerima} penerima`);
  }

  // Kirim pesan ke satu client dengan type safety
  private kirim(ws: WebSocket, pesan: PesanKeluar): void {
    if (ws.readyState !== WebSocket.OPEN) return;
    ws.send(JSON.stringify(pesan));
  }

  // Heartbeat: ping semua client setiap 30 detik, putuskan yang tidak merespons
  private mulaiHeartbeat(): void {
    this.intervalHeartbeat = setInterval(() => {
      for (const [sessionId, ws] of this.koneksi) {
        if (!ws.isAlive) {
          console.log(`[WS Server] Heartbeat gagal, memutus: ${sessionId}`);
          ws.terminate(); // Paksa tutup tanpa handshake close
          this.koneksi.delete(sessionId);
          continue;
        }
        ws.isAlive = false;
        ws.ping(); // Kirim ping, tunggu pong
      }
    }, 30_000);
  }

  // Siaran ke SEMUA client yang terhubung
  siarkanSemua(pesan: PesanKeluar): void {
    for (const ws of this.koneksi.values()) {
      if (ws.readyState === WebSocket.OPEN) {
        this.kirim(ws, pesan);
      }
    }
  }

  get statistik() {
    return {
      totalKoneksi: this.koneksi.size,
      terautentikasi: [...this.koneksi.values()].filter((ws) => ws.status.terautentikasi).length,
    };
  }

  berhenti(): void {
    clearInterval(this.intervalHeartbeat as unknown as number);
    for (const ws of this.koneksi.values()) {
      ws.close(1001, "Server ditutup"); // 1001 = Going Away
    }
    this.wss.close();
  }
}

// Mock JWT verifier
function verifikasiJWT(token: string): { sub: string } {
  if (token.startsWith("valid-")) return { sub: token.replace("valid-", "usr-") };
  throw new Error("Token tidak valid");
}

// Jalankan server
const server = new WebSocketServerApp(8080);

// Graceful shutdown
process.on("SIGTERM", () => server.berhenti());
process.on("SIGINT", () => server.berhenti());

WebSocket Client di Browser #

// src/client/ws-client.ts — berjalan di browser
import type { PesanKeluar, PesanMasuk } from "../types/ws-messages";

class WebSocketClient {
  private ws: WebSocket | null = null;
  private reconnectDelay = 1000;
  private readonly maxDelay = 30_000;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private harus_terhubung = true;

  constructor(
    private readonly url: string,
    private readonly token: string,
    private readonly onPesan?: (pesan: PesanKeluar) => void
  ) {}

  hubungkan(): void {
    if (this.ws?.readyState === WebSocket.OPEN) return;

    this.ws = new WebSocket(this.url);

    this.ws.addEventListener("open", () => {
      console.log("[WSClient] Terhubung");
      this.reconnectDelay = 1000; // Reset backoff

      // Autentikasi segera setelah terhubung
      this.kirim({ tipe: "AUTH", token: this.token });
    });

    this.ws.addEventListener("message", (event: MessageEvent<string>) => {
      try {
        const pesan = JSON.parse(event.data) as PesanKeluar;
        this.tanganiPesanMasuk(pesan);
        this.onPesan?.(pesan);
      } catch {
        console.error("[WSClient] Gagal parse pesan:", event.data);
      }
    });

    this.ws.addEventListener("close", (event) => {
      console.log(`[WSClient] Terputus (kode: ${event.code}, alasan: ${event.reason})`);

      // Jangan reconnect untuk kode tertentu (auth gagal, dll)
      const tidakReconnect = [1008, 1003];
      if (this.harus_terhubung && !tidakReconnect.includes(event.code)) {
        this.jadwalkanReconnect();
      }
    });

    this.ws.addEventListener("error", () => {
      console.error("[WSClient] Error koneksi");
    });
  }

  private tanganiPesanMasuk(pesan: PesanKeluar): void {
    switch (pesan.tipe) {
      case "AUTH_OK":
        console.log(`[WSClient] Autentikasi berhasil, session: ${pesan.sessionId}`);
        break;
      case "AUTH_GAGAL":
        console.error(`[WSClient] Autentikasi gagal: ${pesan.alasan}`);
        this.harus_terhubung = false; // Jangan retry jika auth gagal
        break;
      case "PONG":
        // Server merespons ping kita
        break;
      case "ERROR":
        console.error(`[WSClient] Error dari server [${pesan.kode}]: ${pesan.pesan}`);
        break;
    }
  }

  private jadwalkanReconnect(): void {
    if (this.reconnectTimer) return;

    console.log(`[WSClient] Reconnect dalam ${this.reconnectDelay}ms...`);

    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      this.hubungkan();
    }, this.reconnectDelay);

    // Backoff eksponensial dengan jitter
    this.reconnectDelay = Math.min(
      this.reconnectDelay * 2 + Math.random() * 1000,
      this.maxDelay
    );
  }

  kirim(pesan: PesanMasuk): void {
    if (this.ws?.readyState !== WebSocket.OPEN) {
      console.warn("[WSClient] Tidak terhubung, pesan dibuang");
      return;
    }
    this.ws.send(JSON.stringify(pesan));
  }

  langganan(topik: string): void {
    this.kirim({ tipe: "LANGGANAN", topik });
  }

  batalLangganan(topik: string): void {
    this.kirim({ tipe: "BATAL_LANGGANAN", topik });
  }

  kirimPesan(topik: string, isi: unknown): void {
    this.kirim({ tipe: "KIRIM", topik, isi });
  }

  putuskan(): void {
    this.harus_terhubung = false;
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
    }
    this.ws?.close(1000, "Ditutup oleh pengguna");
  }

  get statusKoneksi(): string {
    switch (this.ws?.readyState) {
      case WebSocket.CONNECTING: return "menghubungkan";
      case WebSocket.OPEN: return "terhubung";
      case WebSocket.CLOSING: return "menutup";
      case WebSocket.CLOSED: return "terputus";
      default: return "tidak diinisialisasi";
    }
  }
}

// Penggunaan di aplikasi web
const klien = new WebSocketClient(
  "wss://api.muslimapps.id/ws",
  "valid-user-token",
  (pesan) => {
    if (pesan.tipe === "PESAN") {
      console.log(`[${pesan.topik}] ${pesan.dari}: ${JSON.stringify(pesan.isi)}`);
    }
  }
);

klien.hubungkan();
klien.langganan("jadwal-sholat");
klien.kirimPesan("jadwal-sholat", { kota: "Jakarta" });

Server-Sent Events — Alternatif untuk Push Satu Arah #

Jika hanya membutuhkan server → client push (tidak perlu client → server), SSE jauh lebih sederhana dari WebSocket:

// src/server/sse-server.ts — menggunakan Express
import express from "express";

const app = express();

app.get("/notifikasi", (req, res) => {
  // Set header SSE
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no"); // Penting untuk Nginx

  // Kirim event pertama
  res.write("data: {\"tipe\":\"TERHUBUNG\"}\n\n");

  // Kirim update secara berkala
  const interval = setInterval(() => {
    const data = {
      tipe: "UPDATE",
      waktu: new Date().toISOString(),
      pesan: "Data terbaru dari server",
    };
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }, 5000);

  // Bersihkan saat client disconnect
  req.on("close", () => {
    clearInterval(interval);
    console.log("SSE client terputus");
  });
});

// Client SSE (browser)
// const source = new EventSource("/notifikasi");
// source.onmessage = (event) => {
//   const data = JSON.parse(event.data);
//   console.log(data);
// };

Kode Status Penutupan WebSocket #

KodeNamaPenggunaan
1000Normal ClosureKoneksi ditutup secara normal oleh salah satu pihak
1001Going AwayServer shutting down atau halaman browser ditutup
1002Protocol ErrorViolation protokol WebSocket
1003Unsupported DataData yang diterima tidak bisa diproses
1006Abnormal ClosureKoneksi terputus tanpa handshake close
1007Invalid Frame PayloadData tidak konsisten (mis. teks bukan UTF-8)
1008Policy ViolationServer menolak karena kebijakan (autentikasi gagal, dll)
1009Message Too BigPesan melebihi batas ukuran yang diizinkan
1011Server ErrorServer mengalami kondisi error yang tidak terduga
4000-4999ApplicationKode kustom yang bisa digunakan aplikasi

Ringkasan #

  • Definisikan tipe pesan sebagai discriminated union (PesanMasuk, PesanKeluar) dan buat type guard isPesanMasuk() untuk memvalidasi data dari jaringan — data masuk melalui WebSocket bertipe unknown dan harus divalidasi sebelum diproses.
  • Implementasikan heartbeat ping-pong — WebSocket tidak memiliki mekanisme built-in untuk mendeteksi koneksi yang mati secara diam-diam; server harus mengirim ping secara berkala dan memutus koneksi yang tidak merespons dengan pong.
  • Gunakan verifyClient untuk menolak koneksi yang tidak valid saat handshake HTTP upgrade — lebih efisien dari menolak setelah koneksi terbentuk karena tidak ada overhead koneksi yang terbuang.
  • Tangani kode close yang berbeda — kode 1008 (Policy Violation, biasanya auth gagal) tidak boleh di-reconnect otomatis; kode 1001 (server restart) boleh.
  • Backoff eksponensial dengan jitter untuk reconnect — hindari “thundering herd” saat semua klien mencoba reconnect bersamaan setelah server restart; jitter menambahkan variasi acak untuk menyebar request.
  • SSE untuk komunikasi satu arah (server → client only) — lebih sederhana dari WebSocket, berjalan di atas HTTP biasa, otomatis reconnect, dan didukung semua browser modern; pertimbangkan SSE sebelum memilih WebSocket jika tidak butuh komunikasi dua arah.
  • Gunakan kode status yang tepat saat menutup koneksi — 1000 untuk normal, 1001 untuk server shutdown, 1008 untuk policy violation; ini membantu client memutuskan apakah perlu reconnect.
  • Batasi ukuran pesan di server — ws mendukung opsi maxPayload (default 100MB, terlalu besar); set ke nilai yang masuk akal (mis. 64KB) untuk mencegah memory exhaustion akibat pesan yang sangat besar.

← Sebelumnya: Socket   Berikutnya: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact