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:
@@ -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" }
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user