WEBSOCKET PHASE2 IMPLEMENTATION PLAN
WebSocket Phase 2 Implementation Plan
This document turns the earlier phase-2 direction into an implementable plan.
Phase 1 solved single-node multi-worker fanout by moving room membership and
connection metadata into vhttpd, while still keeping one PHP worker attached
to one live WebSocket session.
Phase 2 changes that execution model:
- WebSocket connections stay in
vhttpd - PHP workers no longer own a connection for its full lifetime
- each
open/message/closeevent is dispatched as a short-lived worker task - workers return a command list
vhttpdexecutes those commands against its local connection hub
That is the point where connection count and worker count are finally decoupled.
Goals
- make thousands of idle WebSocket connections possible without thousands of PHP workers
- keep room membership, metadata, and presence in
vhttpd - let PHP workers stay stateless between websocket events
- reuse existing
vhttpdlocal hub and room fanout logic as much as possible - keep the worker transport simple and purpose-built
Non-goals for Phase 2
- multi-node cluster protocol
- durable message persistence
- binary frame support beyond the current minimal model
- replacing the existing phase-1 model immediately
Phase 2 should coexist with phase 1 first.
Current MVP status
The first MVP slice is now implemented behind worker.websocket_dispatch = true.
What is already verified:
- websocket connections stay in
vhttpd - worker interaction is short-lived request/response per event
- command-list execution works for
send,close,join,broadcast,set_meta - two websocket clients can connect and chat with
worker.pool_size = 1
That confirms the core architectural goal:
- connection count and PHP worker count are no longer tightly coupled
Reusable vlib pieces
Use these:
net.websocketprotocol, handshake, ping/pong, frame parsing, close lifecyclesyncchannels, mutexes, rwmutexes, threads, cond varsveb.sseas a reference for long-lived connection ownership on the server side
Use only as helpers:
eventbusoptional internal notifications, metrics hooks, non-critical observers
Do not use as the phase-2 core:
sync.pooldesigned for parallel batch work, not event-driven connection dispatchpooldesigned for borrow/return resources, not websocket session orchestration
High-level model
browser
-> vhttpd websocket connection
-> local websocket hub
-> event dispatcher
-> short-lived php-worker task
-> worker command list
-> vhttpd executes commands
-> browser
The worker does not keep the socket open.
Core responsibilities
vhttpd
- own all live websocket connections
- own room membership and metadata
- build event payloads for
open,message,close - choose an available worker for each event
- execute returned commands:
sendclosejoinleavebroadcastsend_toset_metaclear_meta
- enforce timeouts and fallback error handling
php-worker
- receive one websocket event payload
- load bootstrap app
- invoke the event handler
- collect command list
- return commands
- exit request scope for that event
userland app
- process event payload
- return actions, not ownership
- treat connection state as externalized metadata
Data structures in vhttpd
These are the main in-memory structures.
struct WsConnState {
id string
request_id string
trace_id string
path string
remote_addr string
mut:
client &websocket.Client = unsafe { nil }
headers map[string]string
query map[string]string
metadata map[string]string
rooms map[string]bool
opened_at i64
last_seen i64
}
struct WsEventEnvelope {
kind string // open|message|close
conn_id string
request_id string
trace_id string
path string
remote_addr string
query map[string]string
headers map[string]string
opcode string
data string
code int
reason string
rooms []string
metadata map[string]string
room_members map[string][]string
member_metadata map[string]map[string]string
room_counts map[string]int
presence_users map[string][]string
}
struct WsCommand {
event string
id string
target_id string
room string
key string
value string
except_id string
opcode string
data string
code int
reason string
}
The existing phase-1 hub maps can mostly stay:
conn_id -> WsConnStateroom -> membersconn_id -> roomsconn_id -> metadata
Phase 2 should reuse those instead of inventing a second registry.
Event dispatch loop
Open
vhttpdaccepts websocket upgradenet.websocketcompletes handshakevhttpdregisters the connection locallyvhttpdbuilds anopenevent envelopevhttpdpicks an available worker- worker returns command list
vhttpdexecutes commands
There is no worker-owned duplex loop.
Message
- client sends text frame
vhttpdreceives frame vianet.websocketvhttpdupdateslast_seenvhttpdbuildsmessageenvelope using current metadata and room snapshotvhttpdsends one request to a free worker- worker returns command list
vhttpdexecutes commands
Close
- connection closes or is closed by server
vhttpdbuildscloseenvelope using final snapshots- worker may return final commands like
broadcast room left vhttpdexecutes commandsvhttpdunregisters the connection
Worker transport shape
Phase 2 should not reuse the current long-lived mode=websocket duplex loop as-is.
Instead, add a request/response event-dispatch mode.
Request frame
{
"mode": "websocket_dispatch",
"event": "message",
"id": "conn-123",
"request_id": "req-123",
"trace_id": "req-123",
"path": "/ws",
"remote_addr": "127.0.0.1:54321",
"query": {"room": "lobby", "user": "alice"},
"headers": {"origin": "http://127.0.0.1:19891"},
"opcode": "text",
"data": "{\"text\":\"hello\"}",
"rooms": ["lobby"],
"metadata": {"user": "alice", "presence": "online"},
"room_members": {"lobby": ["conn-123", "conn-456"]},
"member_metadata": {
"conn-123": {"user": "alice", "presence": "online"},
"conn-456": {"user": "bob", "presence": "online"}
},
"room_counts": {"lobby": 2},
"presence_users": {"lobby": ["alice", "bob"]}
}
Response frame
{
"mode": "websocket_dispatch",
"event": "result",
"id": "conn-123",
"commands": [
{"event": "broadcast", "room": "lobby", "data": "...", "opcode": "text", "except_id": "conn-123"},
{"event": "send", "id": "conn-123", "data": "...", "opcode": "text"}
]
}
For errors:
{
"mode": "websocket_dispatch",
"event": "error",
"id": "conn-123",
"error_class": "worker_runtime_error",
"error": "uncaught exception"
}
Command list
The command list should remain intentionally small:
sendclosejoinleavebroadcastsend_toset_metaclear_meta
These already exist in phase 1 and should be reused.
Suggested vhttpd internals
Introduce a dedicated dispatcher path instead of folding everything into the current phase-1 websocket loop.
fn (mut app App) dispatch_ws_event(envelope WsEventEnvelope) ![]WsCommand
fn (mut app App) execute_ws_commands(conn_id string, commands []WsCommand)
fn (mut app App) build_ws_envelope(kind string, state &WsConnState, ...) WsEventEnvelope
The websocket callbacks should become thin:
fn websocket_on_message(...) {
envelope := app.build_ws_envelope('message', ...)
commands := app.dispatch_ws_event(envelope) or {
// close or send error policy
return
}
app.execute_ws_commands(conn_id, commands)
}
Suggested php-worker internals
Add a new dispatch branch:
if (($payload['mode'] ?? '') === 'websocket_dispatch') {
return self::handleWebSocketDispatch($payload, $app);
}
That branch should:
- load bootstrap
- resolve app websocket handler
- create a transient command collector
- invoke the matching event callback
- return collected commands
The collector can mimic the current Connection API, but instead of
writing frames directly to the worker transport, it appends commands to an array.
PHP API direction
Phase 1 API:
$conn->send(...)$conn->join(...)$conn->broadcast(...)
These methods can stay.
The implementation underneath changes:
- phase 1: direct command frames written immediately on the live worker connection
- phase 2: commands are collected and returned at the end of the event dispatch
That means most userland code should survive unchanged.
This is a major advantage of keeping the command vocabulary small and stable.
VSlim impact
VSlim\WebSocket\App can remain the public entry point, but some assumptions should be softened:
- process-local connection ownership becomes legacy behavior
remember/forget/join/leave/broadcastonVSlim\WebSocket\Appare still useful for tests and single-worker mode- production multi-worker websocket code should prefer connection-level commands
Longer term, VSlim\WebSocket\App should become explicitly event-driven rather than connection-owned.
Stream implications
The same principle applies to stream mode:
- long-lived SSE / text streams should eventually be connection-hosted in
vhttpd - worker should emit stream commands or chunk decisions, not hold the stream socket forever
This should be a later phase, after websocket dispatch proves out.
Rollout plan
Step 1
Keep phase 1 and phase 2 side-by-side.
- current websocket mode remains available
- add a config flag or app capability flag to opt into
websocket_dispatch
Step 2
Implement message dispatch first.
openandmessagedispatch- command list execution
- simple
close
Step 3
Add close event dispatch with final snapshots.
Step 4
Run both demos:
- phase 1 room demo
- phase 2 message-dispatch echo/chat demo
Step 5
Only after phase 2 is stable, decide whether phase 1 remains as legacy mode or is deprecated.
First MVP slice
The smallest useful phase-2 MVP is:
- text websocket only
open,message,close- commands:
sendclosejoinbroadcastset_meta
- no binary frames
- no external cluster bus
If that works, we have already solved the core scaling problem:
- many connections can stay open in
vhttpd - only active events use PHP workers
That is the architectural win we want.