# h2-ws-client
Minimal client-side WebSocket over HTTP/2, built on top of hyper and tokio-tungstenite.
The goal of this crate is to provide a very small abstraction for
**WebSocket over HTTP/2 using RFC 8441 “Extended CONNECT”**, without trying
to be a full-featured WebSocket client.
> ⚠️ **Warning**
> This is intentionally a *minimal* implementation.
> It is not battle-tested, and using it in production is entirely at your own risk.
> You may need to add your own error handling, reconnection logic, TLS, timeouts, etc.
---
## Compatibility
This crate is designed to work with servers that implement
[RFC 8441: Bootstrapping WebSockets with HTTP/2](https://datatracker.ietf.org/doc/html/rfc8441).
- axum (via `WebSocketUpgrade`) already supports WebSockets over HTTP/2
using the extended `CONNECT` + `:protocol = "websocket"` flow.
- reqwest is an HTTP client and does **not** provide any WebSocket
abstraction (neither over HTTP/1.1 nor HTTP/2). If you need WebSockets,
you typically combine `hyper` + `tokio-tungstenite` (or a crate like this one).
---
## How RFC 8441 WebSockets Work
RFC 8441 defines how to bootstrap WebSockets over **HTTP/2** using an
*extended CONNECT* request:
1. The client sends an HTTP/2 request:
- `:method = CONNECT`
- `:protocol = "websocket"`
- `:path = "/your-endpoint"`
- `sec-websocket-version = 13` (and optionally `sec-websocket-protocol`)
2. If the server accepts, it returns a successful `2xx` response.
From this point on, that **single HTTP/2 stream** becomes a
bidirectional byte stream.
3. Over that byte stream, both sides speak **normal WebSocket frames**
as defined in [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455)
(text, binary, ping/pong, close, masking, etc.).
diagram description of (3)
<img width="767" height="581" alt="image" src="https://github.com/user-attachments/assets/6f2b924a-b613-4706-a952-36a7d9bdba94" />
So conceptually:
- **WebSocket over HTTP/1.1**
- Uses `GET ... HTTP/1.1` + `Upgrade: websocket`
- After `101 Switching Protocols`, the whole TCP connection is “taken over” by WebSocket.
- **WebSocket over HTTP/2 (RFC 8441)**
- Uses `CONNECT` + `:protocol = "websocket"`
- Only **one HTTP/2 stream** (not the entire connection) is used as the underlying WebSocket channel.
- Other HTTP/2 streams can still be used for normal HTTP traffic.
This crate only cares about the **client side** of that HTTP/2 WebSocket flow.
---
## Running tests and examples
### 1. Run tests
```bash
cargo test
```
This will run both unit tests and integration tests (for example, against an axum echo server).
#### 2-1. Run the example server
```bash
# In one terminal: run your server (axum / hyper / etc.)
# For example:
cargo run --bin server
```
#### 2-2. Run the example client
Assuming you have a compatible server listening on `127.0.0.1:3000` and serving
a WebSocket endpoint at `/echo` (for example, an axum `WebSocketUpgrade` handler):
```bash
# In another terminal: run the example client
cargo run --bin example_client
```
You should then be able to type into the client and see the server’s responses.
---
## Example: Interactive HTTP/2 WebSocket client
```rust
use futures_util::{SinkExt, StreamExt};
use h2_ws_client::{H2WsConnection, H2WebSocketStream};
use tokio::{
io::{AsyncBufReadExt, BufReader},
spawn,
};
use tokio_tungstenite::tungstenite::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Connect over TCP and perform the HTTP/2 handshake.
let mut conn = H2WsConnection::connect_tcp("127.0.0.1:3000").await?;
// 2. Open a WebSocket over HTTP/2 using RFC 8441 extended CONNECT.
//
// This sends:
// :method = CONNECT
// :protocol = "websocket"
// :path = "/echo"
// Host = "localhost"
// Sec-WebSocket-Version = 13
//
// The server (e.g. axum WebSocketUpgrade) upgrades this single stream
// into a WebSocket-compatible byte stream.
let ws: H2WebSocketStream = conn
.connect_websocket("/echo", "localhost", Some("echo"))
.await?;
println!("[client] WebSocket over HTTP/2 established!");
println!("--- Type anything and press ENTER to send over WebSocket ---");
println!("--- Ctrl+C to quit ---");
// Split the WebSocket into a sender and receiver half so we can
// read from stdin and the socket concurrently.
let (mut ws_tx, mut ws_rx) = ws.split();
// Task 1: read lines from stdin and send them as text messages.
let input_task = spawn(async move {
let mut stdin = BufReader::new(tokio::io::stdin()).lines();
while let Ok(Some(line)) = stdin.next_line().await {
if ws_tx.send(Message::Text(line.into())).await.is_err() {
println!("[client] failed to send (connection closed)");
break;
}
}
});
// Task 2: receive messages from the server and print them.
let output_task = spawn(async move {
while let Some(msg) = ws_rx.next().await {
match msg {
Ok(m) => println!("[server] {m:?}"),
Err(e) => {
println!("[client] receive error: {e}");
break;
}
}
}
});
// Wait until either stdin finishes or the WebSocket is closed.
tokio::select! {
_ = input_task => println!("[client] input task finished"),
_ = output_task => println!("[client] output task finished"),
}
Ok(())
}
```