h2-ws-client

Crates.ioh2-ws-client
lib.rsh2-ws-client
version0.1.0
created_at2025-11-17 15:53:29.67712+00
updated_at2025-11-17 15:53:29.67712+00
descriptionMinimal HTTP/2 WebSocket client built on hyper + tokio-tungstenite
homepagehttps://github.com/nam2ee/h2-ws-client
repositoryhttps://github.com/nam2ee/h2-ws-client
max_upload_size
id1937061
size51,270
Nam2ee (nam2ee)

documentation

https://docs.rs/h2-ws-client

README

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.

  • 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 (text, binary, ping/pong, close, masking, etc.).

diagram description of (3) image

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

cargo test

This will run both unit tests and integration tests (for example, against an axum echo server).

2-1. Run the example server

# 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):

# 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

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(())
}
Commit count: 0

cargo fmt