Reviewed-on: #2
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" }