WEBSOCKET MVP PLAN
vhttpd WebSocket MVP Plan
Goal
Keep vhttpd focused on transport/runtime concerns:
HTTP / WebSocket protocol
-> vhttpd
-> php-worker transport
-> PHP app
The first WebSocket version should:
- reuse V’s built-in
net.websocket - avoid reimplementing handshake, frame parsing, ping/pong, and close handling
- fit the existing
vhttpd -> php-workerarchitecture - keep PHP userland focused on business messages, not protocol internals
For the next single-node cross-worker phase, see:
Key findings
1. veb.Context already exposes the TCP connection
veb.Context contains:
conn &net.TcpConntakeover_conn()
This gives vhttpd a safe escape hatch for upgraded connections:
- detect
Upgrade: websocket - call
ctx.takeover_conn() - hand
ctx.conntonet.websocket
2. net.websocket already supports server-side handshake on an existing connection
vlib/net/websocket/websocket_server.v exposes:
websocket.ServerServer.handle_handshake(mut conn net.TcpConn, key string) !&ServerClient
This is the most important reuse point for vhttpd.
It means vhttpd does not need to run a second listener or implement RFC6455 itself.
3. Current worker contract is request/response or request/stream, not duplex
Today the worker protocol supports:
- one-shot JSON response
- server-driven stream frames
WebSocket needs a new transport shape:
vhttpd -> worker: open + message + close eventsworker -> vhttpd: send + close commands
So the WebSocket MVP should extend the worker frame contract, not try to fake WebSocket as SSE.
Recommended MVP scope
In scope
- HTTP upgrade handling in
vhttpd - text-frame WebSocket messages
- close events
- worker-level duplex frame channel
- minimal PHP handler API
Out of scope for MVP
- binary frames
- permessage-deflate
- subprotocol negotiation
- room/broadcast abstractions
- cross-worker connection routing
- cluster/distributed fanout
Architecture
Browser
-> HTTP GET with Upgrade: websocket
-> vhttpd route/proxy branch
-> ctx.takeover_conn()
-> net.websocket handles handshake + socket loop
-> websocket events bridged to php-worker over unix socket
-> PHP app decides send/close actions
-> vhttpd writes back via net.websocket client object
Upgrade entry point
The lowest-risk entry point is inside the worker proxy path:
- current HTTP proxy entry:
proxy_worker_response(...) - new branch before normal request encoding:
- detect websocket upgrade headers
- switch to
proxy_worker_websocket(...)
This keeps:
- static files unchanged
/events/streamunchanged- existing HTTP worker logic unchanged
WebSocket request detection
Treat a request as WebSocket only when all are true:
method == GET- header
Upgrade: websocket - header
ConnectioncontainsUpgrade - header
Sec-WebSocket-Keyis present
If validation fails, return normal HTTP 400/426.
Worker frame protocol
Use the same framing layer as today:
[4-byte big-endian length][json payload]
vhttpd -> worker frames
Open
{
"mode": "websocket",
"event": "open",
"id": "trace-id-or-conn-id",
"path": "/ws/chat",
"query": { "room": "general" },
"headers": { "host": "127.0.0.1:19881" },
"remote_addr": "127.0.0.1",
"request_id": "req-123",
"trace_id": "req-123"
}
Message
{
"mode": "websocket",
"event": "message",
"id": "trace-id-or-conn-id",
"opcode": "text",
"data": "hello"
}
Close
{
"mode": "websocket",
"event": "close",
"id": "trace-id-or-conn-id",
"code": 1000,
"reason": "client closed"
}
worker -> vhttpd frames
Accept
Sent after worker receives open.
{
"mode": "websocket",
"event": "accept",
"id": "trace-id-or-conn-id"
}
This lets PHP reject or accept a connection before message processing starts.
Send
{
"mode": "websocket",
"event": "send",
"id": "trace-id-or-conn-id",
"opcode": "text",
"data": "hello from php"
}
Close
{
"mode": "websocket",
"event": "close",
"id": "trace-id-or-conn-id",
"code": 1000,
"reason": "done"
}
Error
{
"mode": "websocket",
"event": "error",
"id": "trace-id-or-conn-id",
"error_class": "worker_runtime_error",
"error": "message"
}
PHP handler shape
The PHP side should not work with raw RFC6455 details.
The smallest useful contract is:
return new VPhp\VSlim\WebSocket\App(
onOpen: function (Connection $conn, array $open): void {
$conn->send('connected');
},
onMessage: function (Connection $conn, string $message): void {
$conn->send('echo: ' . $message);
},
onClose: function (Connection $conn, int $code, string $reason): void {
},
);
Alternative callable contract:
return function (array $frame, VPhp\VHttpd\PhpWorker\WebSocket\Connection $conn): void {
if (($frame['event'] ?? '') === 'open') {
$conn->accept();
return;
}
if (($frame['event'] ?? '') === 'message') {
$conn->send('echo: ' . ($frame['data'] ?? ''));
}
};
For MVP, Connection should support only:
accept(): voidsend(string $data): voidclose(int $code = 1000, string $reason = ''): voidid(): string
Why not return a PSR-7 response?
Because after upgrade there is no longer a normal HTTP response lifecycle.
This is closer to:
- connection session
- duplex event loop
- command channel
So WebSocket should be a distinct worker contract, not a special Response.
vhttpd-side lifecycle
On incoming HTTP upgrade request
- validate headers
- connect to selected PHP worker socket
- send worker
openframe - wait for
acceptorclose - if accepted:
- call
ctx.takeover_conn() - create
websocket.Server - call
handle_handshake(mut ctx.conn, key) - wire websocket callbacks
- call
On WebSocket message from client
- receive
Messagefromnet.websocket - if text frame:
- send worker
messageframe
- send worker
- read worker output frames until:
- zero or more
send - optional
close
- zero or more
On close
- send worker
close - close unix socket to worker
- let
net.websocketcomplete socket shutdown
First implementation constraints
To reduce risk, MVP should use:
- one unix socket worker connection per websocket client connection
- one PHP handler instance per accepted websocket connection
- synchronous message roundtrip per received text message
This is intentionally simple:
- easy to reason about
- easy to test
- matches current worker transport shape
It can be optimized later.
Test plan
V-side integration tests
- upgrade request rejected without valid websocket headers
- successful upgrade with echo app
- worker can send text frames back
- worker-triggered close propagates to client
- client-triggered close reaches worker
PHP worker tests
- bootstrap returns websocket app object
open -> acceptmessage -> sendcloseframe handling- invalid websocket handler returns contract error
Suggested implementation order
- Add websocket frame structs to
vhttpd - Add
proxy_worker_websocket(...)invhttpd - Add PHP worker websocket dispatcher and connection helper
- Add minimal PHP echo demo
- Add end-to-end test using a websocket client
Non-goals for the first patch
Do not block MVP on:
- room state
- pub/sub
- Redis fanout
- auth framework integration
- binary payload codecs
The first deliverable is simply:
vhttpdcan accept a WebSocket upgrade and bridge text messages to a PHP worker using V’s built-innet.websocket.