Replace the hand-rolled WebSocket implementation with a transport boundary (Frame over any Sink/Stream) plus adapters for tokio-tungstenite (server/client features) and axum (axum feature). The JSON wire format is unchanged, so existing peers keep working. Fixes: - unbounded frame lengths were allocated up front; the server now enforces message/frame size limits (1 MiB default) - responses arriving before `request` subscribed were lost - an accept() error ended `session_loop` - requests sent right after connecting could arrive before handlers were registered; the receiver now starts after `on_conn` returns - panics in the receive loop skipped `on_close` and leaked sessions; `on_close` now runs exactly once and handler panics fail only their request - unknown methods and invalid data got no reply; they now get an error response - slow handlers blocked pongs and responses Adds `on_notification`, `request_timeout`, `closed`, `is_closed`, `id`, `ServerConfig`, `from_transport`, `from_tungstenite` and `from_axum`, integration tests, and an axum example. Bumps to 0.2.0 since `Session::connect` now takes a URL and the `ws` module is removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
session-rs
A lightweight, async WebSocket protocol for Rust.
Introduction
session-rs is a small request/response + notification protocol that runs over WebSockets, with typed methods on both ends.
- Typed methods: requests, responses and errors are (de)serialized for you.
- Both directions: either peer can send requests and notifications.
- Transport-agnostic: the protocol runs over any
Sink/Streamof frames. Adapters ship for tokio-tungstenite and axum. - Bounded: the built-in server limits message and frame sizes (1 MiB by default).
Installation
cargo add session-rs
| Feature | Default | Enables |
|---|---|---|
server |
yes | SessionServer, a standalone WebSocket server |
client |
yes | Session::connect for ws:// URLs |
rustls / native-tls |
no | wss:// URLs in Session::connect |
axum |
no | Session::from_axum for axum WebSocket upgrades |
Usage
Define a method once and share it between both peers:
#[derive(Debug, Serialize, Deserialize)]
struct Data;
impl Method for Data {
const NAME: &'static str = "data";
type Request = String;
type Response = String;
type Error = String;
}
Server
let server = SessionServer::bind("127.0.0.1:8080").await?;
server
.session_loop(async |session, addr| {
// Runs for every new client. Register handlers here; the session
// starts reading once this returns, so no message arrives early.
session
.on_request::<Data, _>(async |_id, req| Ok(format!("echo: {req}")))
.await;
Ok(())
})
.await?;
Use SessionServer::with_config(ServerConfig { .. }) to change the size limits or the handshake timeout.
Client
let session = Session::connect("ws://127.0.0.1:8080").await?;
session.start_receiver();
let reply = session.request::<Data>("Hello".to_string()).await?; // Ok("echo: Hello")
axum
With the axum feature, a session can share a router with ordinary HTTP routes, such as a health check:
async fn ws(upgrade: WebSocketUpgrade) -> Response {
upgrade.max_message_size(1 << 20).on_upgrade(async |socket| {
let session = Session::from_axum(socket);
session.on_request::<Data, _>(async |_, req| Ok(req)).await;
session.start_receiver();
})
}
let app = Router::new()
.route("/", get(ws))
.route("/health", get(async || "ok"));
Other transports
Session::from_transport(sink, stream) accepts any Sink<Frame> and Stream<Item = Result<Frame, E>>, and Session::from_tungstenite wraps an existing tokio-tungstenite stream (e.g. one accepted over TLS). The transport must answer pings itself.
Semantics
- Incoming requests and notifications are handled one at a time, in arrival order. Responses to your own requests are delivered independently, so a handler can
requestfrom its peer. - A request for an unknown method, or with data that doesn't deserialize, gets an error response instead of no reply.
- A handler that panics fails only its own request.
requestfails withError::ConnectionClosedif the session closes first;request_timeoutadds a deadline.on_closeruns exactly once, whichever side closes.start_ping(interval, timeout)closes peers that stop answering pings.
Protocol
Every message is a JSON text frame with a type tag.
Request
The id is chosen by the sender and increments per peer.
{ "type": "request", "id": 1, "method": "data", "data": "Hello from client" }
Response
A response must carry the id of the request it answers.
{ "type": "response", "id": 1, "result": "Hello from server" }
Error response
{ "type": "errorresponse", "id": 1, "error": "Invalid data" }
Notification
A notification is fire-and-forget and gets no response.
{ "type": "notification", "method": "data", "data": "Hello from server" }