Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.
API Index — Namespaces, Packages, Reports, Indices

WSRouter
in package

FinalYes

Cross-server WebSocket routing helper.

Bundles the "owner of fd does the push; Redis routes by client_id" pattern into a small ergonomic API on top of the existing Store/pub/sub primitives. Requires Store::defaultBackend('redis') (Table backend rejects pub/sub anyway).

Usage (in app.php — before $app->run()):

use ZealPHP\WSRouter;
Store::defaultBackend(Store::BACKEND_REDIS);
WSRouter::init('my-app');                // server id auto-derived from hostname:pid

$app->ws('/ws', function ($server, $frame) { ... });
// In your onOpen:    WSRouter::own($clientId, $request->fd);
// In your onClose:   WSRouter::release($clientId);

// Anywhere: send to a specific client, regardless of which server holds them.
WSRouter::sendToClient($clientId, json_encode($payload));

// Or broadcast to a room channel (every subscribed server fans out locally).
WSRouter::onRoom('chat:42', function (string $payload) use ($localClients) {
    foreach ($localClients as $fd) { $server->push($fd, $payload); }
});
WSRouter::broadcast('chat:42', json_encode($payload));

Implementation:

  • Per-cluster: ws_owner Store table holds (client_id → server_id).
  • Per-server: WSRouter spawns ONE subscriber for ws:server:{ID} on first init() call inside onWorkerStart; messages route into the user-registered handler (default: push to local fd if one is found).
  • Room broadcasts are plain pub/sub on a user-chosen channel.

Lifecycle hooks must be wired before App::run() — same constraint as App::subscribe.

Table of Contents

Constants

CLOSE_AUTH_INVALID  : mixed = 4002
Auth failed: bad token, expired session, etc.
CLOSE_AUTH_REQUIRED  : mixed = 4001
Auth required: client connected but never authenticated.
CLOSE_CAPACITY  : mixed = 4013
Server is over capacity (paired with CapacityException).
CLOSE_FORBIDDEN  : mixed = 4003
Authenticated but lacking permission for this operation.
CLOSE_GOING_AWAY  : mixed = 1001
Server is going down (or client is navigating away).
CLOSE_IDLE  : mixed = 4040
Connection idle / heartbeat missed beyond threshold.
CLOSE_INTERNAL_ERROR  : mixed = 1011
Server hit an internal error processing the request.
CLOSE_MESSAGE_TOO_BIG  : mixed = 1009
Message too big for the receiver to process.
CLOSE_NORMAL  : mixed = 1000
Normal closure (peer is closing as expected).
CLOSE_POLICY_VIOLATION  : mixed = 1008
Peer sent a message that violates server policy.
CLOSE_PROTOCOL_ERROR  : mixed = 1002
Peer sent a malformed frame / protocol violation.
CLOSE_RATE_LIMITED  : mixed = 4029
Client breached the per-client rate limit (WS-4).
CLOSE_TRY_AGAIN_LATER  : mixed = 1013
Server is temporarily overloaded — client should retry later.
CLOSE_UNSUPPORTED  : mixed = 1003
Peer sent a frame of a type the endpoint can't accept.
ROOM_CHANNEL_PREFIX  : mixed = 'ws:room:'
ROOM_TABLE  : mixed = 'ws_room_members'
SERVER_GC_INTERVAL_MS  : mixed = 60000
SERVER_HEARTBEAT_INTERVAL_MS  : mixed = 30000
SERVER_STALE_AFTER_SEC  : mixed = 90
SERVERS_TABLE  : mixed = 'ws_servers'
TABLE  : mixed = 'ws_owner'

Properties

