Skip to content

WebSocket

Ray Fung edited this page Feb 26, 2026 · 3 revisions

WebSocket

The Razy\WebSocket namespace provides a full RFC 6455 WebSocket implementation — an event-driven Server, a connecting Client, and the low-level Frame and Connection building blocks. All communication uses native PHP stream sockets with no external dependencies.

Quick Start — Server

use Razy\WebSocket\Server;
use Razy\WebSocket\Connection;
use Razy\WebSocket\Frame;

$server = new Server('0.0.0.0', 8080);

$server
    ->onOpen(fn(Connection $conn) => echo "Connected: {$conn->getId()}\n")
    ->onMessage(function (Connection $conn, Frame $frame) use ($server) {
        echo "Received: {$frame->getPayload()}\n";
        // Echo back
        $conn->sendText('echo: ' . $frame->getPayload());
        // Or broadcast to all
        $server->broadcast($frame->getPayload(), exclude: $conn);
    })
    ->onClose(fn(Connection $conn, int $code, string $reason) =>
        echo "Disconnected ({$code}): {$reason}\n"
    )
    ->onError(fn(Connection $conn, \Throwable $e) =>
        echo "Error: {$e->getMessage()}\n"
    );

$server->start();

Quick Start — Client

use Razy\WebSocket\Client;

$client = Client::connect('ws://localhost:8080/chat');
$client->sendText('Hello, server!');

$frame = $client->receiveBlocking(10);
echo $frame->getPayload(); // "echo: Hello, server!"

$client->close();

Classes Overview

Class Purpose
Frame RFC 6455 frame encode/decode — opcodes, masking, payload length encoding
Connection Stream wrapper — handshake, frame I/O, ping/pong, close protocol
Server Event-driven TCP server with stream_select loop
Client Connects to a remote server, performs handshake, send/receive

Frame

Represents a single WebSocket frame per RFC 6455 §5.2.

Constants

Constant Value Description
OPCODE_CONTINUATION 0x0 Continuation frame
OPCODE_TEXT 0x1 Text frame (UTF-8)
OPCODE_BINARY 0x2 Binary frame
OPCODE_CLOSE 0x8 Connection close
OPCODE_PING 0x9 Ping frame
OPCODE_PONG 0xA Pong frame

Constructor

new Frame(int $opcode, string $payload = '', bool $fin = true, bool $masked = false)

Factory Methods

Method Description
Frame::text(string $payload, bool $mask = false) Create a text frame
Frame::binary(string $payload, bool $mask = false) Create a binary frame
Frame::ping(string $payload = '') Create a ping frame
Frame::pong(string $payload = '') Create a pong frame
Frame::close(int $code = 1000, string $reason = '') Create a close frame

Instance Methods

Method Returns Description
getOpcode() int Frame opcode
getOpcodeName() string Human-readable opcode name
getPayload() string Raw payload data
getPayloadLength() int Payload byte length
isFin() bool True if this is the final fragment
isMasked() bool True if payload is masked
isControl() bool True for close, ping, pong
isText() bool Opcode is TEXT
isBinary() bool Opcode is BINARY
isClose() bool Opcode is CLOSE
isPing() bool Opcode is PING
isPong() bool Opcode is PONG
getCloseCode() ?int 2-byte status code from close frame
getCloseReason() string Reason string from close frame
encode(bool $mask = false) string Encode to binary wire format

Static Methods

Method Returns Description
Frame::decode(string $buffer) ?array{Frame, int} Decode one frame from buffer; returns [frame, bytesConsumed] or null if incomplete
Frame::applyMask(string $data, string $maskKey) string XOR-mask/unmask per RFC 6455 §5.3

Encoding & Decoding

use Razy\WebSocket\Frame;

// Encode a text frame (unmasked, for server → client)
$frame = Frame::text('Hello');
$binary = $frame->encode(mask: false);

// Decode a frame from raw bytes
$result = Frame::decode($binary);
if ($result !== null) {
    [$decoded, $bytesConsumed] = $result;
    echo $decoded->getPayload(); // "Hello"
}

Connection

Wraps a PHP stream resource and provides bidirectional WebSocket communication — handshake, frame-level read/write, automatic ping/pong, and the close protocol.

Constructor

new Connection(resource $stream, bool $maskOutput = false)
  • $maskOutput — set to true for client connections (RFC 6455 §5.3 requires clients to mask outgoing frames)

Handshake Methods

Method Returns Description
performServerHandshake() bool Read the client's HTTP Upgrade request, send 101 response
completeClientHandshake(string $expectedAccept) bool Validate the server's 101 response
isHandshakeCompleted() bool Whether the opening handshake is done

Send Methods

