grpc-webnext-client 0.2.0

gRPC client for Rust WASM frontends, speaking real gRPC to a grpc-webnext endpoint over an h2ts WebSocket tunnel. No hyper, no tokio; tonic optional, for its generated stubs.
Documentation

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 hyper, no tokio — and no tonic unless you ask for it. 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 grpc_webnext_client::{connect, CallOptions, TypedClient};

let client = connect("https://api.example.com")?;   // lazy: dials on first call

let reply: HelloReply = client
    .unary_typed("/helloworld.Greeter/SayHello", HelloRequest { name: "world".into() }, CallOptions::new())
    .await?
    .into_inner();

All four cardinalities are supported. Streaming::message() yields responses as they arrive, and Streaming::status() gives the terminal status afterwards.

Would rather write greeter.say_hello(request)? Turn on the tonic feature and tonic's own generated stubs drive the same tunnel.

Features

feature default what it adds
prost Typed helpers (TypedClient) over prost::Message. Without it the client deals in raw message bytes and you bring your own codec.
tonic Runs tonic's generated client stubs over the tunnel, via Client::into_tonic. Costs ≈26 KB gzipped — see what it costs in the bundle.

Generated stubs (tonic)

The native API above deals in method paths and message bytes. Enable the tonic feature and pass the client where a Channel would go:

[dependencies]
grpc-webnext-client = { version = "0.2", features = ["tonic"] }
# No server, no channel, no TLS — none of which build for wasm, and none of which this
# needs. `codegen` comes with the feature above, since generated code opens with
# `use tonic::codegen::*`.
tonic = { version = "0.14", default-features = false }
tonic-prost = "0.14"

[build-dependencies]
tonic-prost-build = "0.14"
// build.rs
tonic_prost_build::configure()
    .build_client(true)
    .build_server(false)
    // Essential. Without it the stub also gets a `connect()` constructor returning
    // `Self<tonic::transport::Channel>` — hyper, TCP and TLS, which do not exist in a
    // browser and do not compile for wasm32. Off, it leaves `GreeterClient::new(T)`,
    // which is the constructor that matters: the transport is passed in, and ours is
    // the tunnel.
    .build_transport(false)
    .compile_protos(&["proto/greeter.proto"], &["proto"])?;
use grpc_webnext_client::connect;
use pb::greeter_client::GreeterClient;

let mut greeter = GreeterClient::new(connect("https://api.example.com")?.into_tonic());

let reply = greeter.say_hello(HelloRequest { name: "world".into() }).await?;   // unary
let mut ticks = greeter.countdown(CountdownRequest { from: 3 }).await?.into_inner();
let joined = greeter.concat(futures::stream::iter(words)).await?;              // client streaming
let mut chat = greeter.chat(outbound).await?.into_inner();                     // bidi

That is the whole difference from a native tonic program — one line, because the wire is real HTTP/2 either way and there is nothing to translate. tonic keeps doing codec, framing, status, compression and interceptors; this crate keeps doing what tonic cannot do in a browser: dial, tunnel, reconnect. All four cardinalities work, including the two (client- and bidi-streaming) that grpc-web cannot express at all.

A runnable end-to-end example is tonic-stub-client.

Three things differ from tonic over a Channel, all of them deliberate:

  • Request::set_timeout is enforced locally, not only sent as grpc-timeout, and it covers the whole call including a stream that stalls after it opens. On a Channel the header is advisory and only an Endpoint::timeout layer enforces anything; in a tab, a peer that ignores the header would hang the call forever. Same promise as CallOptions::timeout on the native API — see Deadlines.
  • The client stays single-threaded. tonic's stubs require T::ResponseBody: Send, and the engine underneath is !Send on purpose — see below.
  • There is no connect() constructor, which is what build_transport(false) removes. The transport is this client.

The Send bound, honestly — and no, it does not mean threads

tonic's generated stubs bound T::ResponseBody: Send + 'static. The h2ts engine is deliberately !Send (Rc, boxed non-Send streams), which is what a browser actually is, so asserting Send with an unsafe impl would be a lie the compiler could no longer check. The adapter uses send_wrapper instead: it carries the value across the bound and records the thread that created it, so a value that really does move threads panics at the boundary rather than racing. Exactly one type is wrapped — the response body — because tonic bounds the body and never the future, which it awaits inline. Natively, keep the client on a LocalSet, where a !Send client belongs anyway.