$channelHmacSecret  : string|null
Per-channel HMAC secret (WS-3) — null disables. Set via setChannelHmacSecret.
$clientRateLimitN  : int
Per-client rate limit (WS-4) — 0 disables. Set via setClientRateLimit.
$clientRateLimitWindowSec  : int
$clientSink  : callable(string $clientId, int $fd, string $payload): void|null
Inbound-routed-message handler. Receives ($clientId, $fd, $payload) and is responsible for pushing $payload to the local fd.
$fanoutConcurrency  : int
WS-6 — max simultaneous push coroutines per room broadcast. Bounds the fan-out so a large room + message burst can't spawn an unbounded number of concurrent push coroutines (each push call is non-blocking, but the getClientInfo + dispatch path is not free, and unbounded go() per member is the keystone backpressure gap). Pushes still run concurrently (per-coroutine isolation preserved) — only the SIMULTANEITY is capped via a Coroutine\Channel token semaphore. Configure via setFanoutConcurrency.
$initialized  : bool
true once init() has wired the pub/sub subscribers and lifecycle hooks.
$localClientRooms  : array<string, array<string, true>>
Per-worker reverse index: which rooms each LOCALLY-OWNED client has joined. Lets release() (ws onClose) leave every room a client was in, so an abnormal disconnect doesn't leak ws_room_members rows / per-room SET entries until the whole server is GC'd.
$localFds  : array<string, array{fd: int, conn_id: string}>
Per-worker map of locally-owned WebSocket connections.
$localRoomMembership  : array<string, array<string, string>>
Per-worker cache of locally-owned clients by room.
$ownerCapacity  : int
Max connections per cluster — bump via initOptions() for prod.
$principalByFd  : array<int, string>
#234 — server-derived principal bound to a connection at ownAuthenticated(), keyed by fd. Lets later onMessage handlers recover the authenticated identity (principalForFd($fd)) without re-reading the session, and ties a client_id to the authenticated session rather than a client-supplied string. Reaped in release().
$rateLimitWindows  : array<string, int>
WS-7 — per-worker record of the window id each rate-limit counter is currently serving. A sliding-window limiter rotates buckets every $windowSec; the PRE-WS-7 code baked the bucket id into the counter NAME (..._{hash}_{bucket}), so on the default Atomic backend every elapsed window left a dead named Atomic forever → unbounded per-worker memory growth. WS-7 keeps the counter name STABLE (one per distinct room / client) and reuses it across windows: when this map shows the counter has rolled into a new window, the limiter reset()s it to 0 before the increment. Bounds the live Atomic count to the number of distinct rate-limited rooms + clients, not rooms × elapsed-windows.
$roomAuthorizer  : callable(string, string, string): bool|null
#234 — optional per-room authorizer. `fn(string $action, string $room, string $clientId): bool where $action ∈ {join,leave,push,read}`. When SET, every Room op consults it FAIL-CLOSED (a falsey return refuses); when null (default) the room layer is unguarded as before (BC). Wire it to your session auth — e.g. `WSRouter::roomAuthorizer(fn($a,$r,$c) => App::authChecker() && (App::authChecker())())`.
$roomMembersCapacity  : int
Max (room × member) pairs per cluster — bump via initOptions() for prod.
$roomMessageHandlers  : array<string, array<int, callable>>
User-registered message handlers per room. Each callable receives (array $msg, string $room).
$roomPresenceHandlers  : array<string, array<int, callable>>
User-registered presence (join/leave) handlers per room. Each callable receives (array $event, string $room).
$roomRateLimitN  : int
Per-room rate limit — 0 disables. Set via setRoomRateLimit($n, $windowSec).
$roomRateLimitWindowSec  : int
$serverId  : string
Cluster-unique server identifier — defaults to hostname:pid. Set by init().
$slowConsumerBytes  : int
Slow-consumer threshold. If $server->getClientInfo($fd)['send_queue_bytes'] exceeds this, the framework DROPS the message for that fd instead of letting the kernel/OpenSwoole buffer grow unbounded. Tracked in stats()['pushes_dropped_slow_consumer']. Default 4 MB; tune via initOptions(slowConsumerBytes: N).
$stats  : Stats|null
Per-worker counter struct. Surfaced via WSRouter::stats(). Lazily initialised on first access.

Methods

