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
This commit is contained in:
meiseayoung
2026-01-04 13:38:12 +08:00
parent 4e2c6a90c3
commit 6ab6d3b99e
22 changed files with 8545 additions and 4 deletions
+392
View File
@@ -0,0 +1,392 @@
// test_websocket_subprotocol.v - Property test for WebSocket subprotocol negotiation
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
// Validates: Requirements 4.4
//
// *For any* WebSocket handshake where the client requests subprotocols via
// Sec-WebSocket-Protocol header and the server has matching protocols configured,
// the response SHALL include exactly one matching protocol in the
// Sec-WebSocket-Protocol response header.
module main
import rand
import time
import net.http
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== WebSocket Subprotocol Negotiation Property Test Summary ===')
println('Total tests: ${stats.total_tests}')
println('Passed: ${stats.passed_tests}')
println('Failed: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 All property tests passed!')
} else {
println('⚠️ ${stats.failed_tests} property test(s) failed')
}
}
// ============================================================================
// Subprotocol Negotiation Functions (copied from websocket.v for testing)
// ============================================================================
// negotiate_subprotocol_from_header - Negotiate subprotocol from client header value
// This is a simplified version that takes the header value directly
fn negotiate_subprotocol_from_header(client_protocols_header string, supported_protocols []string) string {
if supported_protocols.len == 0 {
return ''
}
if client_protocols_header.len == 0 {
return ''
}
// Parse comma-separated list
requested := client_protocols_header.split(',').map(it.trim_space())
// Find first matching protocol
for proto in requested {
if proto in supported_protocols {
return proto
}
}
return ''
}
// create_handshake_response - Create WebSocket handshake response
fn create_handshake_response(key string, protocol string) http.Response {
mut headers := http.new_header()
headers.add_custom('Upgrade', 'websocket') or {}
headers.add_custom('Connection', 'Upgrade') or {}
headers.add_custom('Sec-WebSocket-Accept', key) or {}
if protocol.len > 0 {
headers.add_custom('Sec-WebSocket-Protocol', protocol) or {}
}
return http.Response{
status_code: 101
header: headers
body: ''
}
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn generate_random_protocol_name() string {
// Generate protocol names like: chat, graphql-ws, mqtt, soap, wamp, etc.
prefixes := ['chat', 'graphql', 'mqtt', 'soap', 'wamp', 'stomp', 'xmpp', 'json', 'binary', 'proto']
suffixes := ['', '-ws', '-v1', '-v2', '.subprotocol', '-rpc', '-stream']
prefix := prefixes[rand.int_in_range(0, prefixes.len) or { 0 }]
suffix := suffixes[rand.int_in_range(0, suffixes.len) or { 0 }]
return prefix + suffix
}
fn generate_random_protocol_list(count int) []string {
mut protocols := []string{cap: count}
for _ in 0 .. count {
proto := generate_random_protocol_name()
if proto !in protocols {
protocols << proto
}
}
return protocols
}
fn protocols_to_header(protocols []string) string {
return protocols.join(', ')
}
// ============================================================================
// Property 9: Subprotocol Negotiation
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
// Validates: Requirements 4.4
//
// *For any* WebSocket handshake where the client requests subprotocols via
// Sec-WebSocket-Protocol header and the server has matching protocols configured,
// the response SHALL include exactly one matching protocol in the
// Sec-WebSocket-Protocol response header.
// ============================================================================
fn test_property_9_matching_protocol_selected() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate server's supported protocols (1-5 protocols)
server_count := rand.int_in_range(1, 6) or { 2 }
server_protocols := generate_random_protocol_list(server_count)
// Generate client's requested protocols (1-5 protocols)
client_count := rand.int_in_range(1, 6) or { 2 }
mut client_protocols := generate_random_protocol_list(client_count)
// Ensure at least one matching protocol by adding one from server list
matching_idx := rand.int_in_range(0, server_protocols.len) or { 0 }
matching_protocol := server_protocols[matching_idx]
// Insert matching protocol at random position in client list
insert_pos := rand.int_in_range(0, client_protocols.len + 1) or { 0 }
client_protocols.insert(insert_pos, matching_protocol)
// Create header string
client_header := protocols_to_header(client_protocols)
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Verify exactly one protocol is returned
if result.len == 0 {
println(' Iteration ${i}: Expected a matching protocol to be selected')
println(' Server protocols: ${server_protocols}')
println(' Client protocols: ${client_protocols}')
return false
}
// Verify the selected protocol is in both lists
if result !in server_protocols {
println(' Iteration ${i}: Selected protocol not in server list')
println(' Selected: ${result}')
println(' Server protocols: ${server_protocols}')
return false
}
if result !in client_protocols {
println(' Iteration ${i}: Selected protocol not in client list')
println(' Selected: ${result}')
println(' Client protocols: ${client_protocols}')
return false
}
}
return true
}
fn test_property_9_no_match_returns_empty() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate completely different protocol lists
server_protocols := ['server-proto-a', 'server-proto-b', 'server-proto-c']
client_protocols := ['client-proto-x', 'client-proto-y', 'client-proto-z']
client_header := protocols_to_header(client_protocols)
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Verify no protocol is selected when there's no match
if result.len > 0 {
println(' Iteration ${i}: Expected empty result when no match')
println(' Server protocols: ${server_protocols}')
println(' Client protocols: ${client_protocols}')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_first_match_selected() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
// Server supports multiple protocols
server_protocols := ['proto-a', 'proto-b', 'proto-c', 'proto-d']
// Client requests protocols in specific order with multiple matches
// The first matching protocol in client's list should be selected
client_protocols := ['proto-c', 'proto-a', 'proto-d']
client_header := protocols_to_header(client_protocols)
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Verify the first matching protocol (proto-c) is selected
if result != 'proto-c' {
println(' Iteration ${i}: Expected first matching protocol to be selected')
println(' Expected: proto-c')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_response_includes_protocol() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
// Generate a random protocol
protocol := generate_random_protocol_name()
// Create handshake response with protocol
response := create_handshake_response('dummy-accept-key', protocol)
// Verify response includes the protocol header
proto_header := response.header.get_custom('Sec-WebSocket-Protocol') or { '' }
if proto_header != protocol {
println(' Iteration ${i}: Response should include Sec-WebSocket-Protocol header')
println(' Expected: ${protocol}')
println(' Got: ${proto_header}')
return false
}
// Verify status code is 101
if response.status_code != 101 {
println(' Iteration ${i}: Response status should be 101')
println(' Got: ${response.status_code}')
return false
}
}
return true
}
fn test_property_9_empty_protocol_not_in_response() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for _ in 0 .. test_iterations {
// Create handshake response without protocol
response := create_handshake_response('dummy-accept-key', '')
// Verify response does NOT include the protocol header
proto_header := response.header.get_custom('Sec-WebSocket-Protocol') or { '' }
if proto_header.len > 0 {
println(' Response should not include Sec-WebSocket-Protocol when empty')
println(' Got: ${proto_header}')
return false
}
}
return true
}
fn test_property_9_empty_server_protocols() bool {
rand.seed([u32(time.now().unix()), u32(22222)])
for i in 0 .. test_iterations {
// Client requests protocols but server has none configured
client_protocols := generate_random_protocol_list(3)
client_header := protocols_to_header(client_protocols)
// Empty server protocols
server_protocols := []string{}
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Should return empty when server has no protocols
if result.len > 0 {
println(' Iteration ${i}: Expected empty when server has no protocols')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_empty_client_header() bool {
rand.seed([u32(time.now().unix()), u32(33333)])
for i in 0 .. test_iterations {
// Server has protocols but client sends empty header
server_protocols := generate_random_protocol_list(3)
// Negotiate with empty client header
result := negotiate_subprotocol_from_header('', server_protocols)
// Should return empty when client sends no protocols
if result.len > 0 {
println(' Iteration ${i}: Expected empty when client sends no protocols')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_whitespace_handling() bool {
// Test that whitespace around protocol names is handled correctly
server_protocols := ['chat', 'graphql-ws', 'mqtt']
// Various whitespace formats
test_cases := [
'chat, graphql-ws, mqtt', // Standard spacing
'chat,graphql-ws,mqtt', // No spaces
' chat , graphql-ws , mqtt ', // Extra spaces
'chat ,graphql-ws, mqtt', // Mixed spacing
]
for i, client_header in test_cases {
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Should find 'chat' as first match regardless of whitespace
if result != 'chat' {
println(' Test case ${i}: Whitespace handling failed')
println(' Header: "${client_header}"')
println(' Expected: chat')
println(' Got: ${result}')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Subprotocol Negotiation Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
// Validates: Requirements 4.4
stats.run_property_test('Property 9: Matching Protocol Selected', test_property_9_matching_protocol_selected)
stats.run_property_test('Property 9: No Match Returns Empty', test_property_9_no_match_returns_empty)
stats.run_property_test('Property 9: First Match Selected', test_property_9_first_match_selected)
stats.run_property_test('Property 9: Response Includes Protocol', test_property_9_response_includes_protocol)
stats.run_property_test('Property 9: Empty Protocol Not In Response', test_property_9_empty_protocol_not_in_response)
stats.run_property_test('Property 9: Empty Server Protocols', test_property_9_empty_server_protocols)
stats.run_property_test('Property 9: Empty Client Header', test_property_9_empty_client_header)
stats.run_property_test('Property 9: Whitespace Handling', test_property_9_whitespace_handling)
// Print test summary
stats.print_summary()
}