Skip to content

Tunnel / TunnelManager / ClientConnection โ€‹

Applies to: Hub (phlix-hub)

High-level relay management for bidirectional WebSocket tunnels between the hub and servers, and the client connections multiplexed through them.

Overview โ€‹

Server (WSS) โ†โ†’ [Tunnel] โ†โ†’ [TunnelManager] โ†โ†’ [ClientConnection] โ†โ†’ Client (WSS)
                     โ†‘
               RelaySessionManager
                     โ†‘
                Database
  • Tunnel โ€” represents a single server-to-hub WebSocket connection with state machine, framing, and multiplexed clients
  • TunnelManager โ€” registry of all active tunnels, accepts server/client connections, runs heartbeat/reaper timers
  • ClientConnection โ€” represents a single client-to-hub WebSocket connection attached to a tunnel

Tunnel State Machine โ€‹

PENDING โ†’ ACTIVE โ†’ CLOSING โ†’ CLOSED
StateDescription
PENDINGAwaiting HELLO handshake from server
ACTIVEFully established, frames can be exchanged
CLOSINGClean shutdown in progress
CLOSEDAll resources released

Transitions โ€‹

FromToTrigger
PENDINGACTIVEValid HELLO frame received
PENDINGCLOSEDMalformed HELLO, timeout, or protocol error
ACTIVECLOSINGServer disconnect, explicit close(), or protocol error
ACTIVECLOSINGisStale() returns true (reaper)
CLOSINGCLOSEDCleanup completes

Tunnel โ€‹

Phlix\Hub\Relay\Tunnel

Represents a bidirectional WebSocket tunnel between the hub and a server. Manages the server-side connection, all client connections multiplexed through this tunnel, frame sequencing, and session lifecycle.

Construction โ€‹

php
use Phlix\Hub\Relay\Tunnel;
use Phlix\Hub\Hub\RelaySessionManager;
use Phlix\Hub\Common\Logger\StructuredLogger;
use Phlix\Hub\Relay\FrameEncoder;
use Phlix\Shared\Relay\RelayWireCodecInterface;

$tunnel = new Tunnel(
    serverId: 'server-uuid',
    serverWs: $tcpConnection,          // Workerman TcpConnection to server
    sessionManager: $sessionManager,     // For byte accounting
    codec: $codec,                      // FrameEncoder/Decoder codec
    logger: $logger,                    // StructuredLogger
    tunnelId: null,                      // Optional UUID (generated if null)
);

Properties โ€‹

PropertyTypeDescription
tunnelIdstringUnique tunnel UUID.
serverIdstringServer UUID.
serverWsTcpConnectionWorkerman connection to the server.
statusstringCurrent state (PENDING, ACTIVE, CLOSING, CLOSED).
clientConnectionsSplObjectStorageAll client connections attached to this tunnel.
openedAtintUnix timestamp when tunnel was opened.
lastFrameAtintUnix timestamp of last frame received from server.
seqintNext sequence number for frames sent to server.
relaySessionId`stringnull`

Lifecycle Methods โ€‹

onServerMessage(string $data): void โ€‹

Handle an incoming message from the server. During PENDING state, expects a JSON HELLO frame. During ACTIVE state, decodes binary frames via FrameDecoder and handles:

  • DATA โ†’ broadcast to all clients
  • HEARTBEAT โ†’ touch lastFrameAt
  • Other types โ†’ log warning and close

onServerClose(): void โ€‹

Handle server WebSocket close event. Notifies all clients with TYPE_DISCONNECTED, closes the session in the database, transitions to CLOSED.

close(string $reason = 'normal'): void โ€‹

Initiate clean tunnel shutdown. Sends TYPE_DISCONNECTED to all clients, closes the server connection, closes the session in DB.

Client Multiplexing โ€‹

registerClient(ClientConnection $client): void โ€‹

Register a new client connection with this tunnel. Sends CLIENT_CONNECT notification to the server.

php
$tunnel->registerClient($clientConnection);

removeClient(ClientConnection $client): void โ€‹

Remove a client connection from this tunnel. Sends CLIENT_DISCONNECT notification to the server if the tunnel is still active.

php
$tunnel->removeClient($clientConnection);

Frame Exchange โ€‹

sendToServer(RelayFrame $frame): void โ€‹

Send a frame to the server. Only valid when tunnel is ACTIVE. Records bytes sent to the session manager.

php
use Phlix\Shared\Relay\RelayFrame;
use Phlix\Shared\Relay\RelayFrameType;

$frame = new RelayFrame(RelayFrameType::DATA, $tunnel->seq++, 'payload bytes');
$tunnel->sendToServer($frame);

broadcastToClients(RelayFrame $frame): void โ€‹

