Skip to content

Commit 290c9e4

Browse files
authored
Add WebSocket server library (#5)
Implements the WebSocket server library designed in Discussion #2, built on lori (TCP) and ssl (SHA-1 for handshake accept key). Follows lori/stallion's "your actor IS the connection" pattern where the user's actor owns a WebSocketServer protocol handler instance. Public API: WebSocketServer (protocol handler), WebSocketServerActor (user trait), WebSocketLifecycleEventReceiver (callbacks with default no-ops), WebSocketConfig (immutable config), UpgradeRequest (parsed HTTP upgrade), CloseCode and HandshakeError unions. Internal components: _HandshakeParser (HTTP upgrade), _FrameParser (incremental WebSocket frame parsing), _FrameEncoder (server frames), _FragmentReassembler (message reassembly with UTF-8 validation), _Utf8Validator, and a trait-based state machine (_Handshaking, _Open, _Closing, _Closed). 60 tests (example-based + PonyCheck property tests) covering all parsers, encoder, reassembler, and validator. Echo server example. Design: #2
1 parent 6da9aa5 commit 290c9e4

26 files changed

Lines changed: 3107 additions & 9 deletions

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# WebSocket Server Library
2+
3+
## Building
4+
5+
This project requires an SSL version flag because it depends on the `ssl` package for SHA-1 (handshake accept key computation).
6+
7+
```
8+
make test ssl=3.0.x # Build + run tests + build examples
9+
make unit-tests ssl=3.0.x # Just tests
10+
make build-examples ssl=3.0.x # Just examples
11+
```
12+
13+
## Dependencies
14+
15+
- **lori** (0.8.5) — TCP networking. Provides `TCPListener`, `TCPConnection`, and the actor/lifecycle-receiver pattern.
16+
- **ssl** (2.0.0) — SHA-1 digest for computing the WebSocket handshake accept key (`ssl/crypto`). Also provides `ssl/net` for WSS (TLS) support.
17+
- **stdlib encode/base64** — Base64 encoding for the handshake accept key.
18+
19+
Import aliases used consistently across the codebase:
20+
- `use lori = "lori"`
21+
- `use crypto = "ssl/crypto"`
22+
- `use ssl_net = "ssl/net"` (only in `websocket_server.pony`)
23+
- `use "encode/base64"` (unqualified)
24+
25+
## Architecture
26+
27+
Follows lori/stallion's "your actor IS the connection" pattern. Users implement two actors:
28+
29+
1. **Listener actor** — implements `lori.TCPListenerActor`, accepts connections, creates handler actors.
30+
2. **Handler actor** — implements `WebSocketServerActor` (which combines `lori.TCPConnectionActor` + `WebSocketLifecycleEventReceiver`). Each handler owns a `WebSocketServer` protocol handler instance.
31+
32+
`WebSocketServer` is a class (not an actor) that implements `lori.ServerLifecycleEventReceiver`. It owns the state machine, parsers, and frame encoder. All protocol logic runs synchronously within the handler actor's context.
33+
34+
## State Machine
35+
36+
Four states, implemented as a trait (`_ConnectionState`) with concrete state classes. Every state handles every event — the state machine is the single place to understand behavior.
37+
38+
```
39+
_Handshaking → _Open → _Closing → _Closed
40+
↓ ↑
41+
└────────────────────┘
42+
(error / abnormal close)
43+
```
44+
45+
- **`_Handshaking`**: Buffers HTTP upgrade request via `_HandshakeParser`. On success, sends 101 response, transitions to `_Open`. On error, sends HTTP error, closes TCP.
46+
- **`_Open`**: Parses WebSocket frames via `_FrameParser`, reassembles fragments via `_FragmentReassembler`. Handles ping/pong automatically. Delivers text/binary messages to user callbacks.
47+
- **`_Closing`**: Server initiated close, waiting for client's close response. Only processes close and control frames; data frames are discarded.
48+
- **`_Closed`**: Terminal state, all operations are no-ops.
49+
50+
## Internal Components
51+
52+
| File | Type | Purpose |
53+
|------|------|---------|
54+
| `_handshake_parser.pony` | `_HandshakeParser` | Buffers and parses HTTP upgrade request, validates WebSocket headers, computes accept key |
55+
| `_frame_parser.pony` | `_FrameParser` | Incremental WebSocket frame parser with masking, length decoding, validation |
56+
| `_frame_encoder.pony` | `_FrameEncoder` | Builds outgoing server frames (never masked) |
57+
| `_fragment_reassembler.pony` | `_FragmentReassembler` | Reassembles fragmented messages, enforces size limits, validates UTF-8 for text |
58+
| `_utf8_validator.pony` | `_Utf8Validator` | UTF-8 byte sequence validation |
59+
| `_connection_state.pony` | `_ConnectionState` | State machine trait + four state classes |
60+
| `_mort.pony` | `_Unreachable` | Crash-on-bug helper for impossible code paths |
61+
62+
## Naming Conventions
63+
64+
- `_` prefix on type names = package-private (visible within `websockets/` package, not to consumers)
65+
- `_` prefix on members = type-private (only accessible within the defining type)
66+
- File names match the primary type they contain (e.g., `websocket_server.pony` contains `WebSocketServer`)
67+
68+
## Test Patterns
69+
70+
Tests are in `websockets/_test*.pony` files, registered in `_test.pony`. Mix of:
71+
- **Example-based unit tests** for specific scenarios (valid input, each error case, boundary conditions)
72+
- **PonyCheck property tests** for invariants over generated inputs (roundtrip encoding, valid input acceptance, fragment reassembly)
73+
74+
Tests run sequentially with `--exclude=integration` (no integration tests in v1).
75+
76+
## Design Decisions
77+
78+
- **Close timeout deferred**: The design specifies a close handshake timeout, but it's omitted from v1. Lori's idle timeout resets on any TCP receive, making it unreliable for close timeouts. OS TCP timeout handles the degenerate case.
79+
- **Send errors silently dropped**: `_tcp_connection.send()` errors (`SendErrorNotConnected`, `SendErrorNotWriteable`) are ignored — the library can't do anything useful in either case.
80+
- **No integration tests in v1**: Unit tests cover parsing, encoding, and reassembly. Integration tests requiring TCP connections are deferred.

corral.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,14 @@
1010
"version": "0.0.0",
1111
"name": "websockets"
1212
},
13-
"deps": []
13+
"deps": [
14+
{
15+
"locator": "github.com/ponylang/lori.git",
16+
"version": "0.8.5"
17+
},
18+
{
19+
"locator": "github.com/ponylang/ssl.git",
20+
"version": "2.0.0"
21+
}
22+
]
1423
}

