🐙 tako
Transports

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

MethodResult
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:

examples/webtransport/src/main.rs
//! 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.

examples/raw-quic/src/main.rs
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:

MethodPurpose
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.

Last updated on

On this page