Send is a compile-time marker, not a runtime. Requiring it links nothing and spawns nothing, and this feature brings no threading with it — which matters, because a wasm bundle that quietly needed shared memory would not run where you want to ship it. In a release build of the stub path:

  • zero atomic instructions and no shared memory — threads on wasm require both;
  • no thread::spawn / tokio::spawn / JoinHandle symbols anywhere;
  • tokio resolves to default,sync only. tokio::spawn lives behind the rt feature, which is off, so the code path cannot spawn a task — a compile-time property, not an observation about one build.

send_wrapper itself is a ThreadId comparison, not a threading primitive, and it was already in the tree before this feature existed (futures-timer pulls it in for its wasm-bindgen shim). Plain wasm32-unknown-unknown — no +atomics — is the supported and measured configuration.

If you do build with wasm threads, the rule is one client per worker: a Client moved across workers panics at the SendWrapper boundary, deliberately, because the alternative is a data race on Rc. Per-worker clients are the right shape regardless — each worker gets its own tunnel.

What it costs in the bundle

The feature is off by default because it is not free: tonic goes in the dependency tree, and so in the wasm bundle. Measured, rather than guessed — same five RPCs, release profile (opt-level = "z", LTO, panic = "abort"), wasm-bindgen then wasm-opt -Oz:

.wasm gzipped
empty crate (the wasm-bindgen floor) 20.4 KB 9.4 KB
native API, unary only 145.0 KB 62.9 KB
tonic stubs, unary only 208.3 KB 90.1 KB
native API, all four cardinalities 158.0 KB 68.4 KB
tonic stubs, all four cardinalities 221.0 KB 94.6 KB

≈26 KB gzipped, and note it is flat: the same whether you call one unary method or all four cardinalities. What you are paying for is tonic's core — codec, Status, MetadataMap, the decode state machine — which does not tree-shake away by cardinality. As a share of the gRPC layer (bundle minus the floor) it is +44%.

What you are not paying for is two clients. With the feature on and only stubs in use, LTO drops this crate's own framing, metadata and status handling entirely — the feature swaps one reading of the wire for the other rather than stacking them. Nor does it drag in a server stack: default-features = false gates out transport, server, channel and TLS, so there is no hyper, h2, axum, socket2 or rustls anywhere in the tree.

Worth it for the ergonomics and for interop with tonic's ecosystem; not worth it if bytes are the binding constraint. Both APIs are here precisely so that stays your call — with the feature off, nothing above changes and the client is tonic-free.

Codec

The client deals in message bytes, so it is codec-agnostic. The default prost feature adds typed helpers (TypedClient) over prost::Message, and a service is then a handful of thin wrappers over unary, server_streaming, client_streaming and bidi_streaming. For generated stubs instead of hand-written wrappers, see Generated stubs.

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() {
    ConnectivityState::Ready => { /* ... */ }
    ConnectivityState::TransientFailure => show_offline_banner(),
    _ => {}
}

// gRPC's WaitForStateChange, as a stream. Repeats are collapsed.
while let Some(state) = client.state_changes().next().await {
    log(state);
}

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.

Bringing your own transport

connect is the browser entry point and needs wasm. Anywhere else — a test, a CLI, a conformance driver — build the byte transport yourself and hand it to Client::over_transport. Everything the signature needs is re-exported here, so this does not mean depending on h2ts-client directly:

use grpc_webnext_client::{Client, ConnectOptions, Transport, TransportError};

let transport = Transport::new(Box::pin(reader), Box::pin(writer));
let (client, driver) = Client::over_transport(transport, "api.example.com", ConnectOptions::default());
spawn_local(driver);   // the driver must be polled for anything to happen

Such a client is single-shot: the transport is consumed, so it cannot redial (see Reconnect). For one that can, build a Connector — a function returning a fresh H2Connection per dial — and pass it to Client::with_connector; open_tunnel is h2ts_client::connect re-exported so that needs no second dependency either. The tonic-stub-client example does exactly this over tokio-tungstenite, in about thirty lines.

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.

The repo's conformance-driver is the same idea at a larger scale: it runs this client through the language-neutral conformance suite against every server implementation, over a real socket — both its paths, native and tonic stubs, with the harness asserting the two agree case for case.

License

MIT OR Apache-2.0, at your option.