Skip to content

WebSocket Multiplexed Relay Tunnel β€” Implementation Plan ​

status: implemented
phase: complete
updated: 2026-05-25

Implemented (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 from Application::run(); TunnelManager/Tunnel multiplex frames per channel, FrameEncoder/ FrameDecoder handle the wire format, and IdleReaper sweeps idle sessions. ClientMountController::handle() returns 426/501 steering plain HTTP callers to the WS worker. See phlix-hub CHANGELOG.md (Section 9) and the tests/Unit/Relay/ suite. The remaining caveat is TLS: relay certificates are provisioned out-of-band (ACME is not implemented) β€” see docs/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-side RelayMessageFramer in phlix-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 as TYPE_HTTP_REQUEST β†’ server responds with TYPE_HTTP_RESPONSE.
  • Session init: Server sends TYPE_HTTP_REQUEST with method=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 ​

DecisionRationaleSource
Frame type constants live in Phlix\Shared\Relay\Both hub and server must agree on wire types; shared constants prevent driftphlix-shared/src/Hub/ (existing pattern)
Wire format: [4-byte seq][1-byte type][2-byte len][payload] for DATA framesConsistent with existing RelayMessageFramer (seq+len big-endian); separate type byte avoids ambiguityphlix-server/src/Hub/RelayMessageFramer.php
HELLO handshake is JSON text sent immediately after WS upgradearchitecture-hub.md shows JSON text exchange; avoids binary framing complexity for the single init messagedocs/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 graceNAT timeout windows; matches PHLIX_RELAY_PING_INTERVAL=30 in current server configphlix-docs/docs/dev/relay-protocol.md
Close codes follow WebSocket RFC 64551000 normal, 1008 policy violation, 1011 server error, 1012 restartStandard WS close
TunnelManager in new Phlix\Hub\Relay\ namespacearchitecture-hub.md Β§ Namespace map already shows Phlix\Hub\Relay\docs/dev/architecture-hub.md Β§ Namespace map
Tunnel class represents per-server outbound WSClean separation: one Tunnel per server_id; multiple ClientConnection per tunnelarchitecture-hub.md
Idle reaper every 60 s, 90 s stale thresholdGrace period > heartbeat interval avoids false positivesarchitecture-hub.md failure mode: idle reaper
RelaySessionManager unchanged signaturesPublic API already covers all needed operationssrc/Hub/RelaySessionManager.php
LogChannels::RELAY must exist before Phase 5All relay events route through this channel; ensure it is declaredsrc/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 ​

ComponentFileResponsibility
TunnelManagersrc/Relay/TunnelManager.phpOwns all active tunnels; maps server_id β†’ Tunnel; routes client connections to the right server tunnel
Tunnelsrc/Relay/Tunnel.phpPer-server-outbound WS state: server WS handle, client list, sequence, heartbeat timer, byte counters
ClientConnectionsrc/Relay/ClientConnection.phpPer-client inbound WS: client WS handle, mount path, last-frame timestamp
Framesrc/Relay/Frame.phpHub-side immutable value object: wraps Phlix\Shared\Relay\RelayFrame, adds toBytes() / parse() using the hub's FrameDecoder codec
FrameDecodersrc/Relay/FrameDecoder.phpImplements Phlix\Shared\Relay\RelayWireCodec. Stateful streaming parser; handles partial frames. Also handles JSON HELLO/HELLO_ACK text encoding
IdleReapersrc/Relay/IdleReaper.phpPeriodic timer: scans all tunnels, closes any with last_frame_at stale > 90 s
ClientMountControllersrc/Http/Controllers/ClientMountController.phpWS upgrade for GET /client/{server_id}; delegates to TunnelManager::acceptClient()
HubServicesProvider (modify)src/Common/Container/HubServicesProvider.phpRegister TunnelManager, IdleReaper, start heartbeat timer in boot()

RelayFrameType and RelayFrame live in phlix-shared (Phlix\Shared\Relay\). The hub imports them directly β€” no hub-side duplication of type constants.

Components to modify ​

ComponentChange
RelayController::handle()After auth + WS upgrade, call TunnelManager::acceptServer() instead of 501
RelaySessionManagerAdd recordBytesIn(string $sessionId, int $bytes): void; add touchLastFrame(string $sessionId): void
HubServicesProviderRegister TunnelManager, IdleReaper, start heartbeat + reaper timers in boot()
RouterAdd 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 ClientConnection a stable, monotonically increasing channel id (1, 2, 3, …) at CLIENT_CONNECT time and records a channel β†’ ClientConnection map (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β†’server DATA with the client's channel (Tunnel::sendClientData()); the server tags its response DATA with 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 (RelayConsumer channel β†’ AsyncTcpConnection map).
    • unknown/closed channel: a DATA frame whose channel has no live mapping is dropped and logged (warning) on whichever side receives it.
  • 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 constantValueDirectionLeading fieldPayload
TYPE_HELLO0x01S→Hn/a (JSON text)JSON object (handled as text before binary mode)
TYPE_HELLO_ACK0x02H→Sn/a (JSON text)JSON object (handled as text before binary mode)
TYPE_CLIENT_CONNECT0x03H→Schannel id{"client_id":"<uuid>","session_id":"<uuid>"} (observability)
TYPE_CLIENT_DISCONNECT0x04H→Schannel id{"client_id":"<uuid>"} (observability)
TYPE_DATA0x05S↔H↔Cchannel idraw bytes forwarded verbatim
TYPE_HEARTBEAT0x06either→either0empty
TYPE_DISCONNECTED0x07H→C0{"reason":"..."}
TYPE_ERROR0x08H↔any0{"code":"...","message":"..."}

Note: TYPE_HELLO and TYPE_HELLO_ACK are exchanged as JSON text before binary mode is entered. Binary mode begins immediately after the hello_ack is 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) and detain/phlix-server (update RelayMessageFramer to 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 carries public const int value.
  • [ ] 0.2 Phlix\Shared\Relay\RelayWireCodec β€” interface for the encoder/decoder pair. Defines:
    php
    public 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.json in phlix-shared β€” add Phlix\Shared\Relay\ to autoload psr-4; bump Phlix\Shared\Version::VERSION
  • [ ] 0.5 Server-side: update RelayMessageFramer in phlix-server to implement RelayWireCodec, using shared RelayFrameType constants. Remove old TYPE_HTTP_REQUEST/RESPONSE/PING/PONG constants (or mark deprecated). Add encodeHello(), encodeHelloAck().
  • [ ] 0.6 Update RelayConsumer in phlix-server to send JSON HELLO immediately after WS handshake, then enter binary mode; handle CLIENT_CONNECT / CLIENT_DISCONNECT frame types from hub.
  • [ ] 0.7 All PHPStan level 9 and Psalm green on phlix-shared; phpcs PSR-12 clean; 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 around Phlix\Shared\Relay\RelayFrameType for hub use (imports from shared, re-exports for convenience). Alternatively, the hub code imports Phlix\Shared\Relay\RelayFrameType directly 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), static parse(string $raw): ?self (binary decode, null if incomplete). Can extend or wrap Phlix\Shared\Relay\RelayFrame.
  • [ ] 1.3 Hub\Relay\FrameDecoder β€” implements RelayWireCodec. Stateful streaming parser using an internal buffer. append(string $chunk): \Generator<Frame> yields complete frames. Handles partial frames correctly. Also implements encodeHello() and encodeHelloAck() (JSON text output) for the handshake phase.
  • [ ] 1.4 Unit tests for Frame::parse / toBytes round-trip for all RelayFrameType variants at boundary sizes (0, 1, 255, 256, 65534, 65535 payload bytes)
  • [ ] 1.5 Verify FrameDecoder handles: 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: RelayFrameType and RelayFrame live in phlix-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 Tunnel class β€” serverId, serverWs, clientConnections: SplObjectStorage, seq (int), openedAt, lastFrameAt, status (PENDING|ACTIVE|CLOSING|CLOSED)
  • [ ] 2.2 Tunnel::sendToServer(Frame): void β€” encode + write to server WS, call RelaySessionManager::recordBytesOut()
  • [ ] 2.3 Tunnel::broadcastToClients(Frame): void β€” encode once, write to all connected client WS, call recordBytesIn() per client
  • [ ] 2.4 Tunnel::onServerMessage(string): void β€” after HELLO exchange: decode binary frame via FrameDecoder; handle DATAβ†’broadcast, HEARTBEATβ†’touch lastFrameAt; other typesβ†’log+close
  • [ ] 2.5 Tunnel::onServerClose(): void β€” mark CLOSED, call RelaySessionManager::closeSession(), close all client WS with TYPE_DISCONNECTED
  • [ ] 2.6 TunnelManager::acceptServer(string $serverId, WorkermanConnection $ws): void β€” register tunnel in map, call RelaySessionManager::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, send TYPE_DISCONNECTED to all clients, close server WS, call RelaySessionManager::closeSession(), remove from map
  • [ ] 2.9 RelaySessionManager::recordBytesIn(string $sessionId, int $bytes): void β€” mirrors recordBytesOut(); update bytes_in + last_frame_at
  • [ ] 2.10 RelaySessionManager::touchLastFrame(string $sessionId): void β€” update last_frame_at without 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 ClientConnection class β€” 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 via FrameDecoder; only TYPE_DATA frames are forwarded to the server; other types are logged and discarded
  • [ ] 3.4 ClientConnection::onClose(): void β€” notify Tunnel to send TYPE_CLIENT_DISCONNECTED upstream
  • [ ] 3.5 Tunnel::registerClient(ClientConnection): void β€” add to clientConnections, send TYPE_CLIENT_CONNECT to server
  • [ ] 3.6 Tunnel::removeClient(ClientConnection): void β€” remove from clientConnections, send TYPE_CLIENT_DISCONNECT to server
  • [ ] 3.7 TunnelManager::acceptClient(string $serverId, WorkermanConnection $ws): void β€” look up tunnel by serverId; 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 as RelayController; call TunnelManager::acceptClient() on WS upgrade
  • [ ] 3.9 Router β€” add GET /client/{server_id} alongside the existing POST /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 + write TYPE_HEARTBEAT frame to server; touch lastFrameAt via touchLastFrame()
  • [ ] 4.2 TunnelManager::startHeartbeatTimer(): void β€” register Timer::add(30, ...) in HubServicesProvider::boot() that iterates all tunnels and calls sendHeartbeat() on each
  • [ ] 4.3 Tunnel::onHeartbeatOrData(): void β€” any valid binary frame from server touches lastFrameAt (already handled in onServerMessage)
  • [ ] 4.4 IdleReaper class β€” Timer::add(60, ...) in HubServicesProvider::boot(); iterates all tunnels; closes any where now - tunnel.lastFrameAt > 90 with reason "timeout"
  • [ ] 4.5 TunnelManager::allTunnels(): \Generator<string, Tunnel> β€” yields [serverId => Tunnel] for all ACTIVE tunnels; 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_DATA frame decoded in Tunnel::onServerMessage() β†’ touchLastFrame()
  • [ ] 5.4 Structured log events:
    • INFO: tunnel open, tunnel close, client connect, client disconnect, idle reaped, heartbeat sent
    • WARNING: protocol error, unexpected frame type
    • ERROR: frame decode error, DB write failure in session manager
  • [ ] 5.5 Confirm LogChannels::RELAY exists in LogChannels.php. If not, add it and update LoggerFactory initialization. Route all relay events through this channel.
  • [ ] 5.6 relay_url in accessInfo β€” already implemented via servers.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, call TunnelManager::acceptServer(). If TunnelManager throws (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"} and Retry-After: 30 header (RFC 9110 Β§15.7.4).
  • [ ] 6.3 On hub restart: all relay_sessions.closed_at IS NULL rows are orphaned. Idle reaper handles within 90 s. Servers reconnect automatically via HubClient retry loop.
  • [ ] 6.4 Server disconnect + reconnect: if a HELLO arrives for a server_id that already has an active Tunnel, close the old tunnel first (closeTunnel(oldId, 'server_replaced')) before accepting the new one. A server_id must not have two simultaneous tunnels.
  • [ ] 6.5 RelayRouter::routeBySubdomain() β€” unchanged. It still looks up relay_sessions by server_id. No impact from multiplex design.
  • [ ] 6.6 The 501 response body shape from the old RelayController was: {"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; verify broadcastToClients() encodes once and writes to two clients; verify sendToServer() calls recordBytesOut
  • [ ] 7.4 TunnelManagerTest β€” verify acceptServer() registers tunnel, acceptClient() 503s when no tunnel, getTunnelForServer() returns correct tunnel, same server_id reconnect 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 β€” add recordBytesIn() test, add touchLastFrame() test
  • [ ] 7.8 All PHPStan level 9 and Psalm errorLevel 1 green on new code
  • [ ] 7.9 phpcs --standard=PSR12 clean 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.

PRContentDependencies
9.0Phase 0 β€” Shared types: Phlix\Shared\Relay\* in phlix-sharednone
9.0sPhase 0 server-side: updated RelayMessageFramer + RelayConsumer in phlix-server9.0
9.1Phase 1 β€” Hub frame layer: Frame, FrameDecoder using shared codec9.0, 9.0s
9.2Phase 2 β€” Server-side hub: Tunnel, TunnelManager, RelaySessionManager additions, RelayController WS upgrade9.1
9.3Phase 3 β€” Client-side: ClientConnection, ClientMountController, routing9.2
9.4Phase 4 β€” Heartbeat + reaper: IdleReaper, timers, closeTunnel9.3
9.5Phase 5 β€” Observability: byte accounting wiring, structured logs, LogChannels::RELAY9.4
9.6Phase 6 β€” Error handling: all error responses, restart behavior, final tests9.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 ​

FailureDetectionResponse
Server WS dropsTunnel::onServerClose()Mark CLOSED; send TYPE_DISCONNECTED to all clients; close clients; call closeSession()
Client WS dropsClientConnection::onClose()Remove from clientConnections; send TYPE_CLIENT_DISCONNECT to server
Idle tunnel (no frames 90 s)IdleReaper every 60 sClose tunnel with timeout; notify clients + server
Hub restartAll WS connections dropOrphaned relay_sessions rows; idle reaper cleans within 90 s; servers reconnect
Server sends malformed binary frameFrameDecoder throwsLog ERROR; close tunnel with RFC 6455 1011; call closeSession('protocol-error')
Client sends non-DATA frameClientConnection::onMessage()Log WARNING; send TYPE_ERROR to client; do not forward to server
Client connects to offline serverTunnelManager::acceptClient()HTTP 503 + Retry-After: 30
Server sends HELLO for already-connected serverTunnelManager::acceptServer()Close old tunnel first; accept new

phlix-shared (new β€” Phase 0) ​

FileRole
src/Relay/RelayFrameType.phpPHP 8.3 backed enum: 8 frame type constants (0x01–0x08)
src/Relay/RelayWireCodec.phpInterface for encode/decode operations
src/Relay/RelayFrame.phpImmutable value object: type, seq, payload
composer.jsonAdd Phlix\Shared\Relay\ to PSR-4 autoload

Hub (this repo) ​

FileRole
src/Http/Controllers/RelayController.phpCurrent 501 stub β€” replace with WS upgrade + delegate to TunnelManager
src/Hub/RelaySessionManager.phpDB access β€” extend with recordBytesIn, touchLastFrame
src/Hub/RelayRouter.phpSubdomain-based routing β€” unchanged
src/Common/Container/HubServicesProvider.phpDI β€” register TunnelManager, IdleReaper, start timers in boot()
src/Common/Logger/LogChannels.phpMust declare const RELAY = 'relay'
src/Relay/Frame.phpHub-side frame value object wrapping shared RelayFrame
src/Relay/FrameDecoder.phpHub codec β€” implements RelayWireCodec; streaming parser

Server (detain/phlix-server β€” Phase 0s) ​

FileChange needed
src/Hub/RelayMessageFramer.phpImplement RelayWireCodec; use shared RelayFrameType constants; remove old C.6 type constants
src/Hub/RelayConsumer.phpSend JSON HELLO immediately after WS upgrade; enter binary mode; handle CLIENT_CONNECT / CLIENT_DISCONNECT frames
composer.jsonAdd detain/phlix-shared:^0.3 constraint

Docs ​

FileNote
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.mdStub β€” populate with heartbeat interval, idle timeout, bandwidth limit settings
docs/hub/remote-access.mdUpdate 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 design
    • phlix-docs/docs/dev/relay-protocol.md β€” C.6 HTTP-proxy design (current ship)
    • phlix-server/src/Hub/RelayMessageFramer.php β€” existing binary framing
    • phlix-server/src/Hub/RelayConsumer.php β€” existing server-side relay client
    • src/Hub/RelaySessionManager.php β€” hub-side session tracking
  • Workerman 5 WebSocket: the onMessage callback 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 via FrameDecoder.
  • The Relay namespace (src/Relay/) does not exist yet β€” create it and register PSR-4 in composer.json under Phlix\\Hub\\Relay\\.
  • Both IdleReaper timer and heartbeat timer must be registered in HubServicesProvider::boot() so they survive hub worker restarts.
  • worker_node in relay_sessions must be populated on tunnel open (use posix_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.

BSD-3-Clause