Files
meiseayoung 6ab6d3b99e feat(websocket): add WebSocket Helper with RFC 6455 compliance
Add comprehensive WebSocket support for v-hono framework, inspired by Hono.js WebSocket Helper.

Features:
- upgrade_websocket() function for WebSocket route handlers
- Event-based callbacks: on_open, on_message, on_close, on_error
- WSContext with send(), send_bytes(), send_json(), close() methods
- Connection state tracking (connecting, open, closing, closed)
- Configurable options: ping_interval, max_message_size, timeout, protocols
- Subprotocol negotiation support
- Ping/pong keep-alive handling
- Message size validation with 1009 close code
- Integration with picoev and uSockets backends

Protocol Compliance:
- RFC 6455 compliant handshake with Sec-WebSocket-Accept computation
- Frame encoding/decoding with proper masking
- Close handshake with status codes and reasons
- Text and binary message frame support

Testing:
- 13 property-based test files with 82 tests total
- Validates handshake, frame encoding, masking, JSON round-trip
- Tests context preservation, route patterns, connection state
- All existing tests pass (no regressions)

Files added:
- websocket.v: Core WebSocket implementation
- examples/websocket_demo.v: Chat room example
- examples/websocket_test.html: Browser test client
- tests/test_websocket_*.v: Property-based tests

Files modified:
- app.v: Add ws() method for WebSocket routes
- middleware.v: Export WebSocket types and functions
- picoev_server.v: WebSocket upgrade detection
- usockets_server.v: WebSocket upgrade detection
- README.md: WebSocket documentation
2026-01-04 13:38:12 +08:00

337 lines
13 KiB
V

