Web Socket

Web Socket #

WebSocket adalah protokol komunikasi full-duplex di atas satu koneksi TCP persisten — berbeda dari HTTP yang sifatnya request-response, WebSocket memungkinkan server dan client saling mengirim pesan kapan saja tanpa menunggu satu sama lain. Ini adalah teknologi di balik chat real-time, notifikasi instan, live dashboard, collaborative editing, dan game multiplayer berbasis web. PHP bukan bahasa yang dirancang khusus untuk long-lived connection seperti ini, tapi dengan library yang tepat — terutama Ratchet yang dibangun di atas ReactPHP — PHP bisa menjadi WebSocket server yang andal. Artikel ini membahas protokol WebSocket dari level bawah (handshake dan framing), cara membangun server sederhana dari scratch untuk memahami mekanismenya, lalu menggunakan Ratchet untuk implementasi production.

Bagaimana WebSocket Bekerja #

WebSocket dimulai sebagai HTTP request biasa yang kemudian di-“upgrade” menjadi koneksi WebSocket. Setelah upgrade berhasil, koneksi tetap terbuka dan keduanya bisa saling kirim pesan bebas.

sequenceDiagram
    participant Browser as Browser (Client)
    participant Server as PHP WebSocket Server

    Browser->>Server: HTTP GET /ws\nUpgrade: websocket\nConnection: Upgrade\nSec-WebSocket-Key: abc123
    Server->>Browser: HTTP 101 Switching Protocols\nUpgrade: websocket\nSec-WebSocket-Accept: xyz789

    Note over Browser,Server: Koneksi WebSocket terbuka — full duplex

    Browser->>Server: Frame: "Halo Server!"
    Server->>Browser: Frame: "Halo Client!"
    Server->>Browser: Frame: "Update data: {...}" (server push)
    Browser->>Server: Frame: "PING"
    Server->>Browser: Frame: "PONG"

    Browser->>Server: Close Frame
    Server->>Browser: Close Frame
    Note over Browser,Server: Koneksi ditutup

Protokol WebSocket dari Level Bawah #

Sebelum menggunakan library, memahami protokol WebSocket membuat debugging jauh lebih mudah.

Handshake #

<?php
// Klien mengirim request seperti ini:
/*
GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
*/

// Server harus membalas dengan accept key yang benar
function generateAcceptKey(string $clientKey): string
{
    // Magic string dari RFC 6455
    $magic    = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    $combined = $clientKey . $magic;
    $hash     = sha1($combined, binary: true); // SHA1 dalam format biner
    return base64_encode($hash);
}

// Contoh:
// clientKey = "dGhlIHNhbXBsZSBub25jZQ=="
// acceptKey = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

function kirimHandshakeResponse($socket, string $clientKey): void
{
    $acceptKey = generateAcceptKey($clientKey);

    $response = implode("\r\n", [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' . $acceptKey,
        '',
        '',
    ]);

    fwrite($socket, $response);
}

Frame WebSocket #

Setelah handshake, data dikirim dalam format frame WebSocket — bukan teks HTTP biasa:

<?php
/**
 * Format frame WebSocket (disederhanakan):
 *
 * Byte 0:  FIN (1 bit) + RSV (3 bit) + Opcode (4 bit)
 * Byte 1:  Mask (1 bit) + Payload Length (7 bit)
 * Byte 2-3 atau 2-7: Extended payload length (jika perlu)
 * 4 byte masking key (jika dari client — client selalu mask)
 * Payload data (di-XOR dengan masking key jika masked)
 */