examples/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Examples
2+
3+
## echo
4+
5+
A WebSocket echo server that echoes back text and binary messages. Listens on `ws://localhost:8080`. Connect with any WebSocket client (e.g., `websocat ws://localhost:8080`) and send messages to see them echoed back.

examples/basic/main.pony

Lines changed: 0 additions & 6 deletions
This file was deleted.

examples/echo/main.pony

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
A WebSocket echo server that echoes back text and binary messages.
3+
4+
Connect with any WebSocket client (e.g., websocat, browser JS) to
5+
ws://localhost:8080 and messages will be echoed back.
6+
"""
7+
use lori = "lori"
8+
use ws = "../../websockets"
9+
10+
actor Main
11+
new create(env: Env) =>
12+
let auth = lori.TCPListenAuth(env.root)
13+
let config = ws.WebSocketConfig(where
14+
host' = "localhost",
15+
port' = "8080")
16+
EchoListener(auth, config, env.out)
17+
18+
actor EchoListener is lori.TCPListenerActor
19+
var _tcp_listener: lori.TCPListener = lori.TCPListener.none()
20+
let _server_auth: lori.TCPServerAuth
21+
let _config: ws.WebSocketConfig val
22+
let _out: OutStream
23+
24+
new create(
25+
auth: lori.TCPListenAuth,
26+
config: ws.WebSocketConfig val,
27+
out: OutStream)
28+
=>
29+
_server_auth = lori.TCPServerAuth(auth)
30+
_config = config
31+
_out = out
32+
_tcp_listener = lori.TCPListener(auth, config.host, config.port, this)
33+
34+
fun ref _listener(): lori.TCPListener => _tcp_listener
35+
36+
fun ref _on_accept(fd: U32): EchoHandler =>
37+
EchoHandler(_server_auth, fd, _config, _out)
38+
39+
fun ref _on_listening() =>
40+
_out.print("Listening on " + _config.host + ":" + _config.port)
41+
42+
fun ref _on_listen_failure() =>
43+
_out.print("Failed to listen on " + _config.host + ":" + _config.port)
44+
45+
actor EchoHandler is ws.WebSocketServerActor
46+
var _ws: ws.WebSocketServer = ws.WebSocketServer.none()
47+
let _out: OutStream
48+
49+
new create(
50+
auth: lori.TCPServerAuth,
51+
fd: U32,
52+
config: ws.WebSocketConfig val,
53+
out: OutStream)
54+
=>
55+
_out = out
56+
_ws = ws.WebSocketServer(auth, fd, this, config)
57+
58+
fun ref _websocket(): ws.WebSocketServer => _ws
59+
60+
fun ref on_open(request: ws.UpgradeRequest val) =>
61+
_out.print("Client connected: " + request.uri)
62+
63+
fun ref on_text_message(data: String val) =>
64+
_out.print("Text: " + data)
65+
_ws.send_text(data)
66+
67+
fun ref on_binary_message(data: Array[U8] val) =>
68+
_out.print("Binary: " + data.size().string() + " bytes")
69+
_ws.send_binary(data)
70+
71+
fun ref on_closed() =>
72+
_out.print("Client disconnected")

websockets/_connection_state.pony

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use lori = "lori"
2+
3+
trait val _ConnectionState
4+
"""
5+
Connection lifecycle state.
6+
7+
Dispatches WebSocket events to the appropriate server methods based on
8+
what operations are valid in each state. Four states:
9+
`_Handshaking` (parsing HTTP upgrade), `_Open` (exchanging messages),
10+
`_Closing` (server initiated close, awaiting client response), and
11+
`_Closed` (all operations are no-ops).
12+
"""
13+
14+
fun on_received(server: WebSocketServer ref, data: Array[U8] iso)
15+
"""Handle incoming data from the TCP connection."""
16+
17+
fun on_closed(server: WebSocketServer ref)
18+
"""Handle connection close notification."""
19+
20+
fun on_throttled(server: WebSocketServer ref)
21+
"""Handle backpressure applied notification."""
22+
23+
fun on_unthrottled(server: WebSocketServer ref)
24+
"""Handle backpressure released notification."""
25+
26+
fun on_sent(server: WebSocketServer ref, token: lori.SendToken)
27+
"""Handle send completion notification from lori."""
28+
29+
fun on_idle_timeout(server: WebSocketServer ref)
30+
"""Handle connection going idle."""
31+
32+
fun send_text(server: WebSocketServer ref, data: String val)
33+
"""Send a text message."""
34+
35+
fun send_binary(server: WebSocketServer ref, data: Array[U8] val)
36+
"""Send a binary message."""
37+
38+
fun close(
39+
server: WebSocketServer ref,
40+
code: CloseCode,
41+
reason: String val)
42+
"""Initiate a close handshake."""
43+
44+
primitive _Handshaking is _ConnectionState
45+
"""Parsing the HTTP upgrade request. No WebSocket messages yet."""
46+
47+
fun on_received(server: WebSocketServer ref, data: Array[U8] iso) =>
48+
server._feed_handshake(consume data)
49+
50+
fun on_closed(server: WebSocketServer ref) =>
51+
// Handshake never completed — no user callbacks
52+
server._set_state(_Closed)
53+
54+
fun on_throttled(server: WebSocketServer ref) => None
55+
fun on_unthrottled(server: WebSocketServer ref) => None
56+
fun on_sent(server: WebSocketServer ref, token: lori.SendToken) => None
57+
fun on_idle_timeout(server: WebSocketServer ref) => None
58+
fun send_text(server: WebSocketServer ref, data: String val) => None
59+
fun send_binary(server: WebSocketServer ref, data: Array[U8] val) => None
60+
61+
fun close(
62+
server: WebSocketServer ref,
63+
code: CloseCode,
64+
reason: String val)
65+
=>
66+
None
67+
68+
primitive _Open is _ConnectionState
69+
"""WebSocket connection is open — exchanging messages."""
70+
71+
fun on_received(server: WebSocketServer ref, data: Array[U8] iso) =>
72+
server._feed_frames(consume data)
73+
74+
fun on_closed(server: WebSocketServer ref) =>
75+
// Abnormal TCP close
76+
server._fire_on_closed()
77+
server._set_state(_Closed)
78+
79+
fun on_throttled(server: WebSocketServer ref) =>
80+
server._fire_on_throttled()
81+
82+
fun on_unthrottled(server: WebSocketServer ref) =>
83+
server._fire_on_unthrottled()
84+
85+
fun on_sent(server: WebSocketServer ref, token: lori.SendToken) => None
86+
fun on_idle_timeout(server: WebSocketServer ref) => None
87+
88+
fun send_text(server: WebSocketServer ref, data: String val) =>
89+
server._send_frame(_FrameEncoder.text(data))
90+
91+
fun send_binary(server: WebSocketServer ref, data: Array[U8] val) =>
92+
server._send_frame(_FrameEncoder.binary(data))
93+
94+
fun close(
95+
server: WebSocketServer ref,
96+
code: CloseCode,
97+
reason: String val)
98+
=>
99+
server._send_frame(_FrameEncoder.close(code, reason))
100+
server._set_state(_Closing)
101+
102+
primitive _Closing is _ConnectionState
103+
"""
104+
Server initiated close, waiting for client's close response.
105+
106+
Data frames are discarded. Control frames are still processed:
107+
ping gets a pong, pong is ignored, close completes the handshake.
108+
"""
109+
110+
fun on_received(server: WebSocketServer ref, data: Array[U8] iso) =>
111+
server._feed_frames_closing(consume data)
112+
113+
fun on_closed(server: WebSocketServer ref) =>
114+
// TCP dropped before close response
115+
server._fire_on_closed()
116+
server._set_state(_Closed)
117+
118+
fun on_throttled(server: WebSocketServer ref) => None
119+
fun on_unthrottled(server: WebSocketServer ref) => None
120+
fun on_sent(server: WebSocketServer ref, token: lori.SendToken) => None
121+
fun on_idle_timeout(server: WebSocketServer ref) => None
122+
fun send_text(server: WebSocketServer ref, data: String val) => None
123+
fun send_binary(server: WebSocketServer ref, data: Array[U8] val) => None
124+
125+
fun close(
126+
server: WebSocketServer ref,
127+
code: CloseCode,
128+
reason: String val)
129+
=>
130+
None
131+
132+
primitive _Closed is _ConnectionState
133+
"""Connection is closed — all operations are no-ops."""
134+
135+
fun on_received(server: WebSocketServer ref, data: Array[U8] iso) => None
136+
fun on_closed(server: WebSocketServer ref) => None
137+
fun on_throttled(server: WebSocketServer ref) => None
138+
fun on_unthrottled(server: WebSocketServer ref) => None
139+
fun on_sent(server: WebSocketServer ref, token: lori.SendToken) => None
140+
fun on_idle_timeout(server: WebSocketServer ref) => None
141+
fun send_text(server: WebSocketServer ref, data: String val) => None
142+
fun send_binary(server: WebSocketServer ref, data: Array[U8] val) => None
143+
144+
fun close(
145+
server: WebSocketServer ref,
146+
code: CloseCode,
147+
reason: String val)
148+
=>
149+
None

0 commit comments

Comments
 (0)