Broadcast a DATA frame to all connected clients. The frame is encoded once and written to each client connection.

php
$tunnel->broadcastToClients($frame);

sendHeartbeat(): void โ€‹

Send a heartbeat frame to the server. Increments sequence number and updates lastFrameAt.

php
$tunnel->sendHeartbeat();

Health Checks โ€‹

isStale(int $staleThresholdSeconds = 90): bool โ€‹

Check if the tunnel is stale (no frames received within the threshold). Used by the reaper to detect dead tunnels.

php
if ($tunnel->isStale(90)) {
    // Close stale tunnel
    $tunnel->close('stale');
}

TunnelManager โ€‹

Phlix\Hub\Relay\TunnelManager

Manages all active relay tunnels between the hub and servers. Provides registration of new server tunnels, lookup by server ID, client connection routing, and tunnel lifecycle management.

Construction โ€‹

php
use Phlix\Hub\Relay\TunnelManager;
use Phlix\Hub\Hub\RelaySessionManager;
use Phlix\Hub\Common\Logger\StructuredLogger;
use Phlix\Shared\Relay\RelayWireCodecInterface;

$manager = new TunnelManager(
    sessionManager: $sessionManager,
    codec: $codec,
    logger: $logger,
);

Server Connections โ€‹

acceptServer(string $serverId, TcpConnection $serverWs): Tunnel โ€‹

Accept a new server connection and create a tunnel. If a tunnel already exists for this server_id, it is closed first (server reconnect scenario) before a new one is created.

Returns a Tunnel in PENDING state (transitions to ACTIVE after HELLO is received).

php
$serverId = 'server-uuid';
$tunnel = $manager->acceptServer($serverId, $serverWs);

// $tunnel is now in PENDING state, waiting for HELLO

getTunnelForServer(string $serverId): ?Tunnel โ€‹

Get the tunnel for a given server ID.

php
$tunnel = $manager->getTunnelForServer('server-uuid');
if ($tunnel !== null && $tunnel->status === Tunnel::STATUS_ACTIVE) {
    // Use the active tunnel
}

hasTunnel(string $serverId): bool โ€‹

Check if an active tunnel exists for the given server ID.

php
if ($manager->hasTunnel('server-uuid')) {
    // Server is connected and active
}

Client Connections โ€‹

acceptClient(string $serverId, TcpConnection $clientWs, string $clientId, string $sessionId = ''): ?ClientConnection โ€‹

Accept a new client connection and attach it to the appropriate tunnel. Returns null if the tunnel is not found or not active.

php
$client = $manager->acceptClient(
    serverId: 'server-uuid',
    clientWs: $clientTcpConnection,
    clientId: 'client-uuid',
    sessionId: 'relay-session-id',
);

if ($client === null) {
    // Server not connected or tunnel not active
}

Tunnel Lifecycle โ€‹

closeTunnel(string $serverId, string $reason): void โ€‹

Close a tunnel by server ID. Marks the tunnel as closed, sends TYPE_DISCONNECTED to all clients, closes the server connection, and removes the tunnel from the map.

php
$manager->closeTunnel('server-uuid', 'server_disconnected');

removeTunnel(string $serverId): void โ€‹

Remove a tunnel from the manager (called after cleanup).

php
$manager->removeTunnel('server-uuid');

Iteration โ€‹

allTunnels(): Generator<string, Tunnel> โ€‹

Get all active tunnels as a generator. Yields [serverId => Tunnel] for all tunnels in ACTIVE status. Used by heartbeat timer and idle reaper.

php
foreach ($manager->allTunnels() as $serverId => $tunnel) {
    // Check tunnel health
    if ($tunnel->isStale()) {
        $tunnel->close('stale');
    }
}

getActiveTunnelCount(): int โ€‹

Get the count of active tunnels.

php
$count = $manager->getActiveTunnelCount();

ClientConnection โ€‹

Phlix\Hub\Relay\ClientConnection

Represents a single client WebSocket connection multiplexed through a tunnel. Each remote client connects to the hub via WSS and is tracked as a ClientConnection attached to a specific server Tunnel.

Construction โ€‹

php
use Phlix\Hub\Relay\ClientConnection;
use Phlix\Hub\Common\Logger\StructuredLogger;

$client = new ClientConnection(
    clientWs: $clientTcpConnection,
    serverId: 'server-uuid',
    clientId: 'client-uuid',
    logger: $logger,
    sessionId: 'relay-session-id',  // Optional
);

Properties โ€‹

PropertyTypeDescription
clientIdstringClient UUID (assigned by the hub).
serverIdstringServer UUID this client is connected through.
sessionIdstringOptional relay session ID for this client.
clientWsTcpConnectionWorkerman connection to the client.
tunnel`Tunnelnull`
lastFrameAtintUnix timestamp of last frame received from client.