// Decode frame yang datang dari client (selalu masked)
function decodeFrame(string $data): ?string
{
    if (strlen($data) < 2) return null;

    $byte0   = ord($data[0]);
    $byte1   = ord($data[1]);

    // $fin     = ($byte0 & 0x80) !== 0; // FIN bit
    $opcode  = $byte0 & 0x0F; // 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong
    $masked  = ($byte1 & 0x80) !== 0;
    $panjang = $byte1 & 0x7F;

    $offset = 2;

    // Extended payload length
    if ($panjang === 126) {
        $panjang = unpack('n', substr($data, $offset, 2))[1];
        $offset += 2;
    } elseif ($panjang === 127) {
        $panjang = unpack('J', substr($data, $offset, 8))[1];
        $offset += 8;
    }

    // Masking key (4 byte) — client selalu mask data
    $maskKey = '';
    if ($masked) {
        $maskKey = substr($data, $offset, 4);
        $offset += 4;
    }

    // Payload
    $payload = substr($data, $offset, $panjang);

    // Unmask payload
    if ($masked && $maskKey !== '') {
        for ($i = 0; $i < strlen($payload); $i++) {
            $payload[$i] = chr(ord($payload[$i]) ^ ord($maskKey[$i % 4]));
        }
    }

    // Tangani opcode
    return match($opcode) {
        0x1 => $payload,      // Text frame
        0x2 => $payload,      // Binary frame
        0x8 => null,          // Close frame
        0x9 => 'PING',        // Ping
        0xA => 'PONG',        // Pong
        default => null,
    };
}

// Encode frame untuk dikirim ke client (server tidak mask)
function encodeFrame(string $payload, int $opcode = 0x1): string
{
    $panjang = strlen($payload);

    // Byte pertama: FIN=1, RSV=0, opcode
    $frame   = chr(0x80 | $opcode);

    // Byte kedua dan seterusnya: payload length (tidak masked dari server)
    if ($panjang < 126) {
        $frame .= chr($panjang);
    } elseif ($panjang < 65536) {
        $frame .= chr(126) . pack('n', $panjang);
    } else {
        $frame .= chr(127) . pack('J', $panjang);
    }

    return $frame . $payload;
}

WebSocket Server dari Scratch #

Menggunakan stream_select dari artikel Socket untuk server multi-client, sekarang ditambahkan WebSocket handshake:

<?php
declare(strict_types=1);

$server  = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
stream_set_blocking($server, false);

$clients    = [];  // [id => socket]
$handshaked = [];  // [id => bool]
$buffer     = [];  // [id => string]

echo "WebSocket Server berjalan di ws://localhost:8080\n";

while (true) {
    $baca    = array_merge([$server], $clients);
    $tulis   = null;
    $kecuali = null;

    stream_select($baca, $tulis, $kecuali, 0, 100_000);

    // Koneksi baru
    if (in_array($server, $baca, true)) {
        $client = stream_socket_accept($server, 0);
        if ($client) {
            $id               = (int) $client;
            $clients[$id]     = $client;
            $handshaked[$id]  = false;
            $buffer[$id]      = '';
            stream_set_blocking($client, false);
            echo "[$id] Koneksi baru\n";
        }
    }

    // Data dari client
    foreach ($clients as $id => $client) {
        if (!in_array($client, $baca, true)) continue;

        $data = fread($client, 65536);

        if ($data === false || $data === '') {
            tutupClient($id, $clients, $handshaked, $buffer);
            continue;
        }

        $buffer[$id] .= $data;

        if (!$handshaked[$id]) {
            // Coba lakukan handshake
            if (str_contains($buffer[$id], "\r\n\r\n")) {
                $key = extractWebSocketKey($buffer[$id]);
                if ($key) {
                    kirimHandshakeResponse($client, $key);
                    $handshaked[$id] = true;
                    $buffer[$id]     = '';
                    echo "[$id] Handshake berhasil\n";

                    // Kirim pesan selamat datang
                    fwrite($client, encodeFrame(json_encode([
                        'type' => 'welcome',
                        'id'   => $id,
                    ])));
                } else {
                    tutupClient($id, $clients, $handshaked, $buffer);
                }
            }
        } else {
            // Proses frame WebSocket
            while (strlen($buffer[$id]) >= 2) {
                $pesan = decodeFrame($buffer[$id]);

                if ($pesan === null) {
                    tutupClient($id, $clients, $handshaked, $buffer);
                    break;
                }

                // Hitung panjang frame yang sudah diproses
                // (implementasi sederhana — asumsikan frame sudah lengkap)
                $buffer[$id] = '';

                echo "[$id] Pesan: $pesan\n";

                // Broadcast ke semua client yang sudah handshake
                $payload = json_encode(['dari' => $id, 'pesan' => $pesan]);
                foreach ($clients as $otherId => $other) {
                    if ($handshaked[$otherId]) {
                        fwrite($other, encodeFrame($payload));
                    }
                }
            }
        }
    }
}

