Zenwave
Async HTTP client for Rust. Works on native (Tokio/Hyper, Apple URLSession, libcurl) and wasm32 (Fetch API). Same API everywhere, middleware you compose yourself.
Getting started
[]
= "0.5"
use ;
async
get, post, put, delete are one-shot convenience functions. For anything
more involved, build a client:
use Duration;
use ;
async
Every method on Client (enable_cache, timeout, bearer_auth, basic_auth,
enable_cookie, retry, .with(custom_middleware)) wraps the client in another
middleware layer. Order matters — the outermost layer runs first.
Request builder
Once you have a client, .get(url), .post(url), etc. return a RequestBuilder.
It has methods for setting the request and methods for reading the response:
Setting the request:
.header(name, value), .bearer_auth(token), .basic_auth(user, pass),
.json_body(&T), .bytes_body(vec), .file_body("path").await,
.reader_body(reader, len), .stream_body(stream)
Reading the response (consume the RequestBuilder by awaiting, then call on
Response, or call directly on the builder as a shortcut):
.json::<T>(), .string(), .bytes(), .form::<T>(), .sse()
The ResponseExt trait adds .into_json::<T>(), .into_string(), .into_bytes(),
.into_sse(), .error_for_status() directly on Response.
Middleware
All built-in features are middleware. You can write your own by implementing
the Middleware trait from http-kit and passing it to .with().
Built-in middleware:
| Method | What it does |
|---|---|
.timeout(duration) |
Fails with 504 if the request takes longer than duration |
.enable_cache() |
RFC-compliant Cache-Control / ETag / Last-Modified caching in memory |
.enable_cookie() |
In-memory cookie jar |
.enable_persistent_cookie() |
Cookie jar backed by a file on disk (native only) |
.bearer_auth(token) |
Adds Authorization: Bearer <token> to every request |
.basic_auth(user, pass) |
Adds Authorization: Basic <base64> to every request |
.retry(n) |
Retries failed requests up to n times |
.with(OAuth2ClientCredentials::new(...)) |
Client-credentials OAuth2 flow with automatic refresh |
Redirects are on by default. Call zenwave::client().disable_redirect() to get the
raw backend, or build one with DefaultClient::raw(transport).
File downloads with resume
use Client;
async
If the file already exists, zenwave sends a Range header and appends. Pass
DownloadOptions { resume_existing: false } via .download_to_path_with() to
start from scratch. Native only.
WebSockets
Requires the ws feature (on by default). Uses async-tungstenite on native,
web_sys::WebSocket on wasm.
use ;
async
You can split a connection for concurrent send/recv:
# use websocket;
# async
Trust and transport
Every backend is built from a Transport. zenwave::client() uses
Transport::system(): the operating system's trust store, verified by the
platform itself (Security.framework on macOS/iOS, CryptoAPI on Windows, the
Android trust manager, the system CA bundle on Linux), and in a browser or a
Cloudflare Worker whatever the runtime trusts. It is built once per process and
shared.
To trust a private CA in addition to the platform roots:
use Transport;
let transport = builder
.extra_root_certificates_pem?
.build?;
let client = client_with;
zenwave::client_with is the default client over that transport; every
backend takes one the same way (HyperBackend::new(transport),
CurlBackend::new(transport), AppleBackend::new(transport),
WebBackend::new(transport)), and DefaultClient::raw(transport) is the
platform backend without redirect following.
Websockets take the same transport: websocket::connect_with(uri, &transport, config).
The curl backend takes a whole CA bundle rather than additional anchors, so
with extra roots it hands libcurl a PEM bundle of the platform roots (a
snapshot taken by rustls-native-certs when the transport is built) plus the
extras. Without extra roots libcurl keeps its own view of the platform store.
The Apple backend adds the extras as anchors of each server's SecTrust
alongside the built-in roots, from the session delegate's challenge handler.
In an iOS app, App Transport Security still requires a system-trusted chain
for every host that is not local, even after the delegate accepts the trust;
a server that only the extra roots vouch for needs an NSExceptionDomains
entry with NSExceptionAllowsInsecureHTTPLoads for its hostname in
Info.plist (or NSAllowsArbitraryLoads).
On Android the extras join the system trust anchors in one webpki store.
zenwave reads those anchors from disk (/apex/com.android.conscrypt/cacerts
on Android 14 and newer, /system/etc/security/cacerts before that) instead
of going through the platform verifier, which reports every certificate whose
issuer publishes revocation only through a CRL as revoked
(rustls/rustls-platform-verifier#221) — Let's Encrypt, Google Trust Services
and SSL.com included. Revocation is not checked on Android, as with every
webpki client, and certificates the user installed are not trusted, Android's
own default for applications. Nothing has to be wired on the Java side. The
instrumented app under tests/android runs the TLS cases from a real
application process and is what scripts/test-android.sh runs on a device
after the plain test binaries.
Proxy support
Every transport follows proxy rules; the default, Proxy::system(), reads
HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY (either case, lower-case
wins as in curl) and then the operating system's proxy settings (macOS System
Settings, Windows internet options). The Apple backend hands that case to
URLSession, which also runs PAC scripts. To state the rules yourself:
use ;
let proxy = builder
.http
.https
.no_proxy
.build;
let transport = builder.proxy.build?;
Proxy::env() reads only the environment, Proxy::none() always connects
directly. Proxy URIs may use http, https, socks5 (resolve on the client)
or socks5h (resolve on the proxy); the curl backend also understands socks4
and socks4a. HTTP proxies see plaintext requests in absolute form and a
CONNECT tunnel for TLS and for websockets, with Proxy-Authorization taken
from the proxy URI's credentials. libcurl's own reading of http_proxy and
no_proxy is switched off; the transport's rules are the only ones that apply.
The Apple backend keeps one URLSession per proxy decision: Proxy::system()
is a single session that lets the OS route everything, explicit rules open a
session per proxy endpoint (plus one for direct traffic) with a pinned
connectionProxyDictionary. CFNetwork sets the limits there: proxy URIs may
be http or socks5/socks5h (which it treats alike, always sending the
hostname); an https proxy is refused as UnsupportedScheme; destinations
written as a loopback or local IP literal are never proxied. Plaintext requests
carry Proxy-Authorization up front, CONNECT tunnels answer the proxy's
challenge from the delegate, and a refused challenge fails at once with
ProxyErrorKind::TunnelRejected.
Backends and feature flags
On wasm32, the built-in Fetch backend is used automatically. No feature selection needed or available.
On native, pick a backend, and for hyper a TLS engine:
| Feature | Backend | TLS | Notes |
|---|---|---|---|
hyper-backend + rustls |
Hyper | rustls, verified by the OS (rustls-platform-verifier) |
Default. |
hyper-backend + native-tls |
Hyper | Platform native | OpenSSL / SChannel / Security.framework |
curl-backend |
libcurl | libcurl's | Smaller binary if you have system libcurl |
apple-backend |
URLSession | Security.framework | Experimental. macOS/iOS only |
ws (websockets) uses the same engine as hyper. When rustls and native-tls
are both enabled, rustls takes precedence. The default feature enables
hyper-backend, rustls, ws and http2.
The hyper backend negotiates HTTP/2 through ALPN by default on every TLS
connection that allows it (http2 feature); servers that only speak HTTP/1.1
keep working unchanged. It also upgrades an origin to HTTP/3 (http3
feature) when the origin advertises it — through an Alt-Svc response header
or an HTTPS DNS record — racing QUIC against TCP and falling back to TCP when
QUIC is unavailable. Disable with default-features = false for a smaller
build.
Common dependency lines:
# Default — hyper + rustls with platform verification + websockets
= "0.5"
# Hyper with the platform's native TLS library
= { = "0.5", = false, = ["hyper-native-tls", "ws"] }
# Curl backend
= { = "0.5", = false, = ["curl-backend"] }
# Apple URLSession
= { = "0.5", = false, = ["apple-backend"] }
Other features: hyper-rustls / hyper-native-tls (shorthands).
Testing
Native backends are covered by cargo test. The wasm backend is tested in
three runtimes, because they differ in exactly the places that break:
scripts/test-wasm.sh <chrome|firefox|safari>— wasm-pack in a browser page (aWindow) and in a dedicated web worker (nowindow).scripts/test-workerd.sh— a real Cloudflare Worker. It builds the skyzen app intests/workerd/, path-patched to this checkout, runs it underwrangler dev(local workerd), and asserts that a bodiless GET, a JSON POST and a bytes PUT all round-trip through zenwave. Needsskyzen,wrangler,jqand thewasm32-unknown-unknowntarget.
CI runs all three.
Examples
License
MIT