WebTransport
Browser-compatible W3C WebTransport over HTTP/3 on Tokio or Compio, routed through the Router, plus a raw QUIC session helper on Tokio.
Tako serves W3C WebTransport over HTTP/3, the protocol behind the browser's
new WebTransport(url). A session carries bidirectional and unidirectional
streams plus unreliable datagrams over one QUIC connection. It runs on the
HTTP/3 server on either runtime, behind the webtransport feature, which also
enables http3.
[dependencies]
tako-rs = { version = "2", features = ["webtransport"] }Accepting sessions
A session starts as an HTTP/3 extended CONNECT request. It goes through the
router like any other request, so path matching, extractors, and middleware such
as authentication run first. Register a CONNECT route whose handler takes the
WebTransport extractor and returns on_session:
use tako::Method;
use tako::TlsCert;
use tako::router::Router;
use tako::types::{BoxError, Response};
use tako::webtransport::{WebTransport, WebTransportSession};
use tokio::io::AsyncWriteExt;
async fn echo(wt: WebTransport) -> Response {
wt.on_session(|session: WebTransportSession| async move {
while let Ok(Some(stream)) = session.accept_bi().await {
tokio::spawn(async move {
let (mut send, mut recv) = stream.split();
let _ = tokio::io::copy(&mut recv, &mut send).await;
let _ = send.shutdown().await;
});
}
})
}
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let mut router = Router::new();
router.route(Method::CONNECT, "/echo", echo);
tako::Server::builder()
.tls(TlsCert::pem_paths("cert.pem", "key.pem"))
.build()
.try_spawn_h3("0.0.0.0:4433", router)?
.result()
.await?;
Ok(())
}on_session answers 200 OK, and the callback runs once the server has sent the
response. To refuse a session, return any non-2xx response instead. The
WebTransport extractor rejects requests that are not a WebTransport CONNECT with
400 Bad Request, and a path without a CONNECT route answers 404.
In the browser:
const transport = new WebTransport("https://example.com:4433/echo");
await transport.ready;
const stream = await transport.createBidirectionalStream();Streams and datagrams
| Method | Result |
|---|---|
accept_bi() | The next bidirectional stream the client opens |
accept_uni() | The next unidirectional stream the client opens |
open_bi() / open_uni() | A stream opened by the server |
read_datagram() | The next datagram of the session |
send_datagram(bytes) | Sends an unreliable datagram |
remote_address() / session_id() | The client and the session |
BidiStream, SendStream, and RecvStream implement AsyncRead and
AsyncWrite from both tokio::io and futures::io. BidiStream::split returns
separate sending and receiving sides; shutting down the sending side finishes the
stream.
WebTransportSession is Clone, so one task can read datagrams while another
accepts streams. Each QUIC connection carries one session. Other HTTP/3 requests
that arrive on the same connection are still served by the router.
QUIC datagrams are always on with the webtransport feature, whatever
ServerConfig::h3_enable_datagrams says, because WebTransport requires them.
Closing a session
A session ends when:
- the browser calls
transport.close({ closeCode, reason })or goes away; - the server calls
session.close(code, reason).await; - the handler returns, which closes the session with code 0;
- the server shuts down.
After that, accept_bi, accept_uni, and read_datagram return Ok(None), so
accept loops end on their own. session.closed().await resolves with a
WebTransportClose { code, reason } from whichever side closed first, and the
browser reads the server's code and reason from transport.closed.
Runtimes
The session and its streams are Send, so the example above hands each stream to
tokio::spawn.
Serve the same router with CompioServer::builder() and try_spawn_h3. The
session and its streams are !Send and stay on the connection's thread, so spawn
per-stream work with compio::runtime::spawn and use the futures::io traits:
use futures_util::io::AsyncWriteExt;
use tako::types::Response;
use tako::webtransport::{WebTransport, WebTransportSession};
async fn echo(wt: WebTransport) -> Response {
wt.on_session(|session: WebTransportSession| async move {
while let Ok(Some(stream)) = session.accept_bi().await {
compio::runtime::spawn(async move {
let (mut send, recv) = stream.split();
let _ = futures_util::io::copy(recv, &mut send).await;
let _ = send.close().await;
})
.detach();
}
})
}Certificates for local development
Browsers only connect to a certificate they trust. During development, skip the
certificate store by passing the certificate's SHA-256 hash to
serverCertificateHashes:
const transport = new WebTransport("https://127.0.0.1:4433/echo", {
serverCertificateHashes: [{ algorithm: "sha-256", value: certificateHash }],
});The certificate must be an ECDSA P-256 certificate valid for at most 14 days.
examples/webtransport generates one at startup, serves a test page at
http://127.0.0.1:3000, and echoes a stream and a datagram:
//! W3C WebTransport from a browser, with no certificate to install.
//!
//! Run it and open <http://127.0.0.1:3000> in Chrome, Edge, or Firefox. The
//! page opens a session to `https://127.0.0.1:4433/echo` with
//! `serverCertificateHashes`, which accepts the short-lived self-signed
//! certificate generated at startup, then echoes a stream and a datagram.
use rcgen::CertificateParams;
use rcgen::KeyPair;
use sha2::Digest;
use sha2::Sha256;
use tako::Method;
use tako::TlsCert;
use tako::body::TakoBody;
use tako::router::Router;
use tako::types::BoxError;
use tako::types::Response;
use tako::webtransport::WebTransport;
use tako::webtransport::WebTransportSession;
use time::Duration;
use time::OffsetDateTime;
use tokio::io::AsyncWriteExt;
async fn echo(wt: WebTransport) -> Response {
wt.on_session(|session: WebTransportSession| async move {
println!("session from {}", session.remote_address());
let datagrams = tokio::spawn({
let session = session.clone();
async move {
while let Ok(Some(datagram)) = session.read_datagram().await {
let _ = session.send_datagram(datagram);
}
}
});
while let Ok(Some(stream)) = session.accept_bi().await {
tokio::spawn(async move {
let (mut send, mut recv) = stream.split();
let _ = tokio::io::copy(&mut recv, &mut send).await;
let _ = send.shutdown().await;
});
}
datagrams.abort();
let close = session.closed().await;
println!("session closed: code {} ({:?})", close.code, close.reason);
})
}
/// A self-signed ECDSA P-256 certificate valid for ten days: the kind
/// `serverCertificateHashes` accepts. Returns it with its SHA-256 hash.
fn certificate() -> Result<(TlsCert, Vec<u8>), BoxError> {
let key = KeyPair::generate()?;
let mut params = CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
params.not_before = OffsetDateTime::now_utc() - Duration::hours(1);
params.not_after = OffsetDateTime::now_utc() + Duration::days(10);
let cert = params.self_signed(&key)?;
let hash = Sha256::digest(cert.der()).to_vec();
Ok((TlsCert::der(vec![cert.der().clone()], key.into()), hash))
}
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let (tls, hash) = certificate()?;
let hash: Vec<String> = hash.iter().map(u8::to_string).collect();
let page = include_str!("index.html").replace("CERT_HASH", &hash.join(","));
let mut pages = Router::new();
pages.get("/", move || {
let mut page = Response::new(TakoBody::from(page.clone()));
page.headers_mut().insert(
tako::header::CONTENT_TYPE,
tako::header::HeaderValue::from_static("text/html; charset=utf-8"),
);
async move { page }
});
let mut sessions = Router::new();
sessions.route(Method::CONNECT, "/echo", echo);
// Browsers load pages over HTTP/3 only from a trusted certificate, so the
// page comes over HTTP/1.1; `127.0.0.1` still counts as a secure context.
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
let page_server = tako::Server::builder()
.build()
.try_spawn_http(listener, pages)?;
let webtransport = tako::Server::builder()
.tls(tls)
.build()
.try_spawn_h3("127.0.0.1:4433", sessions)?;
println!("open http://127.0.0.1:3000");
tokio::select! {
result = page_server.result() => result?,
result = webtransport.result() => result?,
}
Ok(())
}Raw QUIC sessions
serve_webtransport(addr, cert_path, key_path, handler) is a separate, lower-level
API. It binds a plain QUIC endpoint and hands each connection to your handler as a
RawQuicSession, with no HTTP/3 or WebTransport handshake. Browsers cannot connect
to it; use it for private QUIC tunnels between peers you control. It is
Tokio-only and needs the webtransport feature.
use tako::webtransport::RawQuicSession;
use tako::webtransport::serve_webtransport;
async fn handle_session(session: RawQuicSession) {
let remote = session.remote_address();
println!("New WebTransport session from {remote}");
// Handle bidirectional streams (echo server)
loop {
tokio::select! {
// Accept bidirectional streams
result = session.accept_bi() => {
match result {
Ok((mut send, mut recv)) => {
println!("[{remote}] New bidirectional stream");
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
loop {
match recv.read(&mut buf).await {
Ok(Some(n)) => {
println!("[stream] Echoing {n} bytes");
if send.write_all(&buf[..n]).await.is_err() {
break;
}
}
_ => break,
}
}
});
}
Err(e) => {
println!("[{remote}] Connection closed: {e}");
break;
}
}
}
// Read unreliable datagrams
result = session.read_datagram() => {
match result {
Ok(data) => {
println!("[{remote}] Datagram: {} bytes", data.len());
// Echo datagram back
let _ = session.send_datagram(data);
}
Err(e) => {
println!("[{remote}] Datagram error: {e}");
break;
}
}
}
}
}
println!("Session closed: {remote}");
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
println!("WebTransport echo server on [::]:4433");
println!("Requires TLS certificates: cert.pem and key.pem");
println!();
println!("Generate self-signed certs for testing:");
println!(" openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \\");
println!(" -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'");
serve_webtransport("[::]:4433", "cert.pem", "key.pem", |session| {
Box::pin(handle_session(session))
})
.await;
}RawQuicSession wraps a quinn::Connection and exposes its stream and datagram
surface directly:
| Method | Purpose |
|---|---|
remote_address() | Peer socket address. |
accept_bi() | Accept an incoming bidirectional stream (SendStream, RecvStream). |
accept_uni() | Accept an incoming unidirectional stream (RecvStream). |
open_bi() | Open a new bidirectional stream. |
open_uni() | Open a new unidirectional stream. |
read_datagram() | Read an unreliable datagram. |
send_datagram(bytes) | Send an unreliable datagram. |
close(code, reason) | Close the session with a QUIC error code. |
connection() | Borrow the underlying quinn::Connection for advanced use. |
serve_webtransport_with_shutdown(addr, cert, key, handler, signal) adds a
graceful-shutdown future: the endpoint stops accepting, drains in-flight sessions
for up to 30 s, then aborts the rest. The endpoint installs the ring rustls
CryptoProvider if none is installed yet and advertises the h3 ALPN protocol.
Related
- HTTP/3: the QUIC transport WebTransport runs on.
- Feature flags: the
webtransportfeature.
Last updated on