bootChecks()  : array<int, string>
Boot-time WS advisories — mirror of App::redisBootChecks(). Returns a list of human-readable warning strings for misconfigurations that are legal but surprising in production. App::run() (or app.php) can emit these via elog/error_log at boot. Pure + side-effect-free so it's unit-testable without booting a server.
broadcast()  : int
Publish to a room channel. Every server with a matching App::subscribe($channel, ...) handler fans out to its local clients. Returns the receiver count Redis reported.
checkClientRate()  : bool
Returns true when the client is under the rate limit (or limits disabled). Increments the (window-stable) counter atomically.
init()  : void
One-time WSRouter bootstrap. Wires the cluster-wide ownership table, room-membership table, per-server identity subscriber, and per-worker lifecycle hooks (heartbeat + GC + graceful sweep). Idempotent — subsequent calls are no-ops.
initOptions()  : void
Bump the per-cluster capacity caps BEFORE init() — these size the underlying OpenSwoole\Table segments allocated at master fork.
onlineByServer()  : array<string, int>
Per-server connection counts. Iterates ws_owner once and groups by server_id — O(N_total_clients). Useful for dashboards / debugging load-balancer imbalance; pricier than onlineCount(), so don't call on every request.
onlineCount()  : int
Total connected clients across the cluster.
onRoom()  : void
Sugar over App::subscribe($channel, ...) — use when you want the room-channel registration to read more naturally alongside the routing calls above.
own()  : string
Record that this server now owns this client's WS connection.
ownAuthenticated()  : string
Like {@see own()} but binds the connection to the authenticated session principal instead of a caller-supplied id (#234). Resolves {@see sessionPrincipal()}; throws {@see WSAuthException} when the session isn't authenticated, so an attacker can't claim another user's routing id.
principalForFd()  : string|null
The authenticated principal bound to this fd by {@see ownAuthenticated()}, or null if the connection wasn't authenticated that way. Use it to authorize onMessage-time room ops without touching the session again.
release()  : void
Clean up when a client disconnects (call from ws onClose).
room()  : Room
Construct a Room handle. The Room itself is a value object that delegates to WSRouter's static state for actual lifecycle.
roomAuthorizer()  : callable|null
Register a per-room authorizer (or read it back). `fn(string $action, string $room, string $clientId): bool, $action ∈ {join,leave,push,read}`.
sendToClient()  : bool
Send to a specific client by id, regardless of which server holds them. Returns true if the publish was issued (delivery still best-effort + subject to the conn_id verification on the receiver), false if the client isn't connected anywhere we know about.
serverId()  : string
Returns the configured server id (hostname:pid by default).
sessionPrincipal()  : string|null
Resolve the authenticated principal for the CURRENT request/connection from the session, via the same hooks the HTTP layer uses: App::authChecker() (is the session authenticated?) then App::usernameProvider() (who?). Returns null when unauthenticated, the hooks aren't wired, or no username resolves.
setChannelHmacSecret()  : void
WS-3 — set a shared HMAC secret for pub/sub channel authentication.
setClientRateLimit()  : void
WS-4 — per-client rate limit. Throttles sendToClient AND Room::push attributed to a single client. Sliding-window backed by Counter.
setFanoutConcurrency()  : void
WS-6 — bound the per-broadcast push fan-out concurrency. Default 128.
setRoomRateLimit()  : void
Configure per-room push rate limiting. Default is unlimited.
stats()  : Stats
Per-worker WSRouter counter struct. Call ->snapshot() to read the current counters as array<string,int>. Pair with Store::stats() for pool/pubsub-side metrics + App::stats() for worker/memory state.

Constants

CLOSE_AUTH_INVALID

Auth failed: bad token, expired session, etc.

public mixed CLOSE_AUTH_INVALID = 4002

CLOSE_AUTH_REQUIRED

Auth required: client connected but never authenticated.

public mixed CLOSE_AUTH_REQUIRED = 4001

CLOSE_CAPACITY

Server is over capacity (paired with CapacityException).

public mixed CLOSE_CAPACITY = 4013

CLOSE_FORBIDDEN

Authenticated but lacking permission for this operation.

public mixed CLOSE_FORBIDDEN = 4003

CLOSE_GOING_AWAY

Server is going down (or client is navigating away).

public mixed CLOSE_GOING_AWAY = 1001

CLOSE_IDLE

Connection idle / heartbeat missed beyond threshold.

public mixed CLOSE_IDLE = 4040

CLOSE_INTERNAL_ERROR

Server hit an internal error processing the request.

public mixed CLOSE_INTERNAL_ERROR = 1011

CLOSE_MESSAGE_TOO_BIG

Message too big for the receiver to process.

public mixed CLOSE_MESSAGE_TOO_BIG = 1009

CLOSE_NORMAL

Normal closure (peer is closing as expected).

public mixed CLOSE_NORMAL = 1000

CLOSE_POLICY_VIOLATION

Peer sent a message that violates server policy.

public mixed CLOSE_POLICY_VIOLATION = 1008

CLOSE_PROTOCOL_ERROR

Peer sent a malformed frame / protocol violation.

public mixed CLOSE_PROTOCOL_ERROR = 1002

CLOSE_RATE_LIMITED

Client breached the per-client rate limit (WS-4).

public mixed CLOSE_RATE_LIMITED = 4029

CLOSE_TRY_AGAIN_LATER

Server is temporarily overloaded — client should retry later.

public mixed CLOSE_TRY_AGAIN_LATER = 1013

CLOSE_UNSUPPORTED

Peer sent a frame of a type the endpoint can't accept.

public mixed CLOSE_UNSUPPORTED = 1003

ROOM_CHANNEL_PREFIX

private mixed ROOM_CHANNEL_PREFIX = 'ws:room:'

ROOM_TABLE

private mixed ROOM_TABLE = 'ws_room_members'

SERVER_GC_INTERVAL_MS

private mixed SERVER_GC_INTERVAL_MS = 60000

SERVER_HEARTBEAT_INTERVAL_MS

private mixed SERVER_HEARTBEAT_INTERVAL_MS = 30000

SERVER_STALE_AFTER_SEC

private mixed SERVER_STALE_AFTER_SEC = 90

SERVERS_TABLE

private mixed SERVERS_TABLE = 'ws_servers'

Properties

$channelHmacSecret

Per-channel HMAC secret (WS-3) — null disables. Set via setChannelHmacSecret.

private static string|null $channelHmacSecret = null

$clientRateLimitN

Per-client rate limit (WS-4) — 0 disables. Set via setClientRateLimit.

private static int $clientRateLimitN = 0

$clientRateLimitWindowSec

private static int $clientRateLimitWindowSec = 60

$clientSink

Inbound-routed-message handler. Receives ($clientId, $fd, $payload) and is responsible for pushing $payload to the local fd.

private static callable(string $clientId, int $fd, string $payload): void|null $clientSink = null

Defaults to a backpressure-aware $server->push() installed by init().

$fanoutConcurrency

WS-6 — max simultaneous push coroutines per room broadcast. Bounds the fan-out so a large room + message burst can't spawn an unbounded number of concurrent push coroutines (each push call is non-blocking, but the getClientInfo + dispatch path is not free, and unbounded go() per member is the keystone backpressure gap). Pushes still run concurrently (per-coroutine isolation preserved) — only the SIMULTANEITY is capped via a Coroutine\Channel token semaphore. Configure via setFanoutConcurrency.

private static int $fanoutConcurrency = 128

$initialized

true once init() has wired the pub/sub subscribers and lifecycle hooks.

private static bool $initialized = false

$localClientRooms

Per-worker reverse index: which rooms each LOCALLY-OWNED client has joined. Lets release() (ws onClose) leave every room a client was in, so an abnormal disconnect doesn't leak ws_room_members rows / per-room SET entries until the whole server is GC'd.

private static array<string, array<string, true>> $localClientRooms = []

client_id → [room => true]

$localFds

Per-worker map of locally-owned WebSocket connections.

private static array<string, array{fd: int, conn_id: string}> $localFds = []

client_id → ['fd' => int, 'conn_id' => string]

$localRoomMembership

Per-worker cache of locally-owned clients by room.

private static array<string, array<string, string>> $localRoomMembership = []

room → [client_id => conn_id]

#246 — the value is the per-connection nonce (conn_id) captured from $localFds[$clientId] at join time, NOT a bare true. The room push loop re-checks it against the live $localFds[$clientId]['conn_id'] before pushing, so a reused fd (client reconnected under a fresh nonce while a stale membership entry lingers) can't receive a prior owner's room message — mirroring the C1 guard on the identity-delivery path.

$ownerCapacity

Max connections per cluster — bump via initOptions() for prod.

private static int $ownerCapacity = 4096

$principalByFd

#234 — server-derived principal bound to a connection at ownAuthenticated(), keyed by fd. Lets later onMessage handlers recover the authenticated identity (principalForFd($fd)) without re-reading the session, and ties a client_id to the authenticated session rather than a client-supplied string. Reaped in release().

private static array<int, string> $principalByFd = []

$rateLimitWindows

WS-7 — per-worker record of the window id each rate-limit counter is currently serving. A sliding-window limiter rotates buckets every $windowSec; the PRE-WS-7 code baked the bucket id into the counter NAME (..._{hash}_{bucket}), so on the default Atomic backend every elapsed window left a dead named Atomic forever → unbounded per-worker memory growth. WS-7 keeps the counter name STABLE (one per distinct room / client) and reuses it across windows: when this map shows the counter has rolled into a new window, the limiter reset()s it to 0 before the increment. Bounds the live Atomic count to the number of distinct rate-limited rooms + clients, not rooms × elapsed-windows.

private static array<string, int> $rateLimitWindows = []

counter-name → window-id

$roomAuthorizer

#234 — optional per-room authorizer. `fn(string $action, string $room, string $clientId): bool where $action ∈ {join,leave,push,read}`. When SET, every Room op consults it FAIL-CLOSED (a falsey return refuses); when null (default) the room layer is unguarded as before (BC). Wire it to your session auth — e.g. `WSRouter::roomAuthorizer(fn($a,$r,$c) => App::authChecker() && (App::authChecker())())`.

private static callable(string, string, string): bool|null $roomAuthorizer = null

$roomMembersCapacity

Max (room × member) pairs per cluster — bump via initOptions() for prod.

private static int $roomMembersCapacity = 16384

$roomMessageHandlers

User-registered message handlers per room. Each callable receives (array $msg, string $room).

private static array<string, array<int, callable>> $roomMessageHandlers = []

$roomPresenceHandlers

User-registered presence (join/leave) handlers per room. Each callable receives (array $event, string $room).

private static array<string, array<int, callable>> $roomPresenceHandlers = []

$roomRateLimitN

Per-room rate limit — 0 disables. Set via setRoomRateLimit($n, $windowSec).

private static int $roomRateLimitN = 0

$roomRateLimitWindowSec

private static int $roomRateLimitWindowSec = 60

$serverId

Cluster-unique server identifier — defaults to hostname:pid. Set by init().

private static string $serverId = ''

$slowConsumerBytes

Slow-consumer threshold. If $server->getClientInfo($fd)['send_queue_bytes'] exceeds this, the framework DROPS the message for that fd instead of letting the kernel/OpenSwoole buffer grow unbounded. Tracked in stats()['pushes_dropped_slow_consumer']. Default 4 MB; tune via initOptions(slowConsumerBytes: N).

private static int $slowConsumerBytes = 4 * 1024 * 1024

$stats

Per-worker counter struct. Surfaced via WSRouter::stats(). Lazily initialised on first access.

private static Stats|null $stats = null

Methods

bootChecks()

Boot-time WS advisories — mirror of App::redisBootChecks(). Returns a list of human-readable warning strings for misconfigurations that are legal but surprising in production. App::run() (or app.php) can emit these via elog/error_log at boot. Pure + side-effect-free so it's unit-testable without booting a server.

public static bootChecks() : array<int, string>

Covers:

  • WS-2: a WS rate limit (room or client) is configured but the Counter backend is the per-worker Atomic — limits are per-worker (× worker count), NOT cross-node. Flip to Counter::defaultBackend('redis') for a true cluster-wide cap.
  • WS-3: the router is Redis-backed (cross-node pub/sub) but NO channel HMAC secret is set — routed ws:server:* / ws:room:* messages are UNAUTHENTICATED, so any peer with Redis write access can forge a message onto a real client. Set WSRouter::setChannelHmacSecret().
Return values
array<int, string>

broadcast()

Publish to a room channel. Every server with a matching App::subscribe($channel, ...) handler fans out to its local clients. Returns the receiver count Redis reported.

public static broadcast(string $channel, string $payload) : int
Parameters
$channel : string
$payload : string
Return values
int

checkClientRate()

Returns true when the client is under the rate limit (or limits disabled). Increments the (window-stable) counter atomically.

public static checkClientRate(string $clientId) : bool
Parameters
$clientId : string
Return values
bool

init()

One-time WSRouter bootstrap. Wires the cluster-wide ownership table, room-membership table, per-server identity subscriber, and per-worker lifecycle hooks (heartbeat + GC + graceful sweep). Idempotent — subsequent calls are no-ops.

public static init([string|null $serverId = null ][, callable|null $clientSink = null ][, int|null $ownerCapacity = null ][, int|null $roomMembersCapacity = null ][, int|null $slowConsumerBytes = null ]) : void

MUST be called BEFORE App::run() so the Store tables can be allocated in the master process before workers fork. The Redis backend is also acceptable (state lives in Redis instead of Table).

Capacity + tunable options can be passed inline; alternatively call WSRouter::initOptions() BEFORE init() (same setters, different ergonomic shape — pick whichever reads cleaner).

// Inline (most concise):
WSRouter::init(
    ownerCapacity:        200_000,
    roomMembersCapacity:  1_000_000,
    slowConsumerBytes:    8 * 1024 * 1024,
);

// Or split (when you want a custom serverId / sink + tuning):
WSRouter::initOptions(ownerCapacity: 200_000, roomMembersCapacity: 1_000_000);
WSRouter::init('node-A', $myCustomSink);
Parameters
$serverId : string|null = null

Cluster-unique server id; defaults to hostname:pid.

$clientSink : callable|null = null

Inbound-routed-message handler; defaults to a backpressure-aware $server->push.

$ownerCapacity : int|null = null

Max concurrent WS connections cluster-wide (default 4096, Table HARD CAP).

$roomMembersCapacity : int|null = null

Max (room, member) pairs cluster-wide (default 16384, Table HARD CAP).

$slowConsumerBytes : int|null = null

Per-fd send_queue_bytes threshold above which pushWithBackpressure drops + tracks (default 4 MB).

initOptions()

Bump the per-cluster capacity caps BEFORE init() — these size the underlying OpenSwoole\Table segments allocated at master fork.

public static initOptions([int|null $ownerCapacity = null ][, int|null $roomMembersCapacity = null ][, int|null $slowConsumerBytes = null ]) : void

Defaults (4096 owners / 16384 room members) are demo-grade; production deployments should size these against expected peak.

Example: WSRouter::initOptions(ownerCapacity: 200_000, roomMembersCapacity: 1_000_000); WSRouter::init();

Parameters
$ownerCapacity : int|null = null
$roomMembersCapacity : int|null = null
$slowConsumerBytes : int|null = null

onlineByServer()

Per-server connection counts. Iterates ws_owner once and groups by server_id — O(N_total_clients). Useful for dashboards / debugging load-balancer imbalance; pricier than onlineCount(), so don't call on every request.

public static onlineByServer() : array<string, int>
Return values
array<string, int>

server_id → count

onlineCount()

Total connected clients across the cluster.

public static onlineCount() : int

O(1) on both backends — Store::count() reads the membership-set cardinality (Atomic counter on Table, SCARD on Redis). Cheap enough to call from /healthz on every request.

NOTE: a "client" here is one row in ws_owner — one logical connection. A user with 3 open tabs counts as 3.

Return values
int

onRoom()

Sugar over App::subscribe($channel, ...) — use when you want the room-channel registration to read more naturally alongside the routing calls above.

public static onRoom(string $channel, callable $handler) : void
Parameters
$channel : string
$handler : callable

own()

Record that this server now owns this client's WS connection.

public static own(string $clientId, int $fd[, string|null $connId = null ]) : string

Call from your ws onOpen handler — needs the assigned fd locally AND the cluster-wide mapping in Store.

Returns the per-connection nonce (conn_id) — a random 16-byte hex string the framework uses to defeat FD-reuse races. Callers usually don't need it; the framework manages it internally.

Parameters
$clientId : string
$fd : int
$connId : string|null = null
Return values
string

ownAuthenticated()

Like {@see own()} but binds the connection to the authenticated session principal instead of a caller-supplied id (#234). Resolves {@see sessionPrincipal()}; throws {@see WSAuthException} when the session isn't authenticated, so an attacker can't claim another user's routing id.

public static ownAuthenticated(int $fd[, string|null $connId = null ]) : string

Records the principal for the fd (principalForFd()) so later onMessage handlers can authorize without re-reading the session.

Parameters
$fd : int
$connId : string|null = null
Tags
throws
WSAuthException

when no authenticated principal is available.

Return values
string

principalForFd()

The authenticated principal bound to this fd by {@see ownAuthenticated()}, or null if the connection wasn't authenticated that way. Use it to authorize onMessage-time room ops without touching the session again.

public static principalForFd(int $fd) : string|null
Parameters
$fd : int
Return values
string|null

release()

Clean up when a client disconnects (call from ws onClose).

public static release(string $clientId) : void
Parameters
$clientId : string

room()

Construct a Room handle. The Room itself is a value object that delegates to WSRouter's static state for actual lifecycle.

public static room(string $name) : Room
Parameters
$name : string
Return values
Room

roomAuthorizer()

Register a per-room authorizer (or read it back). `fn(string $action, string $room, string $clientId): bool, $action ∈ {join,leave,push,read}`.

public static roomAuthorizer([callable(string, string, string): bool|null $fn = null ]) : callable|null

When SET, every Room op consults it FAIL-CLOSED: a falsey return refuses the op (mutations throw WSAuthException; reads return empty). When null (default) the room layer is unguarded (BC). This is the WS analog of App::authChecker() — wire it to your session so WS follows your auth.

Parameters
$fn : callable(string, string, string): bool|null = null
Return values
callable|null

sendToClient()

Send to a specific client by id, regardless of which server holds them. Returns true if the publish was issued (delivery still best-effort + subject to the conn_id verification on the receiver), false if the client isn't connected anywhere we know about.

public static sendToClient(string $clientId, string $payload) : bool

The payload carries the conn_id captured at the time of THIS lookup. If the client reconnects between this publish and its delivery, the subscriber on the owning server detects the conn_id mismatch and silently drops the stale message (C1 fix).

Parameters
$clientId : string
$payload : string
Return values
bool

serverId()

Returns the configured server id (hostname:pid by default).

public static serverId() : string
Return values
string

sessionPrincipal()

Resolve the authenticated principal for the CURRENT request/connection from the session, via the same hooks the HTTP layer uses: App::authChecker() (is the session authenticated?) then App::usernameProvider() (who?). Returns null when unauthenticated, the hooks aren't wired, or no username resolves.

public static sessionPrincipal() : string|null

Call from a WS onOpen handler (the framework starts the session from the upgrade cookie there) to derive a server-trusted identity — never trust a client-supplied id for routing.

Return values
string|null

setChannelHmacSecret()

WS-3 — set a shared HMAC secret for pub/sub channel authentication.

public static setChannelHmacSecret(string|null $secret) : void

Every server in the cluster MUST share the same secret. Once set:

  • sendToClient + Room::push publishes are wrapped in a signed envelope {v:1, hmac, payload}.
  • Receivers (the per-server + per-room subscribers) verify the HMAC and silently drop messages with bad/missing signatures (bumps hmac_verify_failures_total in stats).

Defeats the "anyone with Redis write access can forge a routed message" attack: a peer that doesn't know the secret cannot spoof messages onto ws:server:* or ws:room:* channels.

Pass null to disable (default). Read via env in your app.php:

WSRouter::setChannelHmacSecret(getenv('ZEALPHP_WS_HMAC') ?: null);
Parameters
$secret : string|null

setClientRateLimit()

WS-4 — per-client rate limit. Throttles sendToClient AND Room::push attributed to a single client. Sliding-window backed by Counter.

public static setClientRateLimit(int $n[, int $windowSec = 60 ]) : void

0 (default) disables. Pair with the setRoomRateLimit per-room cap.

WSRouter::setClientRateLimit(50, 10); // 50 ops / 10s / client

Parameters
$n : int
$windowSec : int = 60

setFanoutConcurrency()

WS-6 — bound the per-broadcast push fan-out concurrency. Default 128.

public static setFanoutConcurrency(int $n) : void

A room broadcast spawns at most $n push coroutines at a time; the rest queue on a Coroutine\Channel token semaphore and start as in-flight pushes complete. Pass 0 to UNBOUND (restores the legacy spawn-per-member behaviour — not recommended for large rooms).

WSRouter::setFanoutConcurrency(256);   // up to 256 simultaneous pushes
WSRouter::setFanoutConcurrency(0);      // unbounded (legacy)
Parameters
$n : int

setRoomRateLimit()

Configure per-room push rate limiting. Default is unlimited.

public static setRoomRateLimit(int $n[, int $windowSec = 60 ]) : void

WSRouter::setRoomRateLimit(100, 60); // 100 pushes / 60s / room WSRouter::setRoomRateLimit(0); // disable

Counts live in a Store counter keyed {room}:rl:{window} — cluster-wide when the Store backend is Redis. Returns true when push allowed. Drops increment rate_limit_drops_total in stats().

Parameters
$n : int
$windowSec : int = 60

stats()

Per-worker WSRouter counter struct. Call ->snapshot() to read the current counters as array<string,int>. Pair with Store::stats() for pool/pubsub-side metrics + App::stats() for worker/memory state.

public static stats() : Stats

Counters surfaced: owns_total / releases_total sendToClient_total / sendToClient_owner_missing room_joins_total / room_leaves_total / room_pushes_total pushes_total / pushes_failed_total pushes_dropped_slow_consumer / pushes_to_dead_fd_total capacity_exceeded_owner_total / capacity_exceeded_room_total rate_limit_drops_total

Return values
Stats
On this page