Method Description
sendText(string $text) Send a UTF-8 text frame
sendBinary(string $data) Send a binary frame
sendPing(string $payload = '') Send a ping frame
sendPong(string $payload = '') Send a pong frame
sendClose(int $code = 1000, string $reason = '') Send a close frame
sendFrame(Frame $frame) Send any raw Frame

Receive Methods

Method Returns Description
readFrame() ?Frame Read one frame; auto-replies ping with pong, echoes close
readFrames() Frame[] Read all available frames

State & Metadata

Method Returns Description
getId() string Unique connection ID
getStream() resource Underlying stream
isOpen() bool Stream is open and close not sent
isCloseComplete() bool Both sides have exchanged close frames
getRequestUri() ?string URI from the opening GET request
getHeaders() array HTTP headers from the handshake
getHeader(string $name) ?string Single header value (case-insensitive)
getUserData() mixed Retrieve custom user data
setUserData(mixed $data) void Attach custom user data
disconnect() void Close the stream

Server

A single-process, event-driven WebSocket server using stream_socket_server + stream_select.

Constructor

new Server(string $host = '0.0.0.0', int $port = 8080, int $timeout = 200000)
  • $timeoutstream_select timeout in microseconds (default 200 ms)

Event Callbacks

All callbacks return $this for fluent chaining:

Method Callback Signature When Called
onOpen(Closure $cb) fn(Connection): void After a client completes the handshake
onMessage(Closure $cb) fn(Connection, Frame): void On each text or binary data frame
onClose(Closure $cb) fn(Connection, int, string): void When a connection is closed (code + reason)
onError(Closure $cb) fn(Connection, Throwable): void On exception during frame processing
onTick(Closure $cb) fn(Server): void Once per event-loop iteration

Lifecycle

Method Description
start() Bind the socket and enter the event loop
stop() Signal the loop to exit after the current iteration

Utility

Method Returns Description
getConnections() array<string, Connection> Active connections keyed by ID
broadcast(string $text, ?Connection $exclude = null) void Send text to all connected clients

Example — Chat Server

use Razy\WebSocket\Server;
use Razy\WebSocket\Connection;
use Razy\WebSocket\Frame;

$server = new Server('0.0.0.0', 9000);

$server
    ->onOpen(function (Connection $conn) use ($server) {
        $server->broadcast("User {$conn->getId()} joined", $conn);
    })
    ->onMessage(function (Connection $conn, Frame $frame) use ($server) {
        $msg = $conn->getId() . ': ' . $frame->getPayload();
        $server->broadcast($msg);
    })
    ->onClose(function (Connection $conn, int $code, string $reason) use ($server) {
        $server->broadcast("User {$conn->getId()} left");
    });

$server->start();

Example — Periodic Heartbeat

$server->onTick(function (Server $srv) {
    static $lastPing = 0;
    $now = time();
    if ($now - $lastPing >= 30) {
        foreach ($srv->getConnections() as $conn) {
            $conn->sendPing();
        }
        $lastPing = $now;
    }
});

Client

Connects to a remote WebSocket server, performs the opening handshake, and provides send/receive methods. Supports both ws:// and wss:// (TLS).

Factory

