Announcing Trillium 1.0
Trillium 1.0 is a major release across the trillium toolkit. The headline feature is HTTP/3, but the release also includes substantial improvements to server lifecycle management, ergonomics, and the client.
HTTP/3
Trillium now supports HTTP/3 via the new trillium-quinn crate,
which wraps the Quinn QUIC implementation. Consistent with trillium's
architecture, alternative async QUIC implementations would be supported if they existed, and
composes neatly with any trillium runtime adapter.
Existing trillium applications can add H3 support with just a line or two of code and no other modifications.
Server-side HTTP/3
Enable HTTP/3 alongside your existing TLS configuration:
trillium_tokio::config()
.with_acceptor(RustlsAcceptor::from_single_cert(&cert_pem, &key_pem))
.with_quic(trillium_quinn::QuicConfig::from_single_cert(&cert_pem, &key_pem))
.run(handler);
The server binds a UDP socket on the same host and port that the tcp listener are bound to and begins accepting QUIC connections alongside TCP. No changes to your application are otherwise required. An alt-svc header will be added to every response to advertise that h3 is supported.
As of 1.0, trillium does not support dynamic QPACK tables, but they will be released as a non-breaking minor release in the near term.
Client-side HTTP/3
The Trillium client now supports HTTP/3 with automatic protocol negotiation:
use trillium_client::Client;
use trillium_rustls::RustlsConfig;
use trillium_rustls::rustls::client::ClientConfig;
use trillium_quinn::ClientQuicConfig;
let client = Client::new_with_quic(
RustlsConfig::<ClientConfig>::default(),
ClientQuicConfig::with_webpki_roots(),
);
The client tracks Alt-Svc response headers and automatically uses HTTP/3 for subsequent requests
to origins that advertise it. QUIC connections are pooled separately. If an H3 attempt fails, that
endpoint is marked broken and requests transparently fall back to HTTP/1.1 for a backoff period
before retrying. Origins without a cached alt-svc entry always use HTTP/1.1. As of this release,
trillium-client does not consult SVCB records or follow a "happy eyes" algorithm like browsers do,
but both of those are on the roadmap.
Trailers
As part of the H3 implementation, trillium now supports http trailers, and for completeness, chunked-body trailer support is now added to HTTP/1.1 as well. trillium client and trillium servers each support trailers in both directions. To generate a streaming Body that dynamically determines trailer content while it streams, use implement BodySource and use Body::new_with_trailers.
Server lifecycle
Trillium's server lifecycle has several usability improvements and extension points.
Shared server-level state
There is now a shared state map that travels through the handler stack as it initializes. This allows handlers to communicate with each other and initialize themselves with information about the bound listener, as well as to establish state that is available immutably throughout the application.
Handler::init lifecycle methods can now add arbitrary state to the Info, and that state is available
for the lifetime of the server. The Init handler also has been updated to support adding or
retrieving ad-hoc state from a shared state TypeSet there:
Init::new(|mut info| async move {
let db = MyDb::connect("db://...").await.unwrap();
info.with_state(db)
})
Any downstream handler can then read it:
|conn: Conn| async move {
let Some(db) = conn.shared_state::<MyDb>() else {
return conn.with_status(Status::InternalServerError);
};
let result = db.query("select ...").await;
conn.ok(result)
}
ServerHandle and BoundInfo
Config::spawn(handler) now returns a ServerHandle that is cheaply Clone and covers the full
server lifecycle:
let handle = trillium_tokio::config().port(0).spawn(handler);
// Wait for the server to finish binding, then get the bound address.
let info = handle.info().await;
println!("listening on {}", info.url());
handle.clone().await; // wait for the server to shut down
// elsewhere: shut it down and wait for all connections to drain.
handle.shut_down().await;
handle.info().await returns a BoundInfo — an immutable snapshot of the server's address and
shared state after initialization.
Graceful shutdown: Swansong replaces Stopper
Swansong replaces the Stopper crate for
coordinating graceful shutdown throughout the trillium crates. Swansong provides affordances for not
only initiating a shutdown but also waiting for all shutdown guards to drop before signaling to the
caller that the shutdown is complete.
No more #[async_trait] on handlers
impl Handler no longer requires #[async_trait]. Remove the attribute from all handler
implementation.
The same applies to FromConn and TryFromConn in trillium-api, ChannelHandler in
trillium-channels, and WebsocketHandler and JsonWebSocketHandler in trillium-websockets.
// Before
#[async_trait]
impl Handler for MyHandler {
async fn run(&self, conn: Conn) -> Conn { ... }
}
// After
impl Handler for MyHandler {
async fn run(&self, conn: Conn) -> Conn { ... }
}
Conn API cleanup
Several long-standing rough edges are resolved in 1.0.
Header access is now unambiguous. conn.headers() and conn.headers_mut() are now split into
conn.request_headers() / conn.request_headers_mut() and conn.response_headers() /
conn.response_headers_mut(). Similarly, conn.with_header(name, val) is now
conn.with_response_header(name, val).
conn.inner() is gone. Methods that were previously only reachable through conn.inner() are
now directly on Conn.
set_state → insert_state. The set_state method was deprecated in 0.2.20; it's removed in
1.0.
conn.state_entry::<T>() provides an entry API for per-connection state, mirroring
HashMap::entry. See
type_set::entry::Entry.
set_* setters now chain. Methods like conn.set_status(200) and conn.set_body("ok") now
return &mut Self, allowing them to be chained like conn.set_status(200).set_body("ok"); Thanks
to @joshtriplett for this suggestion!
Client improvements
Beyond HTTP/3, the client gains several independent improvements.
HTTP/1.1 keepalive is now the default. Client::with_default_pool() no longer exists because
pooling is always on. To opt out, use Client::without_keepalive().
Connection timeouts are now supported on both clients and individual requests:
client.with_timeout(Duration) sets a default for all requests; conn.with_timeout(Duration)
overrides for a single request. Both return Error::TimedOut on expiry.
Per-connection state via TypeSet: client conns now support with_state, insert_state,
state, state_mut, and take_state. This is an incremental step towards the possibility of
supporting "client handlers."
sonic-rs support is available as an opt-in feature alternative to serde_json for
with_json_body and response_json. Enable with features = ["sonic-rs"].
trillium-api
Trillium-api gains substantial documentation. The primary difference from the previous versions is that all cargo features are opt-in now (no default features). This way, you can choose between serde_json and sonic-rs.
Logger improvements
trillium-logger now places a LogTarget in shared state, allows any handler in the pipeline to
emit messages to the logger's configured target.
The dev_formatter output now includes the HTTP version as the first field, so you can see at a
glance whether a connection came in over HTTP/1.1 or HTTP/3.
Testing improvements
trillium-testing introduces TestServer, which is a new testing approach for trillium applications. The previous version is still supported in the first release in order to help make upgrading to trillium 1.0 smooth and avoid changing both your tests and your application at the same time. Macro-style TestConn assertions are deprecated and will be removed in the next breaking release to trillium-testing.
The new approach looks like this:
use test_harness::test;
use trillium::{Conn, Status};
use trillium_testing::{TestResult, TestServer, harness};
#[test(harness)]
async fn basic_test() {
let app = TestServer::new(|conn: Conn| async move { conn.ok("hello") }).await;
app.get("/").await.assert_ok().assert_body("hello");
// or if you prefer:
let conn = app.post("/").with_body("body").await;
conn.assert_ok();
conn.assert_body("hello");
}
// also an option, but not preferred:
#[test]
fn sync_test() {
let app = TestServer::new_blocking(|conn: Conn| async move { conn.ok("hello") });
app.get("/").block().assert_ok().assert_body("hello");
let conn = app.post("/").with_body("body").block();
conn.assert_ok();
conn.assert_body("hello");
}
TypeSet
The StateSet type has been renamed to TypeSet and extracted to the standalone
type-set crate, re-exported as trillium_http::TypeSet and
trillium::TypeSet. If you were referring to StateSet directly, rename to TypeSet.