function extractWebSocketKey(string $request): ?string
{
    if (preg_match('/Sec-WebSocket-Key:\s*(.+)\r\n/i', $request, $m)) {
        return trim($m[1]);
    }
    return null;
}

function tutupClient(int $id, array &$clients, array &$handshaked, array &$buffer): void
{
    fclose($clients[$id]);
    unset($clients[$id], $handshaked[$id], $buffer[$id]);
    echo "[$id] Koneksi ditutup\n";
}

Ratchet — WebSocket Server untuk Production #

Menulis WebSocket server dari scratch bagus untuk belajar, tapi untuk production gunakan Ratchet — library WebSocket PHP yang dibangun di atas ReactPHP, sudah menangani semua detail protokol, ping/pong, dan koneksi yang putus.

composer require cboden/ratchet

Antarmuka MessageComponentInterface #

Ratchet menggunakan antarmuka MessageComponentInterface yang mendefinisikan empat event:

<?php
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

interface MessageComponentInterface
{
    // Koneksi baru terbuka
    public function onOpen(ConnectionInterface $conn): void;

    // Pesan diterima dari client
    public function onMessage(ConnectionInterface $from, string $msg): void;

    // Koneksi ditutup (normal atau error)
    public function onClose(ConnectionInterface $conn): void;

    // Error pada koneksi
    public function onError(ConnectionInterface $conn, \Exception $e): void;
}

Implementasi Chat Room Sederhana #

