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:
@@ -0,0 +1,578 @@
|
||||
// test_websocket_ping_pong.v - Property test for WebSocket ping/pong handling
|
||||
// Feature: websocket-helper, Property 11: Ping/Pong Handling
|
||||
// Validates: Requirements 7.4
|
||||
//
|
||||
// *For any* ping frame received from a client, the server SHALL respond with
|
||||
// a pong frame containing the same payload data.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
// WebSocket opcodes (RFC 6455)
|
||||
const ws_opcode_ping = u8(0x9)
|
||||
const ws_opcode_pong = u8(0xA)
|
||||
|
||||
// WebSocket Ready State
|
||||
enum WSReadyState {
|
||||
connecting = 0
|
||||
open = 1
|
||||
closing = 2
|
||||
closed = 3
|
||||
}
|
||||
|
||||
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 Ping/Pong 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frame Encoding/Decoding (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
fn encode_ws_frame(opcode u8, payload []u8, masked bool) []u8 {
|
||||
payload_len := payload.len
|
||||
mut frame := []u8{cap: 14 + payload_len}
|
||||
|
||||
frame << u8(0x80 | (opcode & 0x0F))
|
||||
|
||||
mut mask_bit := u8(0)
|
||||
if masked {
|
||||
mask_bit = 0x80
|
||||
}
|
||||
|
||||
if payload_len <= 125 {
|
||||
frame << mask_bit | u8(payload_len)
|
||||
} else if payload_len <= 65535 {
|
||||
frame << mask_bit | u8(126)
|
||||
frame << u8(payload_len >> 8)
|
||||
frame << u8(payload_len & 0xFF)
|
||||
} else {
|
||||
frame << mask_bit | u8(127)
|
||||
for i := 7; i >= 0; i-- {
|
||||
frame << u8((payload_len >> (i * 8)) & 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
if masked {
|
||||
mask_key := [u8(0x12), 0x34, 0x56, 0x78]
|
||||
frame << mask_key[0]
|
||||
frame << mask_key[1]
|
||||
frame << mask_key[2]
|
||||
frame << mask_key[3]
|
||||
|
||||
for i, b in payload {
|
||||
frame << b ^ mask_key[i % 4]
|
||||
}
|
||||
} else {
|
||||
frame << payload
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
fn decode_ws_frame(data []u8) !(bool, u8, []u8) {
|
||||
if data.len < 2 {
|
||||
return error('Frame too short')
|
||||
}
|
||||
|
||||
fin := (data[0] & 0x80) != 0
|
||||
opcode := data[0] & 0x0F
|
||||
|
||||
masked := (data[1] & 0x80) != 0
|
||||
mut payload_len := u64(data[1] & 0x7F)
|
||||
mut offset := 2
|
||||
|
||||
if payload_len == 126 {
|
||||
if data.len < 4 {
|
||||
return error('Frame too short for extended length')
|
||||
}
|
||||
payload_len = u64(data[2]) << 8 | u64(data[3])
|
||||
offset = 4
|
||||
} else if payload_len == 127 {
|
||||
if data.len < 10 {
|
||||
return error('Frame too short for 64-bit length')
|
||||
}
|
||||
payload_len = 0
|
||||
for i := 0; i < 8; i++ {
|
||||
payload_len = (payload_len << 8) | u64(data[2 + i])
|
||||
}
|
||||
offset = 10
|
||||
}
|
||||
|
||||
mut mask_key := [4]u8{}
|
||||
if masked {
|
||||
if data.len < offset + 4 {
|
||||
return error('Frame too short for mask key')
|
||||
}
|
||||
mask_key[0] = data[offset]
|
||||
mask_key[1] = data[offset + 1]
|
||||
mask_key[2] = data[offset + 2]
|
||||
mask_key[3] = data[offset + 3]
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if data.len < offset + int(payload_len) {
|
||||
return error('Frame too short for payload')
|
||||
}
|
||||
|
||||
mut payload := data[offset..offset + int(payload_len)].clone()
|
||||
if masked {
|
||||
for i := 0; i < payload.len; i++ {
|
||||
payload[i] = payload[i] ^ mask_key[i % 4]
|
||||
}
|
||||
}
|
||||
|
||||
return fin, opcode, payload
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock WSContext for testing
|
||||
// ============================================================================
|
||||
|
||||
struct MockWSContext {
|
||||
mut:
|
||||
ready_state WSReadyState
|
||||
sent_frames [][]u8
|
||||
ping_interval int
|
||||
last_ping_sent i64
|
||||
last_pong_received i64
|
||||
pong_timeout int
|
||||
awaiting_pong bool
|
||||
}
|
||||
|
||||
fn create_mock_ws_context() MockWSContext {
|
||||
return MockWSContext{
|
||||
ready_state: .open
|
||||
sent_frames: [][]u8{}
|
||||
ping_interval: 30000
|
||||
pong_timeout: 10000
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut ws MockWSContext) send_ping(payload []u8) ![]u8 {
|
||||
if ws.ready_state != .open {
|
||||
return error('WebSocket connection is not open')
|
||||
}
|
||||
|
||||
if payload.len > 125 {
|
||||
return error('Ping payload too large (max 125 bytes)')
|
||||
}
|
||||
|
||||
frame := encode_ws_frame(ws_opcode_ping, payload, false)
|
||||
ws.sent_frames << frame
|
||||
ws.last_ping_sent = time.now().unix()
|
||||
ws.awaiting_pong = true
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
fn (mut ws MockWSContext) handle_ping(payload []u8) ![]u8 {
|
||||
if ws.ready_state != .open {
|
||||
return error('WebSocket connection is not open')
|
||||
}
|
||||
|
||||
// Build pong frame with same payload
|
||||
pong_frame := encode_ws_frame(ws_opcode_pong, payload, false)
|
||||
ws.sent_frames << pong_frame
|
||||
|
||||
return pong_frame
|
||||
}
|
||||
|
||||
fn (mut ws MockWSContext) handle_pong(payload []u8) {
|
||||
ws.last_pong_received = time.now().unix()
|
||||
ws.awaiting_pong = false
|
||||
}
|
||||
|
||||
fn (ws MockWSContext) is_pong_timeout() bool {
|
||||
if !ws.awaiting_pong {
|
||||
return false
|
||||
}
|
||||
|
||||
if ws.pong_timeout <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
elapsed := time.now().unix() - ws.last_ping_sent
|
||||
timeout_seconds := ws.pong_timeout / 1000
|
||||
|
||||
return elapsed > timeout_seconds
|
||||
}
|
||||
|
||||
fn (ws MockWSContext) should_send_ping() bool {
|
||||
if ws.ping_interval <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if ws.ready_state != .open {
|
||||
return false
|
||||
}
|
||||
|
||||
if ws.awaiting_pong {
|
||||
return false
|
||||
}
|
||||
|
||||
elapsed := time.now().unix() - ws.last_ping_sent
|
||||
interval_seconds := ws.ping_interval / 1000
|
||||
|
||||
return elapsed >= interval_seconds
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_ping_payload() []u8 {
|
||||
// Ping payload must be 125 bytes or less per RFC 6455
|
||||
len := rand.int_in_range(0, 126) or { 50 }
|
||||
mut result := []u8{cap: len}
|
||||
for _ in 0 .. len {
|
||||
result << u8(rand.int_in_range(0, 256) or { 0 })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_small_payload() []u8 {
|
||||
len := rand.int_in_range(0, 20) or { 10 }
|
||||
mut result := []u8{cap: len}
|
||||
for _ in 0 .. len {
|
||||
result << u8(rand.int_in_range(0, 256) or { 0 })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 11: Ping/Pong Handling
|
||||
// Feature: websocket-helper, Property 11: Ping/Pong Handling
|
||||
// Validates: Requirements 7.4
|
||||
//
|
||||
// *For any* ping frame received from a client, the server SHALL respond with
|
||||
// a pong frame containing the same payload data.
|
||||
// ============================================================================
|
||||
|
||||
fn test_property_11_pong_echoes_ping_payload() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
ping_payload := generate_random_ping_payload()
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Handle incoming ping
|
||||
pong_frame := ws.handle_ping(ping_payload) or {
|
||||
println(' Iteration ${i}: Failed to handle ping: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode the pong frame
|
||||
fin, opcode, pong_payload := decode_ws_frame(pong_frame) or {
|
||||
println(' Iteration ${i}: Failed to decode pong frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's a pong frame
|
||||
if opcode != ws_opcode_pong {
|
||||
println(' Iteration ${i}: Expected pong opcode (0xA), got 0x${opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify FIN bit is set
|
||||
if !fin {
|
||||
println(' Iteration ${i}: FIN bit should be set for pong frame')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload matches ping payload
|
||||
if pong_payload != ping_payload {
|
||||
println(' Iteration ${i}: Pong payload should match ping payload')
|
||||
println(' Ping payload length: ${ping_payload.len}')
|
||||
println(' Pong payload length: ${pong_payload.len}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_11_ping_sends_frame() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
payload := generate_small_payload()
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Send ping
|
||||
ping_frame := ws.send_ping(payload) or {
|
||||
println(' Iteration ${i}: Failed to send ping: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode the ping frame
|
||||
fin, opcode, decoded_payload := decode_ws_frame(ping_frame) or {
|
||||
println(' Iteration ${i}: Failed to decode ping frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's a ping frame
|
||||
if opcode != ws_opcode_ping {
|
||||
println(' Iteration ${i}: Expected ping opcode (0x9), got 0x${opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify FIN bit is set
|
||||
if !fin {
|
||||
println(' Iteration ${i}: FIN bit should be set for ping frame')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload matches
|
||||
if decoded_payload != payload {
|
||||
println(' Iteration ${i}: Ping payload mismatch')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify awaiting_pong is set
|
||||
if !ws.awaiting_pong {
|
||||
println(' Iteration ${i}: awaiting_pong should be true after sending ping')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_11_pong_clears_awaiting_flag() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
payload := generate_small_payload()
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Send ping
|
||||
ws.send_ping(payload) or {
|
||||
println(' Iteration ${i}: Failed to send ping: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify awaiting_pong is set
|
||||
if !ws.awaiting_pong {
|
||||
println(' Iteration ${i}: awaiting_pong should be true after ping')
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle pong
|
||||
ws.handle_pong(payload)
|
||||
|
||||
// Verify awaiting_pong is cleared
|
||||
if ws.awaiting_pong {
|
||||
println(' Iteration ${i}: awaiting_pong should be false after pong')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify last_pong_received is updated
|
||||
if ws.last_pong_received <= 0 {
|
||||
println(' Iteration ${i}: last_pong_received should be updated')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_11_ping_payload_size_limit() bool {
|
||||
// Test that ping payload > 125 bytes is rejected
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Create payload larger than 125 bytes
|
||||
large_payload := []u8{len: 126, init: u8(0x42)}
|
||||
|
||||
// Should fail
|
||||
ws.send_ping(large_payload) or {
|
||||
// Expected error
|
||||
return true
|
||||
}
|
||||
|
||||
println(' Large ping payload should be rejected')
|
||||
return false
|
||||
}
|
||||
|
||||
fn test_property_11_ping_requires_open_connection() bool {
|
||||
// Test that ping fails on non-open connections
|
||||
states := [WSReadyState.connecting, .closing, .closed]
|
||||
|
||||
for state in states {
|
||||
mut ws := create_mock_ws_context()
|
||||
ws.ready_state = state
|
||||
|
||||
ws.send_ping([]u8{}) or {
|
||||
// Expected error
|
||||
continue
|
||||
}
|
||||
|
||||
println(' Ping should fail in ${state} state')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_11_should_send_ping_logic() bool {
|
||||
// Test should_send_ping logic
|
||||
|
||||
// Case 1: Ping disabled (interval = 0)
|
||||
mut ws1 := create_mock_ws_context()
|
||||
ws1.ping_interval = 0
|
||||
if ws1.should_send_ping() {
|
||||
println(' should_send_ping should be false when interval is 0')
|
||||
return false
|
||||
}
|
||||
|
||||
// Case 2: Connection not open
|
||||
mut ws2 := create_mock_ws_context()
|
||||
ws2.ready_state = .closing
|
||||
if ws2.should_send_ping() {
|
||||
println(' should_send_ping should be false when not open')
|
||||
return false
|
||||
}
|
||||
|
||||
// Case 3: Already awaiting pong
|
||||
mut ws3 := create_mock_ws_context()
|
||||
ws3.awaiting_pong = true
|
||||
if ws3.should_send_ping() {
|
||||
println(' should_send_ping should be false when awaiting pong')
|
||||
return false
|
||||
}
|
||||
|
||||
// Case 4: Not enough time elapsed
|
||||
mut ws4 := create_mock_ws_context()
|
||||
ws4.ping_interval = 30000 // 30 seconds
|
||||
ws4.last_ping_sent = time.now().unix() // Just sent
|
||||
if ws4.should_send_ping() {
|
||||
println(' should_send_ping should be false when interval not elapsed')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_ping_pong_round_trip() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(77777)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
payload := generate_random_ping_payload()
|
||||
|
||||
// Build ping frame
|
||||
ping_frame := encode_ws_frame(ws_opcode_ping, payload, false)
|
||||
|
||||
// Decode ping frame
|
||||
_, ping_opcode, ping_payload := decode_ws_frame(ping_frame) or {
|
||||
println(' Iteration ${i}: Failed to decode ping frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
if ping_opcode != ws_opcode_ping {
|
||||
println(' Iteration ${i}: Expected ping opcode')
|
||||
return false
|
||||
}
|
||||
|
||||
// Build pong frame with same payload
|
||||
pong_frame := encode_ws_frame(ws_opcode_pong, ping_payload, false)
|
||||
|
||||
// Decode pong frame
|
||||
_, pong_opcode, pong_payload := decode_ws_frame(pong_frame) or {
|
||||
println(' Iteration ${i}: Failed to decode pong frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
if pong_opcode != ws_opcode_pong {
|
||||
println(' Iteration ${i}: Expected pong opcode')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload round-trip
|
||||
if pong_payload != payload {
|
||||
println(' Iteration ${i}: Payload mismatch in round-trip')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_empty_ping_payload() bool {
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Empty payload should work
|
||||
pong_frame := ws.handle_ping([]u8{}) or {
|
||||
println(' Empty ping payload should be handled')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode and verify
|
||||
_, opcode, payload := decode_ws_frame(pong_frame) or {
|
||||
println(' Failed to decode pong frame')
|
||||
return false
|
||||
}
|
||||
|
||||
if opcode != ws_opcode_pong {
|
||||
println(' Expected pong opcode')
|
||||
return false
|
||||
}
|
||||
|
||||
if payload.len != 0 {
|
||||
println(' Pong payload should be empty')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Ping/Pong Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 11: Ping/Pong Handling
|
||||
// Validates: Requirements 7.4
|
||||
stats.run_property_test('Property 11: Pong Echoes Ping Payload', test_property_11_pong_echoes_ping_payload)
|
||||
stats.run_property_test('Property 11: Ping Sends Frame', test_property_11_ping_sends_frame)
|
||||
stats.run_property_test('Property 11: Pong Clears Awaiting Flag', test_property_11_pong_clears_awaiting_flag)
|
||||
stats.run_property_test('Property 11: Ping Payload Size Limit', test_property_11_ping_payload_size_limit)
|
||||
stats.run_property_test('Property 11: Ping Requires Open Connection', test_property_11_ping_requires_open_connection)
|
||||
stats.run_property_test('Property 11: Should Send Ping Logic', test_property_11_should_send_ping_logic)
|
||||
stats.run_property_test('Ping/Pong Round Trip', test_ping_pong_round_trip)
|
||||
stats.run_property_test('Empty Ping Payload', test_empty_ping_payload)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
Reference in New Issue
Block a user