Add WebSocket client module (WSClient)#23
Conversation
There was a problem hiding this comment.
Build & Tests
Build: CI passes on macOS. Local build fails on ARM/Linux due to a pre-existing TM.tm_zone const-qualification issue in the Carp compiler — confirmed the same error occurs on main, so this is not a regression from this PR.
Tests: CI passes. 15 new tests for masked frame encoding all pass.
Findings
Thorough code review of the WSClient module (~310 lines), handshake logic, frame encoding, recv loop, and tests.
Correctness — encode-masked-frame:
- Mask generation uses
Int.random-between 0 256for each of 4 bytes — correct. - Frame header construction handles 7-bit (< 126), 16-bit (< 65536), and 64-bit length encodings correctly. The 64-bit path zeros the high 32 bits and encodes the low 32 bits, which is fine for any realistic payload size.
- Mask bit (0x80) is correctly OR'd into the length byte.
- XOR masking loop correctly indexes the mask key with
Int.mod k 4.
Correctness — handshake:
- HTTP upgrade request includes required headers (Host, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version).
Sec-WebSocket-Acceptvalidation against the expected SHA-1 hash is correct, reusing the existingws-accept-keyfunction.- Subprotocol negotiation extracts
Sec-WebSocket-Protocolfrom response headers. - Read timeout is set to 10s during handshake and reset to 0 after. Good.
- Minor: the "101" check (
String.contains-string? &resp "101") searches the entire response, not just the status line. In practice this is fine — "101" in a header value would be extremely unusual. Not worth blocking on.
Correctness — recv:
- Control frame handling: ping → auto-pong (masked), pong → skip, close → echo close + return
WSEvent.Close. All correct per RFC 6455. - Fragmentation reassembly: tracks
frag-opcodefor first fragment, appends continuation payloads, returns assembled message on final FIN frame. Correct. compact-bufis called after processing to shift unprocessed bytes to the front. The double-copy could be optimized but is functionally correct.- On read error or connection close (n <= 0), returns appropriate error. Good.
Limitations (not bugs):
- No
wss://(TLS WebSocket) support — only plainws://. Reasonable scope boundary; TLS support would requireTlsStreamintegration. closesends a close frame but doesn't wait for the server's close response. This is common in client implementations and acceptable.- Tests cover frame encoding exhaustively (15 tests) but don't test
connect/recv/sendagainst a real server. Integration testing would require a WS server in the test harness — reasonable to defer.
Style:
- The diff includes significant whitespace-only reformatting of existing code (indentation changes in
web-try-ws-upgrade,handle-ws-upgrade, etc.). These arecarp-fmtchanges and harmless, but they add noise. Worth noting in case the formatter was run on the full file rather than just the changed sections. - CHANGELOG is properly updated.
Verdict: merge
Solid RFC 6455 client implementation with good test coverage of the frame codec. Reuses existing infrastructure (SHA-1, Base64, decode-frame) effectively. The limitations (no TLS, unilateral close) are reasonable scope boundaries, not bugs. Ready to merge.
hellerve
left a comment
There was a problem hiding this comment.
In a web server framework, what do we need the client for?
|
You're right — a WebSocket client doesn't belong in a server framework. Closing. If a client is wanted, it would fit better as a separate |
Summary
Adds a
WSClientmodule for outbound WebSocket connections with full RFC 6455 client-side masking. The web framework already had comprehensive WS server support — this adds the client side, reusing the existing frame codec, SHA-1, and Base64 implementations.New public API
WSClient.connect addr port path— TCP connect + HTTP upgrade handshake withSec-WebSocket-AcceptvalidationWSClient.connect-with-protocols addr port path protocols— connect with subprotocol negotiationWSClient.send &client msg— send masked text frameWSClient.send-binary &client &data— send masked binary frameWSClient.ping &client &payload— send masked ping frameWSClient.recv &client— blocking receive with automatic control frame handling (ping→pong, close→close) and fragmentation reassemblyWSClient.close &client— send close frame and shut down socketWSClient.encode-masked-frame opcode &payload— build custom masked framesImplementation details
encode-masked-framegenerates a random 4-byte mask key per frame and XORs the payload (RFC 6455 §5.3)do-handshakesends the HTTP upgrade request, validates theSec-WebSocket-Acceptheader against the expected SHA-1 hash, and extracts any negotiated subprotocolrecvloops through buffered frames, handles control frames inline, and reassembles fragmented messages before returning aWSEventdecode-framefor text, binary, ping, close, empty payloads, 16-bit extended lengths, and multi-frame offset decodingOpened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.