<?php
// src/ChatRoom.php
namespace App;

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class ChatRoom implements MessageComponentInterface
{
    // SplObjectStorage — menyimpan daftar koneksi aktif
    private \SplObjectStorage $clients;
    private array $usernames = []; // [resourceId => username]

    public function __construct()
    {
        $this->clients = new \SplObjectStorage();
        echo "ChatRoom siap\n";
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        $connId = $conn->resourceId;

        echo "Koneksi baru: #{$connId} ({$this->clients->count()} total)\n";

        // Kirim daftar user yang sudah ada
        $conn->send(json_encode([
            'type'  => 'info',
            'pesan' => "Selamat datang! Ada " . ($this->clients->count() - 1) . " user lain.",
        ]));
    }

    public function onMessage(ConnectionInterface $from, string $msg): void
    {
        $data = json_decode($msg, true);

        if (!is_array($data) || !isset($data['type'])) {
            $from->send(json_encode(['type' => 'error', 'pesan' => 'Format pesan tidak valid']));
            return;
        }

        match($data['type']) {
            'join'    => $this->tanganiJoin($from, $data),
            'message' => $this->tanganiPesan($from, $data),
            'typing'  => $this->siarkanTyping($from),
            default   => $from->send(json_encode(['type' => 'error', 'pesan' => 'Tipe tidak dikenal'])),
        };
    }

    private function tanganiJoin(ConnectionInterface $conn, array $data): void
    {
        $username = htmlspecialchars(trim($data['username'] ?? 'Anonim'), ENT_QUOTES, 'UTF-8');
        $this->usernames[$conn->resourceId] = $username;

        echo "#{$conn->resourceId} bergabung sebagai '$username'\n";

        // Beritahu semua user bahwa ada yang bergabung
        $this->siarkan(json_encode([
            'type'     => 'join',
            'username' => $username,
            'users'    => array_values($this->usernames),
        ]), kecuali: $conn);

        // Konfirmasi ke user yang bergabung
        $conn->send(json_encode([
            'type'     => 'joined',
            'username' => $username,
            'users'    => array_values($this->usernames),
        ]));
    }

    private function tanganiPesan(ConnectionInterface $from, array $data): void
    {
        $username = $this->usernames[$from->resourceId] ?? 'Anonim';
        $isi      = htmlspecialchars(trim($data['isi'] ?? ''), ENT_QUOTES, 'UTF-8');

        if (empty($isi)) return;

        echo "$username: $isi\n";

        $payload = json_encode([
            'type'      => 'message',
            'username'  => $username,
            'isi'       => $isi,
            'timestamp' => time(),
        ]);

        // Kirim ke semua client termasuk pengirim
        $this->siarkan($payload);
    }

    private function siarkanTyping(ConnectionInterface $from): void
    {
        $username = $this->usernames[$from->resourceId] ?? 'Anonim';
        $this->siarkan(json_encode([
            'type'     => 'typing',
            'username' => $username,
        ]), kecuali: $from);
    }

    private function siarkan(string $pesan, ?ConnectionInterface $kecuali = null): void
    {
        foreach ($this->clients as $client) {
            if ($client !== $kecuali) {
                $client->send($pesan);
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $username = $this->usernames[$conn->resourceId] ?? 'Anonim';
        $this->clients->detach($conn);
        unset($this->usernames[$conn->resourceId]);

        echo "#{$conn->resourceId} ($username) meninggalkan chat\n";

        $this->siarkan(json_encode([
            'type'     => 'leave',
            'username' => $username,
            'users'    => array_values($this->usernames),
        ]));
    }

    public function onError(ConnectionInterface $conn, \Exception $e): void
    {
        echo "Error pada #{$conn->resourceId}: {$e->getMessage()}\n";
        $conn->close();
    }
}

Menjalankan Server Ratchet #

<?php
// server.php
require __DIR__ . '/vendor/autoload.php';

use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use App\ChatRoom;

$server = IoServer::factory(
    new HttpServer(
        new WsServer(
            new ChatRoom()
        )
    ),
    port: 8080,
);

echo "WebSocket server berjalan di ws://localhost:8080\n";
$server->run();
# Jalankan server di terminal
php server.php

# Untuk production, jalankan dengan supervisor
# /etc/supervisor/conf.d/websocket.conf
# [program:php-websocket]
# command=php /var/www/app/server.php
# autostart=true
# autorestart=true

Client JavaScript untuk Terhubung ke Server #

<!DOCTYPE html>
<html>
<head><title>Chat WebSocket</title></head>
<body>
<div id="pesan"></div>
<input id="input" type="text" placeholder="Ketik pesan...">
<button onclick="kirim()">Kirim</button>

<script>
// Buat koneksi WebSocket
const ws = new WebSocket('ws://localhost:8080');

ws.onopen = () => {
    console.log('Terhubung ke server');
    // Bergabung dengan nama
    ws.send(JSON.stringify({
        type: 'join',
        username: 'Budi'
    }));
};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    const div  = document.getElementById('pesan');

    switch(data.type) {
        case 'message':
            div.innerHTML += `<p><b>${data.username}:</b> ${data.isi}</p>`;
            break;
        case 'join':
            div.innerHTML += `<p><i>${data.username} bergabung</i></p>`;
            break;
        case 'leave':
            div.innerHTML += `<p><i>${data.username} keluar</i></p>`;
            break;
        case 'typing':
            document.title = `${data.username} mengetik...`;
            setTimeout(() => document.title = 'Chat', 2000);
            break;
    }
};

ws.onclose = () => console.log('Terputus dari server');
ws.onerror = (e) => console.error('Error:', e);

function kirim() {
    const input = document.getElementById('input');
    if (input.value.trim()) {
        ws.send(JSON.stringify({
            type: 'message',
            isi:  input.value.trim()
        }));
        input.value = '';
    }
}

// Kirim sinyal typing saat user mengetik
document.getElementById('input').addEventListener('keypress', () => {
    ws.send(JSON.stringify({ type: 'typing' }));
});
</script>
</body>
</html>

Autentikasi Koneksi WebSocket #

WebSocket handshake adalah HTTP request biasa — kamu bisa memeriksa cookie, token, atau parameter query string sebelum mengizinkan koneksi:

<?php
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServerInterface;
use Psr\Http\Message\RequestInterface;

class AuthenticatedChat implements HttpServerInterface
{
    private ChatRoom $chat;

    public function __construct(ChatRoom $chat)
    {
        $this->chat = $chat;
    }

    // Dipanggil saat HTTP request masuk (sebelum upgrade ke WebSocket)
    public function onOpen(ConnectionInterface $conn, RequestInterface $request = null): void
    {
        // Ambil token dari query string: ws://localhost:8080/?token=xxx
        $query = $request->getUri()->getQuery();
        parse_str($query, $params);
        $token = $params['token'] ?? '';

        // Validasi token
        $user = $this->validasiToken($token);

        if ($user === null) {
            // Tolak koneksi — kirim 401 dan tutup
            $conn->send("HTTP/1.1 401 Unauthorized\r\n\r\n");
            $conn->close();
            echo "Koneksi ditolak — token tidak valid\n";
            return;
        }

        // Simpan info user di koneksi
        $conn->user = $user;
        $this->chat->onOpen($conn);
    }

    private function validasiToken(string $token): ?array
    {
        if (empty($token)) return null;

        // Di sini lakukan validasi JWT atau lookup ke database/Redis
        // Contoh sederhana:
        $data = base64_decode($token);
        $user = json_decode($data, true);

        return is_array($user) && isset($user['id']) ? $user : null;
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        $this->chat->onMessage($from, $msg);
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $this->chat->onClose($conn);
    }

    public function onError(ConnectionInterface $conn, \Exception $e): void
    {
        $this->chat->onError($conn, $e);
    }
}

Broadcast Bertarget — Room dan Channel #

Daripada broadcast ke semua client, seringkali perlu mengirim ke subset — misalnya dalam aplikasi dengan banyak room chat:

<?php
class RoomManager implements MessageComponentInterface
{
    private \SplObjectStorage $clients;
    private array $rooms = []; // [roomId => [connId => conn]]

    public function __construct()
    {
        $this->clients = new \SplObjectStorage();
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        $conn->rooms = []; // track room yang diikuti koneksi ini
    }

    public function onMessage(ConnectionInterface $from, string $msg): void
    {
        $data = json_decode($msg, true);

        match($data['type'] ?? '') {
            'join_room'   => $this->bergabungRoom($from, $data['room_id']),
            'leave_room'  => $this->tinggalkanRoom($from, $data['room_id']),
            'send_room'   => $this->kirimKeRoom($from, $data['room_id'], $data['pesan']),
            'broadcast'   => $this->siarkanGlobal($data['pesan']),
            default       => null,
        };
    }

    private function bergabungRoom(ConnectionInterface $conn, string $roomId): void
    {
        $this->rooms[$roomId][$conn->resourceId] = $conn;
        $conn->rooms[]                            = $roomId;

        $this->kirimKeRoom(null, $roomId, [
            'event'     => 'user_joined',
            'room_id'   => $roomId,
            'count'     => count($this->rooms[$roomId]),
        ]);
    }

    private function tinggalkanRoom(ConnectionInterface $conn, string $roomId): void
    {
        unset($this->rooms[$roomId][$conn->resourceId]);
        $conn->rooms = array_filter($conn->rooms, fn($r) => $r !== $roomId);

        if (empty($this->rooms[$roomId])) {
            unset($this->rooms[$roomId]);
        }
    }

    private function kirimKeRoom(?ConnectionInterface $pengirim, string $roomId, mixed $pesan): void
    {
        if (!isset($this->rooms[$roomId])) return;

        $payload = is_string($pesan) ? $pesan : json_encode($pesan);

        foreach ($this->rooms[$roomId] as $connId => $conn) {
            if ($conn !== $pengirim) {
                $conn->send($payload);
            }
        }
    }

    private function siarkanGlobal(mixed $pesan): void
    {
        $payload = is_string($pesan) ? $pesan : json_encode($pesan);
        foreach ($this->clients as $client) {
            $client->send($payload);
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        // Keluarkan dari semua room
        foreach ($conn->rooms as $roomId) {
            $this->tinggalkanRoom($conn, $roomId);
        }
        $this->clients->detach($conn);
    }

    public function onError(ConnectionInterface $conn, \Exception $e): void
    {
        echo "Error: {$e->getMessage()}\n";
        $conn->close();
    }
}

Kapan WebSocket dan Kapan Alternatifnya #

WebSocket bukan selalu jawaban terbaik. Beberapa alternatif yang sering lebih tepat:

KebutuhanSolusi Terbaik
Notifikasi push dari serverServer-Sent Events (SSE) — lebih sederhana, satu arah
Update data periodikPolling biasa atau Long Polling
Chat, game, kolaborasi real-timeWebSocket
Live dashboard dengan update sesekaliSSE atau polling tiap 5 detik
File upload progressSSE atau XHR progress event
Ribuan koneksi bersamaanWebSocket + ReactPHP atau Node.js
Gunakan WebSocket jika:
  ✓ Perlu komunikasi dua arah (client dan server keduanya inisiasi pesan)
  ✓ Data dikirim sering dan cepat (> 1 kali per detik)
  ✓ Latency rendah sangat penting (game, trading)
  ✓ Server perlu push data tanpa diminta client

Hindari WebSocket jika:
  ✗ Hanya perlu server push satu arah → gunakan SSE
  ✗ Update jarang (tiap menit) → gunakan polling biasa
  ✗ Hanya satu request-response → HTTP biasa jauh lebih sederhana
  ✗ Infrastruktur tidak mendukung long-lived connection (shared hosting)

Ringkasan #

  • WebSocket dimulai sebagai HTTP yang di-upgrade — handshake menggunakan Sec-WebSocket-Key yang di-hash dengan magic string RFC 6455 untuk menghasilkan Sec-WebSocket-Accept.
  • Frame WebSocket memiliki format biner khusus — FIN bit, opcode, masking, dan payload length. Client selalu mask data ke server; server tidak mask data ke client.
  • Ratchet adalah library WebSocket PHP terbaik untuk production — dibangun di atas ReactPHP event loop, menangani semua detail protokol. Implementasikan MessageComponentInterface dengan empat method: onOpen, onMessage, onClose, onError.
  • SplObjectStorage adalah struktur data yang tepat untuk menyimpan daftar koneksi aktif — iterasi efisien, mudah attach/detach.
  • Autentikasi dilakukan saat HTTP upgrade request — periksa token dari query string atau cookie sebelum koneksi diterima. Tolak dengan menutup koneksi jika tidak valid.
  • Room dan channel memungkinkan broadcast bertarget ke subset client — simpan mapping roomId => [connId => conn] dan iterasi hanya koneksi dalam room yang relevan.
  • Server-Sent Events (SSE) adalah alternatif yang lebih sederhana untuk push dari server ke client satu arah — tidak butuh library khusus, cukup HTTP dengan Content-Type: text/event-stream.
  • Jalankan server WebSocket sebagai daemon dengan Supervisor untuk auto-restart saat crash. Gunakan Nginx sebagai reverse proxy yang meneruskan koneksi WebSocket ke server PHP.

← Sebelumnya: Socket   Berikutnya: Web Server →

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