Message Handling โ€‹

onMessage(string $data, FrameDecoder $decoder): void โ€‹

Handle an incoming message from the client. Only TYPE_DATA frames are forwarded to the server. Other frame types log a warning and send TYPE_ERROR back to the client.

php
$decoder = new FrameDecoder();
$client->onMessage($bytesFromClient, $decoder);

Lifecycle โ€‹

onClose(): void โ€‹

Handle client WebSocket close event. Notifies the tunnel to send CLIENT_DISCONNECT upstream.

php
$client->onClose();

close(): void โ€‹

Close the client connection.

php
$client->close();

Sending โ€‹

sendRaw(string $encodedFrame): void โ€‹

Send an already-encoded binary frame to the client.

php
$client->sendRaw($encodedBytes);

send(RelayFrame $frame, FrameEncoder $encoder): void โ€‹

Encode and send a frame to the client.

php
$encoder = new FrameEncoder();
$client->send($frame, $encoder);

Error Handling โ€‹

Tunnel Errors โ€‹

ReasonDescription
malformed_helloJSON parse error in HELLO frame
invalid_helloMissing or invalid type field in HELLO
invalid_hello_payloadMissing enrollment_jwt or server_id in HELLO
protocol_errorUnexpected frame type received
server_closedServer disconnected
server_replacedServer reconnected (old tunnel closed)
staleTunnel idle beyond threshold

When a tunnel closes with an error, all client connections receive TYPE_DISCONNECTED with the reason.

Client Connection Errors โ€‹

When a client sends a non-DATA frame, a TYPE_ERROR frame is sent back:

php
// Error payload sent to client
['error' => 'Unexpected frame type']

The connection is not automatically closed โ€” the client may recover.

FrameDecoder Errors โ€‹

Per RFC 6455 ยง7.4.1, invalid frame types throw InvalidFrameTypeException with code 1011. The decoder is in an undefined state after this exception โ€” discard it and create a new instance.

Example Usage โ€‹

Server Connection Handling โ€‹

php
use Phlix\Hub\Relay\TunnelManager;
use Phlix\Hub\Relay\FrameDecoder;

$manager = new TunnelManager($sessionManager, $codec, $logger);
$decoder = new FrameDecoder();

$worker->onConnect = function ($connection) use ($manager) {
    // Server connecting โ€” create tunnel in PENDING state
    $serverId = extractServerId($connection);
    $tunnel = $manager->acceptServer($serverId, $connection);
};

$worker->onMessage = function ($connection, $data) use ($manager, $decoder) {
    $serverId = extractServerId($connection);
    $tunnel = $manager->getTunnelForServer($serverId);

    if ($tunnel === null) {
        return;
    }

    $tunnel->onServerMessage($data);
};

$worker->onClose = function ($connection) use ($manager) {
    $serverId = extractServerId($connection);
    $manager->closeTunnel($serverId, 'server_closed');
};

Client Connection Handling โ€‹

php
use Phlix\Hub\Relay\ClientConnection;
use Phlix\Hub\Relay\FrameDecoder;

$decoder = new FrameDecoder();

$worker->onConnect = function ($connection) use ($manager) {
    $clientId = generateClientId();
    $serverId = extractRequestedServerId($connection);

    $client = $manager->acceptClient($serverId, $connection, $clientId);

    if ($client === null) {
        // Reject connection
        $connection->close();
    }
};

$worker->onMessage = function ($connection, $data) use ($manager, $decoder) {
    $clientId = extractClientId($connection);
    $client = findClientByConnection($clientId);

    if ($client === null) {
        return;
    }

    $client->onMessage($data, $decoder);
};

$worker->onClose = function ($connection) use ($manager) {
    $clientId = extractClientId($connection);
    $client = findClientByConnection($clientId);

    if ($client !== null) {
        $client->onClose();
    }
};

Heartbeat and Reaper โ€‹

php
use React\EventLoop\Loop;

$heartbeatInterval = 30; // seconds
$staleThreshold = 90;   // seconds

// Heartbeat timer
Loop::addPeriodicTimer($heartbeatInterval, function () use ($manager) {
    foreach ($manager->allTunnels() as $serverId => $tunnel) {
        $tunnel->sendHeartbeat();
    }
});

// Stale reaper
Loop::addPeriodicTimer($staleThreshold, function () use ($manager) {
    foreach ($manager->allTunnels() as $serverId => $tunnel) {
        if ($tunnel->isStale($staleThreshold)) {
            $tunnel->close('stale');
        }
    }
});

BSD-3-Clause