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:#000Setup 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 #
| Kode | Nama | Penggunaan |
|---|---|---|
| 1000 | Normal Closure | Koneksi ditutup secara normal oleh salah satu pihak |
| 1001 | Going Away | Server shutting down atau halaman browser ditutup |
| 1002 | Protocol Error | Violation protokol WebSocket |
| 1003 | Unsupported Data | Data yang diterima tidak bisa diproses |
| 1006 | Abnormal Closure | Koneksi terputus tanpa handshake close |
| 1007 | Invalid Frame Payload | Data tidak konsisten (mis. teks bukan UTF-8) |
| 1008 | Policy Violation | Server menolak karena kebijakan (autentikasi gagal, dll) |
| 1009 | Message Too Big | Pesan melebihi batas ukuran yang diizinkan |
| 1011 | Server Error | Server mengalami kondisi error yang tidak terduga |
| 4000-4999 | Application | Kode kustom yang bisa digunakan aplikasi |
Ringkasan #
- Definisikan tipe pesan sebagai discriminated union (
PesanMasuk,PesanKeluar) dan buat type guardisPesanMasuk()untuk memvalidasi data dari jaringan — data masuk melalui WebSocket bertipeunknowndan 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
pingsecara berkala dan memutus koneksi yang tidak merespons denganpong.- Gunakan
verifyClientuntuk 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 —
1000untuk normal,1001untuk server shutdown,1008untuk policy violation; ini membantu client memutuskan apakah perlu reconnect.- Batasi ukuran pesan di server —
wsmendukung opsimaxPayload(default 100MB, terlalu besar); set ke nilai yang masuk akal (mis. 64KB) untuk mencegah memory exhaustion akibat pesan yang sangat besar.