$client = Client::connect(string $url, array $headers = [], int $timeout = 5): Client
  • $url — Full WebSocket URL (ws://host:port/path or wss://...)
  • $headers — Extra HTTP headers as ['Header-Name' => 'value']
  • $timeout — Connection timeout in seconds

Methods

Method Returns Description
sendText(string $text) void Send a UTF-8 text message
sendBinary(string $data) void Send a binary message
sendPing(string $payload = '') void Send a ping frame
receive() ?Frame Non-blocking receive (returns null if no data)
receiveBlocking(int $timeoutSeconds = 30) ?Frame Block until a frame arrives or timeout
close(int $code = 1000, string $reason = '') void Send close frame, read response, disconnect
listen(Closure $onMessage, int $pollMs = 50) void Run a receive loop until connection closes
isOpen() bool Whether the connection is still open
getConnection() Connection Access the underlying Connection

Example — Receive Loop

use Razy\WebSocket\Client;
use Razy\WebSocket\Frame;

$client = Client::connect('ws://localhost:8080/feed');

$client->listen(function (Frame $frame) {
    echo "Got: {$frame->getPayload()}\n";

    // Return false to stop listening
    if ($frame->getPayload() === 'bye') {
        return false;
    }
});

Example — Custom Headers & TLS

$client = Client::connect('wss://secure.example.com/ws', [
    'Authorization' => 'Bearer my-token',
    'X-Custom'      => 'value',
], timeout: 10);

$client->sendText(json_encode(['action' => 'subscribe', 'channel' => 'updates']));

$frame = $client->receiveBlocking(30);
echo $frame->getPayload();

$client->close();

Running via CLI

WebSocket servers are long-running processes and cannot run inside a normal HTTP request. Use one of the approaches below to start a server from the command line.

Standalone Script

Create a PHP file (e.g. ws-server.php) and run it directly:

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

use Razy\WebSocket\Server;
use Razy\WebSocket\Connection;
use Razy\WebSocket\Frame;

$host = $argv[1] ?? '0.0.0.0';
$port = (int) ($argv[2] ?? 8080);

$server = new Server($host, $port);

$server
    ->onOpen(fn(Connection $conn) => echo "[open] {$conn->getId()}\n")
    ->onMessage(function (Connection $conn, Frame $frame) use ($server) {
        $server->broadcast($frame->getPayload(), $conn);
    })
    ->onClose(fn(Connection $conn, int $code, string $reason) =>
        echo "[close] {$conn->getId()} ({$code})\n"
    );

echo "WebSocket server listening on ws://{$host}:{$port}\n";
$server->start();
# Start on port 8080 (default)
php ws-server.php

# Custom bind address and port
php ws-server.php 127.0.0.1 9000

Module Script (Razy CLI)

Register a CLI script inside a Razy module so it can be launched via runapp:

1. Register the script in your controller's __onInit:

public function __onInit(Agent $agent): bool
{
    $agent->addScript('ws:start', 'script/ws_start');
    return true;
}

2. Create the handler (controller/script/{module_code}.ws_start.php):

<?php
use Razy\WebSocket\Server;
use Razy\WebSocket\Connection;
use Razy\WebSocket\Frame;

return function (mixed ...$args): void {
    $port = (int) ($args[0] ?? 8080);
    $server = new Server('0.0.0.0', $port);

    $server
        ->onOpen(fn(Connection $conn) => echo "Connected: {$conn->getId()}\n")
        ->onMessage(function (Connection $conn, Frame $frame) use ($server) {
            $server->broadcast($frame->getPayload(), $conn);
        })
        ->onClose(fn(Connection $conn) => echo "Disconnected: {$conn->getId()}\n");

    echo "WebSocket server listening on port {$port}\n";
    $server->start();
};

3. Launch via runapp:

# Start the interactive shell
php Razy.phar runapp main

# Inside the shell, run the script
> run /ws:start

Running in the Background

For production, run the server as a background process or under a process manager:

# Background with nohup (Linux/macOS)
nohup php ws-server.php 0.0.0.0 8080 > ws.log 2>&1 &

# Supervisor (supervisord.conf)
[program:ws-server]
command=php /var/www/myproject/ws-server.php 0.0.0.0 8080
autostart=true
autorestart=true
stderr_logfile=/var/log/ws-server.err.log
stdout_logfile=/var/log/ws-server.out.log

# systemd unit
[Unit]
Description=Razy WebSocket Server
After=network.target

[Service]
ExecStart=/usr/bin/php /var/www/myproject/ws-server.php 0.0.0.0 8080
Restart=always
User=www-data

[Install]
WantedBy=multi-user.target

Common Mistakes

Mistake Why It Fails Fix
Forgetting to mask client frames RFC 6455 requires client → server masking; server will reject Use Client::connect() (auto-masks) or new Connection($stream, maskOutput: true)
Calling readFrame() on a blocking stream Blocks indefinitely if no data Set stream_set_blocking($stream, false) or use receiveBlocking() with a timeout
Not handling ping frames Connection may time out on the remote end Connection::readFrame() auto-replies ping with pong — just keep reading
Ignoring the close handshake Remote side waits for close echo before dropping TCP Connection::readFrame() auto-echoes close; or call sendClose() explicitly
Using Server::broadcast() before handshake completes Sends frames to connections with no WebSocket layer broadcast() already checks isHandshakeCompleted() before sending

Decision Guide

Scenario Use
Real-time push from server only (one-way) SSE (simpler, HTTP-based)
Bidirectional real-time messaging WebSocket Server + Client
Sending HTTP requests to an API HttpClient
Inter-process message passing SimplifiedMessage over pipes or sockets
Background job processing Queue

Flow Diagram

Client                              Server
  │                                    │
  │── TCP connect ──────────────────►  │ stream_socket_accept()
  │                                    │
  │── GET / HTTP/1.1 ──────────────►  │ performServerHandshake()
  │   Upgrade: websocket               │
  │   Sec-WebSocket-Key: ...           │
  │                                    │
  │◄── HTTP/1.1 101 ────────────────  │
  │    Sec-WebSocket-Accept: ...       │
  │                                    │
  │══ WebSocket frames ════════════►  │ readFrame() → onMessage()
  │◄════════════════════════════════  │ sendText() / broadcast()
  │                                    │
  │── Close(1000) ─────────────────►  │ readFrame() auto-echoes close
  │◄── Close(1000) ────────────────  │
  │                                    │
  │── TCP FIN ─────────────────────►  │ removeConnection()

SSE Mailer

Clone this wiki locally