-
Notifications
You must be signed in to change notification settings - Fork 1
WebSockets
Courierust ships a complete RFC 6455 WebSocket implementation
(courierust_ws) plus RFC 7692 permessage-deflate, and wires it into the
server and the client you already use. A WebSocket is an HTTP/1.1
connection that gets upgraded in place: it shares the port, the handler,
the TLS stack and the event scheduler with plain HTTP — no second
listener, no third-party dependency, no separate runtime.
client ── GET /ws (Upgrade: websocket) ──► Handler::websocket ── Accept ──► WsService
│ │
└──────────── 101 Switching Protocols ◄───── frames ◄──────────────┘
Handler::websocket picks the routes this server serves as WebSockets;
returning Pass keeps the request on the normal HTTP path (so /ws stays
yours and every other path still gets your real handler):
use courierust::courierust_body::Body;
use courierust::courierust_http::request::Request;
use courierust::courierust_http::response::Response;
use courierust::courierust_server::ws::{WsConn, WsData, WsService, WsUpgradeReply};
use courierust::courierust_server::{Handler, Server, ServerConfig};
use std::sync::Arc;
struct Echo;
impl WsService for Echo {
fn on_message(&self, conn: &mut WsConn, msg: WsData) {
match msg {
WsData::Text(t) => { let _ = conn.send_text(&t); }
WsData::Binary(b) => { let _ = conn.send_binary(&b); }
}
}
fn on_close(&self, conn: &mut WsConn, code: Option<u16>, clean: bool) {
// `code: None` means the transport broke — not the peer's fault.
eprintln!("closed code={code:?} clean={clean} path={}", conn.path());
}
}
struct App;
impl Handler for App {
fn handle(&self, _req: Request<Body>) -> Response<Body> {
Response::text("plain HTTP still works")
}
fn websocket(&self, req: &Request<Body>) -> WsUpgradeReply {
if req.path == "/ws" {
WsUpgradeReply::Accept(Arc::new(Echo))
} else {
WsUpgradeReply::Pass
}
}
}
let server = Server::bind_with_config("127.0.0.1:8080", ServerConfig::default())?;
server.serve(App)?;A WsService gets on_open, on_message, on_pong, on_idle and
on_close; per-connection application state lives on the connection
itself (conn.set_state(...) / conn.with_state(...)), so you do not need
one WsService object per connection.
ServerConfig::event_driven selects the scheduler, and WebSockets work on
either one — with the same policy, because both run the same
courierust_ws engine:
blocking driver (event_driven: false) |
event driver (default) | |
|---|---|---|
| driving loop | blocking read/write on the connection's own thread | stays in the reactor; read on readable, flush on writable |
| cost of an idle WebSocket | one pool worker | one poller slot |
| keepalive | read deadline armed only while waiting for the next frame |
ping_interval Ping/Pong |
| fan-out from another thread | WsConn::sender() |
WsConn::sender() |
The reactor detail worth knowing: an idle WebSocket holds no worker, so
a service with thousands of mostly-idle sockets is bounded by
max_connections, not by worker count. The flip side is that a service
callback runs on an event worker — pushing thousands of messages from
inside on_message blocks the reactor that would drain them. The supported
fan-out path is WsConn::sender() from any thread, which queues the frame
and nudges the reactor.
use courierust::courierust_server::ws::{PmDeflatePolicy, WsConfig};
use courierust::courierust_ws::OriginPolicy;
let ws = WsConfig {
// The default: browsers may only open this socket from the same site,
// because the browser attaches the session cookies to the upgrade.
origin: OriginPolicy::SameOrigin,
// Believe X-Forwarded-For / X-Forwarded-Proto only from your proxy.
trusted_proxies: vec![],
subprotocols: vec!["chat.v2".into(), "chat.v1".into()],
compression: PmDeflatePolicy::default(),
max_frame: 16 * 1024 * 1024,
max_message: 16 * 1024 * 1024,
max_fragments: 0, // 0 = unlimited
max_send_queue: 4 * 1024 * 1024,
read_buffer: 64 * 1024,
ping_interval: Some(std::time::Duration::from_secs(30)),
close_timeout: Some(std::time::Duration::from_secs(5)),
..Default::default()
};| knob | why it exists |
|---|---|
origin |
SameOrigin (default) blocks cross-site page → authenticated socket; NoOrigin for non-browser clients, List for an allow-list, Any to opt out deliberately |
trusted_proxies |
only these peers may override the client address with X-Forwarded-For / X-Forwarded-Proto
|
subprotocols |
server-preference order; the first client offer present here wins |
compression |
permessage-deflate policy (size thresholds, window bits) |
max_frame / max_message / max_fragments
|
bounds a peer cannot talk its way past; violations get 1009/1007/1002 |
max_send_queue |
bounds memory when the application pushes faster than the peer reads |
read_buffer |
64 KiB suits media-ish traffic, 16 KiB suits large fleets of mostly-idle sockets |
ping_interval |
Ping after this much inbound silence, give up at twice that |
close_timeout |
how long to wait for the peer's close echo |
use courierust::courierust_client::ClientConfig;
use courierust::courierust_client::ws::WebSocket;
let cfg = ClientConfig::default(); // 60 s read deadline
let mut ws = WebSocket::connect("wss://example.com/ws", &cfg)?; // TLS is in-crate
ws.send_text("hello")?;
ws.send_binary(&[0u8, 1, 2, 255])?;
loop {
match ws.read_message()? {
courierust::courierust_ws::Event::Text(t) => println!("text {t}"),
courierust::courierust_ws::Event::Binary(b) => println!("binary {} bytes", b.len()),
courierust::courierust_ws::Event::Ping(_) | courierust::courierust_ws::Event::Pong(_) => continue,
courierust::courierust_ws::Event::Close(frame) => {
println!("closed {frame:?}");
break;
}
}
}
ws.close(1000, "done")?;The client speaks ws:// and wss:// (the second one rides the crate's own
TLS 1.2/1.3 stack), understands the same subprotocol negotiation, and
carries its own read deadline. cargo run --example ws_client runs the
production-shaped version of this against a public echo server or your own
endpoint; cargo run --example ws_echo runs a server and a client in
one process.
| rule | what happens |
|---|---|
| mask direction (§5.1) | server fails an unmasked client frame; client fails a masked server frame |
| control frames | never fragmented, never longer than 125 bytes |
| length encoding | minimal-length encodings only |
| UTF-8 in text frames | validated incrementally; 1007 at the exact breaking offset |
| RSV bits | rejected unless negotiated |
| close codes | validated against the legal sets for each direction |
| §5.5.1 (nothing after Close) | a shared connection flag refuses racing sends with ErrorKind::Canceled
|
| limits | 1009 before the bytes are buffered, including inflated permessage-deflate bombs |
| handshake | exactly one canonical Sec-WebSocket-Key, Version: 13, token-list Connection: Upgrade, Host required on HTTP/1.1 |
| masking keys | ChaCha20 stream from platform entropy, one key per frame |
Terminate TLS (and optionally HTTP/2 or HTTP/3) at nginx or Traefik, and proxy the upgrade to your plain-HTTP listener:
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # buffering frames destroys latency
proxy_read_timeout 3600s; # must exceed ping_interval
proxy_send_timeout 3600s;
}Then tell the server it sits behind that proxy:
let ws = WsConfig {
origin: OriginPolicy::List(vec!["https://app.example.com".into()]),
trusted_proxies: vec![IpNet::parse("127.0.0.1/32")?, IpNet::parse("10.0.0.0/8")?],
..Default::default()
};Two mistakes are common enough to name: a proxy proxy_read_timeout
shorter than ping_interval will kill healthy idle sockets, and
trusted_proxies left empty while the application reads
X-Forwarded-For means you are trusting whatever the client sent.
-
RFC 8441 (WebSocket over HTTP/2) is not implemented. The client offers
only
http/1.1in ALPN — even whenClientConfig::http2istrue— and refuses a connection that ends up on h2 before reading a frame. On the server side, a WebSocket attempt on an established h2 connection is a malformed message: a stream error (PROTOCOL_ERROR, RFC 9113 §8.1.1), in both forms — RFC 8441's extended CONNECT (:method = CONNECTwith:protocol = websocket, an undefined pseudo-header here, exactly the rejection RFC 8441 §3 defines for a peer that never advertisedSETTINGS_ENABLE_CONNECT_PROTOCOL) and the HTTP/1.1-styleUpgrade: websocket/Connection: Upgradefields (§8.2.2). The connection and its other streams keep working. Use HTTP/1.1 for WebSockets;h2_rejects_rfc8441_extended_connect_as_stream_error,h2_rejects_websocket_upgrade_header_over_h2andwss_negotiates_http_1_1_against_an_h2_capable_serverare the tests behind those three sentences. -
permessage-deflatecompresses each message independently (no context takeover). That is always legal, removes the "one message's plaintext leaks into another" class of bugs, and is why the server can hold thousands of connections without 32 KiB of sliding window each; the cost is compression ratio on streams of tiny repetitive messages. -
On Windows, a socket deadline is not free. The blocking driver arms
the read deadline only while idle; a client that keeps
read_timeoutarmed pays roughly 2× on 256 KiB bulk pushes — setread_timeout: Noneand use application-level liveness for bulk transfers. Measurements:benches/WS_BENCHMARK.md(see Benchmarks). - 256 KiB messages between two ends of this crate are slower than tungstenite's pairing (cause localised, and the socket-deadline finding above is part of it); small and medium messages are at parity or ahead. The benchmark document reports both directions of that comparison.
-
27 end-to-end tests (
tests/ws.rs) run the real client against the real server over a real socket, through both drivers: the handshake (including the RFC 6455 accept-key vector), masking both ways, fragmentation with interleaved control frames, RFC 7692 negotiation and interop, UTF-8 failure codes, close-handshake cleanliness,wss://over the crate's TLS, push from another thread, Origin/subprotocol policy, the limits, and the reactor regressions (a closed connection must not park the connections that are still open). -
Examples:
cargo run --example ws_echo,cargo run --example ws_client. -
Engine internals, deployment recipes and the full security posture:
src/courierust_ws/README.md. -
Benchmarks vs
tungstenite/tokio-tungstenite:benches/WS_BENCHMARK.md— code, methodology and the honest rows.