Make the protocol transport-agnostic and use tokio-tungstenite

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]>
This commit is contained in:
2026-09-25 05:35:57 +02:00
co-authored by claude
parent dd83ae219a
commit d11d4ea08b
16 changed files with 2230 additions and 804 deletions
+77 -42
View File
@@ -6,20 +6,12 @@
## Introduction
This library provides **type-safe WebSocket communication** with a request-response and notification system built on top of a flexible protocol.
It ensures compile-time guarantees for message structure, reduces runtime errors, and simplifies building Rust client/server applications.
`session-rs` is a small request/response + notification protocol that runs over WebSockets, with typed methods on both ends.
- **Dynamic Methods**: Each message includes a method enum for type safety.
- **Typed Requests & Responses**: Automatic serialization and deserialization.
- **Optional Notifications**: Send asynchronous notifications across sessions.
## Features
- Fully typed WebSocket sessions
- Type-safe request/response mechanism
- Optional typed notifications (Todo)
- Lightweight, minimal runtime overhead
- Async-first with Tokio support
- **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`/`Stream` of frames. Adapters ship for [tokio-tungstenite](https://docs.rs/tokio-tungstenite) and [axum](https://docs.rs/axum).
- **Bounded**: the built-in server limits message and frame sizes (1 MiB by default).
## Installation
@@ -27,9 +19,16 @@ It ensures compile-time guarantees for message structure, reduces runtime errors
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 |
### **Basic Example (client)**
## Usage
Define a method once and share it between both peers:
```rust
#[derive(Debug, Serialize, Deserialize)]
@@ -41,44 +40,74 @@ impl Method for Data {
type Response = String;
type Error = String;
}
let session = Session::connect("127.0.0.1:8080", "/").await?;
session.start_receiver();
session
.request::<Data>("Hello from client".to_string())
.await?;
```
### **Basic Example (server)**
### Server
```rust
#[derive(Debug, Serialize, Deserialize)]
struct Data;
impl Method for Data {
const NAME: &'static str = "data";
type Request = String;
type Response = String;
type Error = String;
}
let server = SessionServer::bind("127.0.0.1:8080").await?;
server
.session_loop(async |session, addr| {
// This will run on every new client
// 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;
})
.await?;
```
Use `SessionServer::with_config(ServerConfig { .. })` to change the size limits or the handshake timeout.
### Client
```rust
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:
```rust
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 `request` from 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.
- `request` fails with `Error::ConnectionClosed` if the session closes first; `request_timeout` adds a deadline.
- `on_close` runs 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 request `id` is separated from the peer, and will increment only on it's requests.
The `id` is chosen by the sender and increments per peer.
```json
{ "type": "request", "id": 1, "method": "data", "data": "Hello from client" }
@@ -86,16 +115,22 @@ The request `id` is separated from the peer, and will increment only on it's req
#### Response
The response `id` **must** remain the same as the request.
A response **must** carry the id of the request it answers.
```json
{ "type": "response", "id": 1, "result": "Hello from server" }
```
#### Notifications
A notification is a method that doesn't need validation or output, it simply notifies a peer for a specific information
#### Error response
```json
{ "type": "notification", "result": "Hello from server" }
{ "type": "errorresponse", "id": 1, "error": "Invalid data" }
```
#### Notification
A notification is fire-and-forget and gets no response.
```json
{ "type": "notification", "method": "data", "data": "Hello from server" }
```