WebSocket Multiplexed Relay Tunnel β Implementation Plan β
status: implemented
phase: complete
updated: 2026-05-25Implemented (2026-05-25). This plan has shipped. The server-side tunnel worker (
RelayWorker,ws://β¦:8802) and the client-facing worker (ClientRelayWorker,ws://β¦:8803) both run fromApplication::run();TunnelManager/Tunnelmultiplex frames per channel,FrameEncoder/FrameDecoderhandle the wire format, andIdleReapersweeps idle sessions.ClientMountController::handle()returns 426/501 steering plain HTTP callers to the WS worker. Seephlix-hubCHANGELOG.md(Section 9) and thetests/Unit/Relay/suite. The remaining caveat is TLS: relay certificates are provisioned out-of-band (ACME is not implemented) β seedocs/hub-admin/tls.md. This document is retained as the design record.
Goal β
Implement the bidirectional WebSocket relay tunnel described in docs/dev/architecture-hub.md ("Relay tunnel design"), replacing the current RelayController 501 stub with a live multiplexed relay that: (a) accepts an outbound WS connection from a phlix-server, (b) accepts inbound WS connections from remote clients, and (c) multiplexes frames between them while tracking bytes and idle state.
β οΈ Design Conflict: Two Relay Designs β
This plan reconciles two competing relay designs in the codebase. Failing to account for this would produce a hub that is incompatible with the existing server-side relay implementation.
C.6 HTTP-proxy-over-WS (existing, shipping) β
- Source:
phlix-docs/docs/dev/relay-protocol.md+ server-sideRelayMessageFramerinphlix-server/src/Hub/RelayMessageFramer.php - Wire:
[1-byte type][4-byte seq (big-endian uint32)][4-byte payload_len (big-endian uint32)][payload_bytes]TYPE_HTTP_REQUEST = 1,TYPE_HTTP_RESPONSE = 2,TYPE_PING = 3,TYPE_PONG = 4
- Mount: single
wss://hub.example.com/api/v1/servers/{id}/relay - Behavior: Hub proxies HTTP request/response pairs over the tunnel. Client makes HTTPS request to
https://{subdomain}.phlix.media/*β hub frames it asTYPE_HTTP_REQUESTβ server responds withTYPE_HTTP_RESPONSE. - Session init: Server sends
TYPE_HTTP_REQUESTwithmethod=REGISTER, not a separate HELLO frame.
Multiplexed WS relay (target, per architecture-hub.md) β
- Source:
docs/dev/architecture-hub.md("Relay tunnel design") - Wire (this plan): Binary framing
[4-byte seq][1-byte type][2-byte length][payload]for DATA/HEARTBEAT frames. JSON text for the initial HELLO handshake. - Mounts: Two separate mounts β
/relay/{id}(server connects outbound) and/client/{id}(client connects inbound) - Behavior: Remote client connects via WebSocket to hub; hub multiplexes the WS stream directly to the server over the server's outbound tunnel. Not an HTTP proxy β raw WebSocket frames are relayed.
- Session init: Server sends
{ "type": "hello", "enrollment_jwt": "...", "server_id": "..." }(JSON text sent immediately after WS handshake). Hub responds with{ "type": "hello_ack", "relay_session_id": "...", "tunnel_id": "..." }.
Decision β
The multiplexed WS relay (architecture-hub.md) is the target. The C.6 HTTP-proxy design is the existing ship, but it is being replaced. Both sides must be updated together β coordination with the detain/phlix-server repo is required for any change that affects the wire format or message types.
Context & Decisions β
| Decision | Rationale | Source |
|---|---|---|
Frame type constants live in Phlix\Shared\Relay\ | Both hub and server must agree on wire types; shared constants prevent drift | phlix-shared/src/Hub/ (existing pattern) |
Wire format: [4-byte seq][1-byte type][2-byte len][payload] for DATA frames | Consistent with existing RelayMessageFramer (seq+len big-endian); separate type byte avoids ambiguity | phlix-server/src/Hub/RelayMessageFramer.php |
| HELLO handshake is JSON text sent immediately after WS upgrade | architecture-hub.md shows JSON text exchange; avoids binary framing complexity for the single init message | docs/dev/architecture-hub.md "Relay tunnel design" |
| Leading 4-byte field = per-client channel id (relay-mux, v0.5.0) | Tunnel is reliable WS/TCP, so no ack/sequence counter is needed; the field is repurposed for per-client multiplexing (CLIENT_CONNECT/CLIENT_DISCONNECT/DATA), channel 0 for tunnel-scoped frames. See "Channel multiplexing" below. | Phlix\Shared\Relay\RelayFrame |
| Heartbeat: every 30 s, 10 s grace | NAT timeout windows; matches PHLIX_RELAY_PING_INTERVAL=30 in current server config | phlix-docs/docs/dev/relay-protocol.md |
| Close codes follow WebSocket RFC 6455 | 1000 normal, 1008 policy violation, 1011 server error, 1012 restart | Standard WS close |
TunnelManager in new Phlix\Hub\Relay\ namespace | architecture-hub.md Β§ Namespace map already shows Phlix\Hub\Relay\ | docs/dev/architecture-hub.md Β§ Namespace map |
Tunnel class represents per-server outbound WS | Clean separation: one Tunnel per server_id; multiple ClientConnection per tunnel | architecture-hub.md |
| Idle reaper every 60 s, 90 s stale threshold | Grace period > heartbeat interval avoids false positives | architecture-hub.md failure mode: idle reaper |
RelaySessionManager unchanged signatures | Public API already covers all needed operations | src/Hub/RelaySessionManager.php |
LogChannels::RELAY must exist before Phase 5 | All relay events route through this channel; ensure it is declared | src/Common/Logger/LogChannels.php |
Architecture β
phlix-server phlix-hub remote client
| | |
|===== [1] wss://hub/relay/{id] ==============================|======
| WS upgrade (auth JWT already validated by RelayController) |
| |
|--- JSON text ---> | |
| { "type": "hello", | |
| "enrollment_jwt": "eyJ...", | |
| "server_id": "..." } | |
| TunnelManager::acceptServer() |
| RelaySessionManager::registerServer() |
| |
|<-- JSON text --- | |
| { "type": "hello_ack", | |
| "relay_session_id": "...", | |
| "tunnel_id": "..." } | |
| |
| |<---- [3] wss://hub/client/{id} ----|
| | WS upgrade + auth (enrollment JWT)|
| | |
| TunnelManager::acceptClient() |
| |
|==== binary DATA frames [4-byte seq][1-byte type][2-byte len] =====|
|<======================== tunnel frames multiplexed ===============>|
| |
| bytes_out += frame.len | bytes_in += frame.len |
| last_frame_at = now() | last_frame_at = now() |
| |
|==[5]== TYPE_DISCONNECTED ======>|==== WS close [6] ============>|Components to create β
| Component | File | Responsibility |
|---|---|---|
TunnelManager | src/Relay/TunnelManager.php | Owns all active tunnels; maps server_id β Tunnel; routes client connections to the right server tunnel |
Tunnel | src/Relay/Tunnel.php | Per-server-outbound WS state: server WS handle, client list, sequence, heartbeat timer, byte counters |
ClientConnection | src/Relay/ClientConnection.php | Per-client inbound WS: client WS handle, mount path, last-frame timestamp |
Frame | src/Relay/Frame.php | Hub-side immutable value object: wraps Phlix\Shared\Relay\RelayFrame, adds toBytes() / parse() using the hub's FrameDecoder codec |
FrameDecoder | src/Relay/FrameDecoder.php | Implements Phlix\Shared\Relay\RelayWireCodec. Stateful streaming parser; handles partial frames. Also handles JSON HELLO/HELLO_ACK text encoding |
IdleReaper | src/Relay/IdleReaper.php | Periodic timer: scans all tunnels, closes any with last_frame_at stale > 90 s |
ClientMountController | src/Http/Controllers/ClientMountController.php | WS upgrade for GET /client/{server_id}; delegates to TunnelManager::acceptClient() |
HubServicesProvider (modify) | src/Common/Container/HubServicesProvider.php | Register TunnelManager, IdleReaper, start heartbeat timer in boot() |
RelayFrameTypeandRelayFramelive inphlix-shared(Phlix\Shared\Relay\). The hub imports them directly β no hub-side duplication of type constants.
Components to modify β
| Component | Change |
|---|---|
RelayController::handle() | After auth + WS upgrade, call TunnelManager::acceptServer() instead of 501 |
RelaySessionManager | Add recordBytesIn(string $sessionId, int $bytes): void; add touchLastFrame(string $sessionId): void |
HubServicesProvider | Register TunnelManager, IdleReaper, start heartbeat + reaper timers in boot() |
Router | Add GET /client/{server_id} route |
Wire Format β
Initial HELLO handshake (JSON text, immediately after WS upgrade) β
Server sends immediately after WS upgrade completes (before any binary frames):
β Hub: {"type":"hello","enrollment_jwt":"<ed25519-jwt>","server_id":"<uuid>"}
β Hub: {"type":"hello_ack","relay_session_id":"<uuid>","tunnel_id":"<uuid>"}If the JWT is invalid the hub closes the WS immediately with RFC 6455 close code 1008 (Policy Violation).
Binary data frames (after handshake) β
All subsequent frames use this binary encoding (all integers big-endian):
[4-byte channel/seq (uint32)][1-byte frame type][2-byte payload length (uint16)][N payload bytes]Maximum frame payload: 65535 bytes.
Channel multiplexing (the leading 4-byte field) β "relay-mux" (v0.5.0) β
The tunnel runs over a single reliable WebSocket/TCP connection, so the leading 4-byte field is not an ack/sequence/reordering counter. It is repurposed as a per-client channel id (uint32) so that multiple concurrent remote clients are demultiplexed over one server tunnel. The binary header layout is unchanged β only the semantics of the leading field changed.
- The hub assigns each
ClientConnectiona stable, monotonically increasing channel id (1, 2, 3, β¦) atCLIENT_CONNECTtime and records achannel β ClientConnectionmap (Tunnel::registerClient()). - Client-scoped frames carry the channel id in the leading field:
CLIENT_CONNECT/CLIENT_DISCONNECTβ channel id of the affected client.DATA(both directions) β channel id of the owning client. The hub re-tags clientβserverDATAwith the client's channel (Tunnel::sendClientData()); the server tags its responseDATAwith the same channel (RelayConsumer::sendDataFrame()).
- Routing:
- serverβclient
DATA: the hub delivers to the single client owning the frame's channel (Tunnel::sendToClient(channelId, frame)), replacing the old broadcast-to-all behaviour. - clientβserver
DATA: the server writes the bytes to the local connection mapped to the frame's channel (RelayConsumerchannel β AsyncTcpConnectionmap). - unknown/closed channel: a
DATAframe whose channel has no live mapping is dropped and logged (warning) on whichever side receives it.
- serverβclient
- Tunnel-scoped frames carry channel id 0:
HELLO_ACK,HEARTBEAT,DISCONNECTED,ERROR.
The JSON client_id / session_id payload on CLIENT_CONNECT / CLIENT_DISCONNECT is retained for logging/observability only β routing is keyed solely on the channel id. This is a pre-release (v0.5.0) protocol change with no backward compatibility (no flags/shims); the old single-active-client seq-as-counter behaviour is removed. The shared contract exposes Phlix\Shared\Relay\RelayFrame::channelId() as a semantic accessor for the leading field (it returns $seq).
Frame type mapping β
| Type constant | Value | Direction | Leading field | Payload |
|---|---|---|---|---|
TYPE_HELLO | 0x01 | SβH | n/a (JSON text) | JSON object (handled as text before binary mode) |
TYPE_HELLO_ACK | 0x02 | HβS | n/a (JSON text) | JSON object (handled as text before binary mode) |
TYPE_CLIENT_CONNECT | 0x03 | HβS | channel id | {"client_id":"<uuid>","session_id":"<uuid>"} (observability) |
TYPE_CLIENT_DISCONNECT | 0x04 | HβS | channel id | {"client_id":"<uuid>"} (observability) |
TYPE_DATA | 0x05 | SβHβC | channel id | raw bytes forwarded verbatim |
TYPE_HEARTBEAT | 0x06 | eitherβeither | 0 | empty |
TYPE_DISCONNECTED | 0x07 | HβC | 0 | {"reason":"..."} |
TYPE_ERROR | 0x08 | Hβany | 0 | {"code":"...","message":"..."} |
Note:
TYPE_HELLOandTYPE_HELLO_ACKare exchanged as JSON text before binary mode is entered. Binary mode begins immediately after thehello_ackis sent/received. The binary encoder/decoder is NOT used for the initial handshake.
Phase 0: Shared relay types in phlix-shared [PENDING] β
The relay wire protocol is the shared contract between phlix-server and phlix-hub. Type constants and codec interfaces live in phlix-shared (Phlix\Shared\Relay\*), and both implementations depend on them. Encoding logic stays in each repo (hub uses FrameDecoder in src/Relay/; server uses RelayMessageFramer in src/Hub/).
This phase requires changes in two repos simultaneously:
detain/phlix-shared(new files) anddetain/phlix-server(updateRelayMessageFramerto use shared constants).
- [ ] 0.1
Phlix\Shared\Relay\RelayFrameTypeβ PHP 8.3 backed enum with 8 cases:HELLO(0x01),HELLO_ACK(0x02),CLIENT_CONNECT(0x03),CLIENT_DISCONNECT(0x04),DATA(0x05),HEARTBEAT(0x06),DISCONNECTED(0x07),ERROR(0x08). Each case carriespublic const int value. - [ ] 0.2
Phlix\Shared\Relay\RelayWireCodecβ interface for the encoder/decoder pair. Defines:phppublic function encode(RelayFrameType $type, int $seq, string $payload): string; public function encodeHello(string $enrollmentJwt, string $serverId): string; // JSON text public function encodeHelloAck(string $relaySessionId, string $tunnelId): string; // JSON text public function decode(string $bytes): ?RelayFrame; // null if incomplete - [ ] 0.3
Phlix\Shared\Relay\RelayFrameβ immutable value object:type (RelayFrameType),seq (int),payload (string). Only for the binary protocol (not used for JSON HELLO frames). - [ ] 0.4
composer.jsonin phlix-shared β addPhlix\Shared\Relay\to autoloadpsr-4; bumpPhlix\Shared\Version::VERSION - [ ] 0.5 Server-side: update
RelayMessageFramerin phlix-server to implementRelayWireCodec, using sharedRelayFrameTypeconstants. Remove oldTYPE_HTTP_REQUEST/RESPONSE/PING/PONGconstants (or mark deprecated). AddencodeHello(),encodeHelloAck(). - [ ] 0.6 Update
RelayConsumerin phlix-server to send JSON HELLO immediately after WS handshake, then enter binary mode; handleCLIENT_CONNECT/CLIENT_DISCONNECTframe types from hub. - [ ] 0.7 All PHPStan level 9 and Psalm green on phlix-shared;
phpcs PSR-12clean; all checks green on phlix-server
Deliverable (phlix-shared): src/Relay/RelayFrameType.php, src/Relay/RelayWireCodec.php, src/Relay/RelayFrame.php; updated composer.json
Deliverable (phlix-server): updated src/Hub/RelayMessageFramer.php, updated src/Hub/RelayConsumer.php
Phase 1: Frame layer [PENDING] β
Implement the hub-side binary wire protocol using the shared codec interface. The hub's Relay\FrameDecoder implements RelayWireCodec.
- [ ] 1.1
Hub\Relay\RelayFrameTypeβ thin wrapper aroundPhlix\Shared\Relay\RelayFrameTypefor hub use (imports from shared, re-exports for convenience). Alternatively, the hub code importsPhlix\Shared\Relay\RelayFrameTypedirectly and this wrapper is not needed β decide in review. - [ ] 1.2
Hub\Relay\Frameβ hub-side immutable value object:type (RelayFrameType),seq (int),payload (string),toBytes(): string(binary encode using shared codec), staticparse(string $raw): ?self(binary decode, null if incomplete). Can extend or wrapPhlix\Shared\Relay\RelayFrame. - [ ] 1.3
Hub\Relay\FrameDecoderβ implementsRelayWireCodec. Stateful streaming parser using an internal buffer.append(string $chunk): \Generator<Frame>yields complete frames. Handles partial frames correctly. Also implementsencodeHello()andencodeHelloAck()(JSON text output) for the handshake phase. - [ ] 1.4 Unit tests for
Frame::parse/toBytesround-trip for allRelayFrameTypevariants at boundary sizes (0, 1, 255, 256, 65534, 65535 payload bytes) - [ ] 1.5 Verify
FrameDecoderhandles: empty chunk, partial header, partial payload, multiple complete frames in one chunk
Deliverable: src/Relay/Frame.php, src/Relay/FrameDecoder.php, tests/Unit/Relay/FrameTest.php, tests/Unit/Relay/FrameDecoderTest.php
Note:
RelayFrameTypeandRelayFramelive inphlix-shared(Phase 0). The hub imports them from there β no duplication.
Phase 2: Server-side outbound connection [PENDING] β
Accept the server's outbound WS, authenticate via HELLO, and manage Tunnel state.
- [ ] 2.1
Tunnelclass βserverId,serverWs,clientConnections: SplObjectStorage,seq (int),openedAt,lastFrameAt,status (PENDING|ACTIVE|CLOSING|CLOSED) - [ ] 2.2
Tunnel::sendToServer(Frame): voidβ encode + write to server WS, callRelaySessionManager::recordBytesOut() - [ ] 2.3
Tunnel::broadcastToClients(Frame): voidβ encode once, write to all connected client WS, callrecordBytesIn()per client - [ ] 2.4
Tunnel::onServerMessage(string): voidβ after HELLO exchange: decode binary frame viaFrameDecoder; handle DATAβbroadcast, HEARTBEATβtouchlastFrameAt; other typesβlog+close - [ ] 2.5
Tunnel::onServerClose(): voidβ mark CLOSED, callRelaySessionManager::closeSession(), close all client WS withTYPE_DISCONNECTED - [ ] 2.6
TunnelManager::acceptServer(string $serverId, WorkermanConnection $ws): voidβ register tunnel in map, callRelaySessionManager::registerServer(), start heartbeat timer for this tunnel - [ ] 2.7
TunnelManager::getTunnelForServer(string $serverId): ?Tunnel - [ ] 2.8
TunnelManager::closeTunnel(string $serverId, string $reason): voidβ mark closed, sendTYPE_DISCONNECTEDto all clients, close server WS, callRelaySessionManager::closeSession(), remove from map - [ ] 2.9
RelaySessionManager::recordBytesIn(string $sessionId, int $bytes): voidβ mirrorsrecordBytesOut(); updatebytes_in+last_frame_at - [ ] 2.10
RelaySessionManager::touchLastFrame(string $sessionId): voidβ updatelast_frame_atwithout byte delta (for HEARTBEAT frames)
Deliverable: src/Relay/Tunnel.php, src/Relay/TunnelManager.php, updated src/Hub/RelaySessionManager.php, tests/Unit/Relay/TunnelTest.php
β οΈ Server-side coordination required: phlix-server/src/Hub/RelayMessageFramer.php currently implements the C.6 binary format. The server must be updated in parallel to use the new frame types (0x01β0x08) in Phase 2. Specifically: RelayMessageFramer constants need new values (or a new RelayMultiplexer class), and RelayConsumer needs to send the JSON HELLO before entering binary mode and handle TYPE_CLIENT_CONNECT / TYPE_CLIENT_DISCONNECT frames.
Phase 3: Client-side inbound mount [PENDING] β
Accept remote client WS connections and route them to the correct Tunnel.
- [ ] 3.1
ClientConnectionclass βclientWs,serverId,clientId,lastFrameAt - [ ] 3.2
ClientConnection::send(Frame): voidβ encode + write to client WS - [ ] 3.3
ClientConnection::onMessage(string $data): voidβ decode binary frame viaFrameDecoder; onlyTYPE_DATAframes are forwarded to the server; other types are logged and discarded - [ ] 3.4
ClientConnection::onClose(): voidβ notifyTunnelto sendTYPE_CLIENT_DISCONNECTEDupstream - [ ] 3.5
Tunnel::registerClient(ClientConnection): voidβ add toclientConnections, sendTYPE_CLIENT_CONNECTto server - [ ] 3.6
Tunnel::removeClient(ClientConnection): voidβ remove fromclientConnections, sendTYPE_CLIENT_DISCONNECTto server - [ ] 3.7
TunnelManager::acceptClient(string $serverId, WorkermanConnection $ws): voidβ look up tunnel byserverId; if not found, close client WS with 503 body{"error":"SERVER_OFFLINE","code":"relay.server_not_connected"} - [ ] 3.8
ClientMountControllerβGET /client/{server_id}route. Validate same enrollment JWT asRelayController; callTunnelManager::acceptClient()on WS upgrade - [ ] 3.9 Router β add
GET /client/{server_id}alongside the existingPOST /relay/{id}route
Deliverable: src/Relay/ClientConnection.php, src/Http/Controllers/ClientMountController.php, updated router, tests/Unit/Relay/ClientConnectionTest.php
Phase 4: Heartbeat + idle reaper [PENDING] β
Keep tunnels alive and reap stale ones.
- [ ] 4.1
Tunnel::sendHeartbeat(): voidβ encode + writeTYPE_HEARTBEATframe to server; touchlastFrameAtviatouchLastFrame() - [ ] 4.2
TunnelManager::startHeartbeatTimer(): voidβ registerTimer::add(30, ...)inHubServicesProvider::boot()that iterates all tunnels and callssendHeartbeat()on each - [ ] 4.3
Tunnel::onHeartbeatOrData(): voidβ any valid binary frame from server toucheslastFrameAt(already handled inonServerMessage) - [ ] 4.4
IdleReaperclass βTimer::add(60, ...)inHubServicesProvider::boot(); iterates all tunnels; closes any wherenow - tunnel.lastFrameAt > 90with reason"timeout" - [ ] 4.5
TunnelManager::allTunnels(): \Generator<string, Tunnel>β yields[serverId => Tunnel]for allACTIVEtunnels; used by both heartbeat timer and idle reaper to avoid iterating a snapshot - [ ] 4.6
TunnelManager::closeTunnel(string $serverId, string $reason): voidβ already defined in Phase 2; wire it to idle reaper
Deliverable: updated TunnelManager, new src/Relay/IdleReaper.php, HubServicesProvider update, tests/Unit/Relay/IdleReaperTest.php
Phase 5: Observability + byte accounting [PENDING] β
Wiring bytes_in, bytes_out, last_frame_at throughout the pipeline.
- [ ] 5.1
Tunnel::sendToServer()βrecordBytesOut(sessionId, strlen(frame)) - [ ] 5.2
Tunnel::broadcastToClients()βrecordBytesIn(sessionId, strlen(frame))per recipient - [ ] 5.3 Every
TYPE_DATAframe decoded inTunnel::onServerMessage()βtouchLastFrame() - [ ] 5.4 Structured log events:
INFO: tunnel open, tunnel close, client connect, client disconnect, idle reaped, heartbeat sentWARNING: protocol error, unexpected frame typeERROR: frame decode error, DB write failure in session manager
- [ ] 5.5 Confirm
LogChannels::RELAYexists inLogChannels.php. If not, add it and updateLoggerFactoryinitialization. Route all relay events through this channel. - [ ] 5.6
relay_urlinaccessInfoβ already implemented viaservers.subdomain+public_domain; no change needed
Phase 6: Backwards compatibility + error handling [PENDING] β
Ensure error responses and restart behavior are correct.
- [ ] 6.1
RelayController::handle()after auth: on WS upgrade, read the initial JSON HELLO, validate JWT, callTunnelManager::acceptServer(). IfTunnelManagerthrows (DB error, JWT validation failure), return HTTP 500 with{"error":"INTERNAL_ERROR","code":"relay.tunnel_init_failed"}and close the WS. - [ ] 6.2 If client connects to
/client/{id}but no active tunnel, close with HTTP 503 body{"error":"SERVER_OFFLINE", "code":"relay.server_not_connected"}andRetry-After: 30header (RFC 9110 Β§15.7.4). - [ ] 6.3 On hub restart: all
relay_sessions.closed_at IS NULLrows are orphaned. Idle reaper handles within 90 s. Servers reconnect automatically via HubClient retry loop. - [ ] 6.4 Server disconnect + reconnect: if a
HELLOarrives for aserver_idthat already has an activeTunnel, close the old tunnel first (closeTunnel(oldId, 'server_replaced')) before accepting the new one. Aserver_idmust not have two simultaneous tunnels. - [ ] 6.5
RelayRouter::routeBySubdomain()β unchanged. It still looks uprelay_sessionsbyserver_id. No impact from multiplex design. - [ ] 6.6 The 501 response body shape from the old
RelayControllerwas:{"error":"NOT_IMPLEMENTED","code":"relay.ws_not_implemented",...}. This is fully replaced; no backwards compat needed for the old JSON body.
Phase 7: Integration + tests [PENDING] β
- [ ] 7.1
FrameTestβ round-trip encode/decode all 8 frame types, boundary sizes (0, 1, 255, 256, 65534, 65535 payload bytes) - [ ] 7.2
FrameDecoderTestβ empty chunk, partial header, partial payload, multiple frames in one chunk, corrupted data - [ ] 7.3
TunnelTestβ mock Workerman WS; verifybroadcastToClients()encodes once and writes to two clients; verifysendToServer()callsrecordBytesOut - [ ] 7.4
TunnelManagerTestβ verifyacceptServer()registers tunnel,acceptClient()503s when no tunnel,getTunnelForServer()returns correct tunnel, sameserver_idreconnect closes old tunnel - [ ] 7.5
ClientConnectionTestβ non-DATA frame is discarded; client close triggers upstream notification - [ ] 7.6
IdleReaperTestβ two tunnels, one stale (>90 s), verify only the stale one is reaped - [ ] 7.7
RelaySessionManagerβ addrecordBytesIn()test, addtouchLastFrame()test - [ ] 7.8 All PHPStan level 9 and Psalm errorLevel 1 green on new code
- [ ] 7.9
phpcs --standard=PSR12clean on all new files
Phase 8: Staggered rollout plan [PENDING] β
Each phase is a separate PR, merged and pulled before the next starts. phlix-shared and phlix-server changes for Phase 0 land first.
| PR | Content | Dependencies |
|---|---|---|
| 9.0 | Phase 0 β Shared types: Phlix\Shared\Relay\* in phlix-shared | none |
| 9.0s | Phase 0 server-side: updated RelayMessageFramer + RelayConsumer in phlix-server | 9.0 |
| 9.1 | Phase 1 β Hub frame layer: Frame, FrameDecoder using shared codec | 9.0, 9.0s |
| 9.2 | Phase 2 β Server-side hub: Tunnel, TunnelManager, RelaySessionManager additions, RelayController WS upgrade | 9.1 |
| 9.3 | Phase 3 β Client-side: ClientConnection, ClientMountController, routing | 9.2 |
| 9.4 | Phase 4 β Heartbeat + reaper: IdleReaper, timers, closeTunnel | 9.3 |
| 9.5 | Phase 5 β Observability: byte accounting wiring, structured logs, LogChannels::RELAY | 9.4 |
| 9.6 | Phase 6 β Error handling: all error responses, restart behavior, final tests | 9.5 |
Cross-repo dependency: Phase 1 requires Phase 0 (shared types) to be merged in phlix-shared. The server-side Phase 0s must also be merged in phlix-server before Phase 1 can be complete. All three repos (phlix-shared, phlix-server, phlix-hub) must have their Phase 0/0s changes before any Phase 1 work can start on the hub.
Failure Modes Summary β
| Failure | Detection | Response |
|---|---|---|
| Server WS drops | Tunnel::onServerClose() | Mark CLOSED; send TYPE_DISCONNECTED to all clients; close clients; call closeSession() |
| Client WS drops | ClientConnection::onClose() | Remove from clientConnections; send TYPE_CLIENT_DISCONNECT to server |
| Idle tunnel (no frames 90 s) | IdleReaper every 60 s | Close tunnel with timeout; notify clients + server |
| Hub restart | All WS connections drop | Orphaned relay_sessions rows; idle reaper cleans within 90 s; servers reconnect |
| Server sends malformed binary frame | FrameDecoder throws | Log ERROR; close tunnel with RFC 6455 1011; call closeSession('protocol-error') |
| Client sends non-DATA frame | ClientConnection::onMessage() | Log WARNING; send TYPE_ERROR to client; do not forward to server |
| Client connects to offline server | TunnelManager::acceptClient() | HTTP 503 + Retry-After: 30 |
| Server sends HELLO for already-connected server | TunnelManager::acceptServer() | Close old tunnel first; accept new |
Related Files β
phlix-shared (new β Phase 0) β
| File | Role |
|---|---|
src/Relay/RelayFrameType.php | PHP 8.3 backed enum: 8 frame type constants (0x01β0x08) |
src/Relay/RelayWireCodec.php | Interface for encode/decode operations |
src/Relay/RelayFrame.php | Immutable value object: type, seq, payload |
composer.json | Add Phlix\Shared\Relay\ to PSR-4 autoload |
Hub (this repo) β
| File | Role |
|---|---|
src/Http/Controllers/RelayController.php | Current 501 stub β replace with WS upgrade + delegate to TunnelManager |
src/Hub/RelaySessionManager.php | DB access β extend with recordBytesIn, touchLastFrame |
src/Hub/RelayRouter.php | Subdomain-based routing β unchanged |
src/Common/Container/HubServicesProvider.php | DI β register TunnelManager, IdleReaper, start timers in boot() |
src/Common/Logger/LogChannels.php | Must declare const RELAY = 'relay' |
src/Relay/Frame.php | Hub-side frame value object wrapping shared RelayFrame |
src/Relay/FrameDecoder.php | Hub codec β implements RelayWireCodec; streaming parser |
Server (detain/phlix-server β Phase 0s) β
| File | Change needed |
|---|---|
src/Hub/RelayMessageFramer.php | Implement RelayWireCodec; use shared RelayFrameType constants; remove old C.6 type constants |
src/Hub/RelayConsumer.php | Send JSON HELLO immediately after WS upgrade; enter binary mode; handle CLIENT_CONNECT / CLIENT_DISCONNECT frames |
composer.json | Add detain/phlix-shared:^0.3 constraint |
Docs β
| File | Note |
|---|---|
docs/dev/relay-protocol.md (phlix-docs) | Documents C.6 HTTP-proxy design; add a clearly-labeled section for the multiplexed design |
docs/hub-admin/relay-tuning.md | Stub β populate with heartbeat interval, idle timeout, bandwidth limit settings |
docs/hub/remote-access.md | Update to reference both relay designs |
Notes β
- 2026-05-24: Plan authored per Section 9 of
phlix_update.md. Primary references:docs/dev/architecture-hub.md("Relay tunnel design") β target designphlix-docs/docs/dev/relay-protocol.mdβ C.6 HTTP-proxy design (current ship)phlix-server/src/Hub/RelayMessageFramer.phpβ existing binary framingphlix-server/src/Hub/RelayConsumer.phpβ existing server-side relay clientsrc/Hub/RelaySessionManager.phpβ hub-side session tracking
- Workerman 5 WebSocket: the
onMessagecallback receives raw data after WS handshake. Binary frames are raw bytes. The WS layer does NOT parse our application-level binary framing β we handle it ourselves viaFrameDecoder. - The
Relaynamespace (src/Relay/) does not exist yet β create it and register PSR-4 incomposer.jsonunderPhlix\\Hub\\Relay\\. - Both
IdleReapertimer and heartbeat timer must be registered inHubServicesProvider::boot()so they survive hub worker restarts. worker_nodeinrelay_sessionsmust be populated on tunnel open (useposix_gethostname()or a configured node name). This matters for multi-node deployments where worker process identity is needed.- Frame decoder must handle the case where the WS connection closes mid-frame β treat incomplete frame as a protocol error.