grpc-webnext-client
A gRPC client for Rust WASM frontends (Leptos, Yew, Dioxus, …), speaking real gRPC to a grpc-webnext endpoint over an h2ts WebSocket tunnel.
No tonic, no hyper, no tokio. The wire is real HTTP/2 — trailers, multiplexing, flow control — so there is nothing to translate: message framing plus a status read off the trailers is the whole client.
use ;
let client = connect?; // lazy: dials on first call
let reply: HelloReply = client
.unary_typed
.await?
.into_inner;
All four cardinalities are supported. Streaming::message() yields responses as they
arrive, and Streaming::status() gives the terminal status afterwards.
Why not tonic
tonic requires T::ResponseBody: Send + 'static. The h2ts engine is deliberately !Send
(Rc, boxed non-Send streams), which is correct for a browser — so going through tonic
would mean asserting Send on a target that has no threads. Everything here is honestly
single-threaded instead.
Codec
The client deals in message bytes, so it is codec-agnostic. The default prost feature
adds typed helpers ([TypedClient]) over prost::Message. There is no generated service
stub layer yet: a service is a handful of thin wrappers over unary, server_streaming,
client_streaming and bidi_streaming.
Deadlines
CallOptions::timeout sends grpc-timeout and arms a local timer. Both matter: the
header lets a server or proxy enforce the deadline, and the timer means the call still ends
if nothing does.
On a stream the deadline bounds the whole call, not just opening it. One timer spans the request and every message read after it, so a server that sends headers promptly and then goes quiet still ends the call — that being the case a deadline is actually for. When it fires the stream is dropped, which resets the HTTP/2 stream and releases the server's handler rather than leaving it producing for a caller that has gone.
Backpressure
Free, and real. h2ts-client replenishes the HTTP/2 receive window only as the response body
is polled, so a consumer that stops reading stops the server rather than filling the tab's
memory.
Reconnect
Client is a gRPC channel, not a handle to a socket. The tunnel opens on the first call
and reopens if it drops — the same contract tonic::transport::Channel has, so an app never
owns socket lifecycle to make an RPC.
The call that finds the tunnel dead reports the failure; the next one reconnects. The transport does not silently replay a request the server may already have seen — that is a retry policy decision, and not the transport's to make.
Connectivity is observable, which a frontend needs more than a backend does (the answer is usually a banner):
match client.state
// gRPC's WaitForStateChange, as a stream. Repeats are collapsed.
while let Some = client.state_changes.next.await
No reconnect backoff: as in tonic, a redial happens when a call asks for one, so your
call rate bounds the dial rate. A client built with Client::over_transport cannot redial —
the transport is consumed — and says so instead of pretending to be live.
Testing
The end-to-end tests run on the host, not in a browser. h2ts-client is a sans-I/O
engine behind a pluggable byte transport, so swapping web_sys::WebSocket for
tokio-tungstenite exercises the identical code path against a real grpc-webnext server —
framing, HPACK, flow control, trailers, all of it. What a browser adds is a socket
implementation, not gRPC behavior.
License
MIT OR Apache-2.0, at your option.