API Index — Namespaces, Packages, Reports, Indices
WSRouter
in package
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_ownerStore 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$payloadto 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 unboundedgo()per member is the keystone backpressure gap). Pushes still run concurrently (per-coroutine isolation preserved) — only the SIMULTANEITY is capped via aCoroutine\Channeltoken semaphore. Configure viasetFanoutConcurrency. - $initialized : bool
trueonceinit()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 leakws_room_membersrows / 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 lateronMessagehandlers recover the authenticated identity (principalForFd($fd)) without re-reading the session, and ties aclient_idto the authenticated session rather than a client-supplied string. Reaped inrelease(). - $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 limiterreset()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, everyRoomop 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 byinit(). - $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 instats()['pushes_dropped_slow_consumer']. Default 4 MB; tune viainitOptions(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 viaelog/error_logat 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 underlyingOpenSwoole\Tablesegments allocated at master fork. - onlineByServer() : array<string, int>
- Per-server connection counts. Iterates
ws_owneronce and groups byserver_id— O(N_total_clients). Useful for dashboards / debugging load-balancer imbalance; pricier thanonlineCount(), 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
nullif the connection wasn't authenticated that way. Use it to authorizeonMessage-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?) thenApp::usernameProvider()(who?). Returnsnullwhen 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
sendToClientANDRoom::pushattributed to a single client. Sliding-window backed byCounter. - 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 asarray<string,int>. Pair withStore::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'
TABLE
private
mixed
TABLE
= 'ws_owner'
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. SetWSRouter::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
intcheckClientRate()
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
boolinit()
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
pushWithBackpressuredrops + 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
intonRoom()
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
stringownAuthenticated()
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
Return values
stringprincipalForFd()
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|nullrelease()
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
RoomroomAuthorizer()
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|nullsendToClient()
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
boolserverId()
Returns the configured server id (hostname:pid by default).
public
static serverId() : string
Return values
stringsessionPrincipal()
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|nullsetChannelHmacSecret()
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::pushpublishes 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_totalin 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