// WebSocket Demo - Chat Room Example for v-hono
// This example demonstrates all WebSocket event handlers and configuration options.
//
// Run with: v run examples/websocket_demo.v
// Then open a browser and navigate to http://127.0.0.1:3000
// Or use a WebSocket client to connect to ws://127.0.0.1:3000/ws/chat/general
//
// Features demonstrated:
// - WebSocket upgrade handling
// - Event handlers (on_open, on_message, on_close, on_error)
// - Route parameters (/ws/chat/:room)
// - Query parameters (?username=xxx)
// - Subprotocol negotiation
// - Custom configuration options (ping interval, max message size, timeout)
// - JSON message handling
// - Binary message handling
// - Middleware integration with WebSocket routes
module main
import net.http
import time
import os
import hono
fn main() {
mut app := hono.Hono.new()
// =========================================================================
// Middleware - Runs before WebSocket upgrade
// =========================================================================
// Logger middleware - logs all requests including WebSocket upgrades
app.use(fn (mut c hono.Context, next fn (mut hono.Context) http.Response) http.Response {
start := time.now()
response := next(mut c)
duration := time.since(start)
println('[${time.now().format_ss()}] ${c.req.method} ${c.path} - ${response.status_code} (${duration})')
return response
})
// =========================================================================
// Static HTML page for testing WebSocket
// =========================================================================
app.get('/', fn (mut c hono.Context) http.Response {
// Try to load the HTML file from the examples directory
html_content := os.read_file('examples/websocket_test.html') or {
// Fallback to a simple HTML page if file not found
return c.html(get_simple_test_page())
}
return c.html(html_content)
})
// =========================================================================
// Basic WebSocket Echo Example
// =========================================================================
// Simple echo WebSocket - echoes back any message received
app.ws('/ws/echo', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Echo] Client connected')
ws.send('Welcome to the echo server!') or {
println('[Echo] Failed to send welcome: ${err}')
}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
if event.is_binary {
println('[Echo] Received binary message: ${event.data_bytes.len} bytes')
ws.send_bytes(event.data_bytes) or {
println('[Echo] Failed to echo binary: ${err}')
}
} else {
println('[Echo] Received: ${event.data}')
ws.send('Echo: ${event.data}') or {
println('[Echo] Failed to echo: ${err}')
}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Echo] Client disconnected - Code: ${event.code}, Reason: ${event.reason}, Clean: ${event.was_clean}')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Echo] Error: ${error}')
}
}
})
// =========================================================================
// Chat Room WebSocket with Route Parameters
// =========================================================================
// Chat room WebSocket - demonstrates route parameters and query parameters
// URL format: /ws/chat/:room?username=xxx
app.ws('/ws/chat/:room', fn (c hono.Context) hono.WSEvents {
// Access route parameters from the HTTP context
room := c.params['room'] or { 'general' }
username := c.query['username'] or { 'anonymous' }
println('[Chat] User "${username}" joining room "${room}"')
return hono.WSEvents{
on_open: fn [room, username] (mut ws hono.WSContext) {
// Access preserved context data
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user} connected')
// Send welcome message as JSON
welcome := '{"type":"system","message":"Welcome to room ${actual_room}, ${actual_user}!"}'
ws.send_json(welcome) or {
println('[Chat] Failed to send welcome: ${err}')
}
// Broadcast join notification
join_msg := '{"type":"join","user":"${actual_user}","room":"${actual_room}"}'
ws.send_json(join_msg) or {}
}
on_message: fn [room, username] (event hono.WSMessageEvent, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user}: ${event.data}')
// Echo the message back with metadata
response := '{"type":"message","user":"${actual_user}","room":"${actual_room}","content":"${event.data}"}'
ws.send_json(response) or {
println('[Chat] Failed to send response: ${err}')
}
}
on_close: fn [room, username] (event hono.WSCloseEvent, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user} disconnected (code: ${event.code})')
}
on_error: fn [room, username] (error string, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] Error for ${actual_user}: ${error}')
}
}
})
// =========================================================================
// WebSocket with Custom Configuration Options
// =========================================================================
// Configured WebSocket - demonstrates custom options
app.ws('/ws/configured', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Configured] Client connected with custom settings')
ws.send('Connected with custom configuration!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
println('[Configured] Message: ${event.data}')
ws.send('Received: ${event.data}') or {}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Configured] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Configured] Error: ${error}')
}
}
}, hono.WebSocketOptions{
ping_interval: 15000 // Send ping every 15 seconds
max_message_size: 65536 // Max 64KB messages
timeout: 30000 // 30 second timeout
protocols: ['chat', 'json'] // Supported subprotocols
})
// =========================================================================
// WebSocket with Subprotocol Negotiation
// =========================================================================
// Subprotocol WebSocket - demonstrates protocol negotiation
app.ws('/ws/protocol', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
protocol := ws.protocol
println('[Protocol] Client connected with protocol: ${protocol}')
if protocol == 'json' {
ws.send_json('{"status":"connected","protocol":"json"}') or {}
} else {
ws.send('Connected with protocol: ${protocol}') or {}
}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
protocol := ws.protocol
println('[Protocol] Message (${protocol}): ${event.data}')
if protocol == 'json' {
ws.send_json('{"echo":"${event.data}"}') or {}
} else {
ws.send('Echo: ${event.data}') or {}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Protocol] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Protocol] Error: ${error}')
}
}
}, hono.WebSocketOptions{
protocols: ['json', 'text', 'binary']
})
// =========================================================================
// Binary Data WebSocket
// =========================================================================
// Binary WebSocket - demonstrates binary message handling
app.ws('/ws/binary', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Binary] Client connected')
ws.send('Binary WebSocket ready. Send binary data!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
if event.is_binary {
println('[Binary] Received ${event.data_bytes.len} bytes')
// Echo back the binary data
ws.send_bytes(event.data_bytes) or {
println('[Binary] Failed to echo: ${err}')
}
// Also send a text confirmation
ws.send('Received ${event.data_bytes.len} bytes of binary data') or {}
} else {
println('[Binary] Received text: ${event.data}')
ws.send('Please send binary data, not text!') or {}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Binary] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Binary] Error: ${error}')
}
}
})
// =========================================================================
// REST API endpoints for demonstration
// =========================================================================
app.get('/api/rooms', fn (mut c hono.Context) http.Response {
return c.json('{"rooms":["general","tech","random"]}')
})
app.get('/api/status', fn (mut c hono.Context) http.Response {
return c.json('{"status":"running","websocket_endpoints":["/ws/echo","/ws/chat/:room","/ws/configured","/ws/protocol","/ws/binary"]}')
})
// =========================================================================
// Start the server
// =========================================================================
println('============================================================')
println('WebSocket Demo Server')
println('============================================================')
println('')
println('Server starting on http://127.0.0.1:3000')
println('')
println('WebSocket Endpoints:')
println(' ws://127.0.0.1:3000/ws/echo - Simple echo server')
println(' ws://127.0.0.1:3000/ws/chat/:room - Chat room with route params')
println(' ws://127.0.0.1:3000/ws/configured - Custom configuration demo')
println(' ws://127.0.0.1:3000/ws/protocol - Subprotocol negotiation')
println(' ws://127.0.0.1:3000/ws/binary - Binary data handling')
println('')
println('HTTP Endpoints:')
println(' http://127.0.0.1:3000/ - Test page')
println(' http://127.0.0.1:3000/api/rooms - List rooms')
println(' http://127.0.0.1:3000/api/status - Server status')
println('')
println('============================================================')
app.listen(':3000')
}
// Simple fallback test page when HTML file is not found
fn get_simple_test_page() string {
return '<!DOCTYPE html>
<html>
<head>
<title>v-hono WebSocket Demo</title>
<style>
body{font-family:sans-serif;max-width:800px;margin:50px auto;padding:20px}
h1{color:#667eea}
.card{background:#f5f5f5;padding:20px;margin:20px 0;border-radius:8px}
input,button{padding:10px;margin:5px;border-radius:4px;border:1px solid #ccc}
button{background:#667eea;color:white;border:none;cursor:pointer}
button:hover{background:#5a6fd6}
#messages{height:200px;overflow-y:auto;background:white;padding:10px;border:1px solid #ccc;border-radius:4px}
.msg{padding:5px;margin:2px 0;border-radius:4px}
.sent{background:#e3f2fd}
.received{background:#e8f5e9}
.system{background:#fff3e0}
</style>
</head>
<body>
<h1>v-hono WebSocket Demo</h1>
<div class="card">
<h3>Connection</h3>
<input type="text" id="url" value="ws://127.0.0.1:3000/ws/echo" style="width:300px">
<button onclick="connect()">Connect</button>
<button onclick="disconnect()">Disconnect</button>
<span id="status">Disconnected</span>
</div>
<div class="card">
<h3>Send Message</h3>
<input type="text" id="msg" placeholder="Type message..." style="width:300px">
<button onclick="send()">Send</button>
</div>
<div class="card">
<h3>Messages</h3>
<div id="messages"></div>
</div>
<script>
var ws=null;
function log(t,c){var m=document.getElementById("messages");m.innerHTML+="<div class=msg "+c+">"+t+"</div>";m.scrollTop=m.scrollHeight}
function connect(){if(ws)return;ws=new WebSocket(document.getElementById("url").value);ws.onopen=function(){document.getElementById("status").textContent="Connected";log("Connected","system")};ws.onmessage=function(e){log("Received: "+e.data,"received")};ws.onclose=function(){document.getElementById("status").textContent="Disconnected";log("Disconnected","system");ws=null};ws.onerror=function(){log("Error","system")}}
function disconnect(){if(ws)ws.close()}
function send(){if(!ws)return;var m=document.getElementById("msg").value;ws.send(m);log("Sent: "+m,"sent");document.getElementById("msg").value=""}
</script>
</body>
</html>'
}