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 listeners 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. A consequence of this is that Info is no
longer Clone.
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)
}
The shared TypeSet allows for a sort of optional dependency injection between unrelated handlers as long as they can name common types.
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], and trillium no longer reexports
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 { ... }
}
The Handler implementation for Arc<T> where T is Handler has been removed
The semantics of this were always a bit awkward because Handler::init requires &mut and it presented
a non-obvious sharp edge to use Arc::get_mut in the init handler. If you need a cloneable handler,
hold the Arc inside a type you implement Handler for, taking responsibility for any initialization
prior to wrapping. Previous implementations of Handler for Arc<T> either panicked or log::warned on
initialization if they were unable to Arc::get_mut, and neither of those is universally correct. As
such trillium 1.0 removes the implementation and leaves it as a per-application concern to implement
an ArcHandler<T>(Arc<T>) with the appropriate initialization semantics for your application.
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!
received_body_max_len default lowered to 10mb. The default maximum size for request bodies
has been reduced from 500mb to 10mb. This limit applies to all body reads, whether buffered or
streamed. Applications that receive large request bodies — file uploads, for example — should set
an appropriate limit explicitly:
use trillium_http::HttpConfig;
trillium_tokio::config()
.with_http_config(HttpConfig::default().with_received_body_max_len(500 * 1024 * 1024))
.run(app);
trillium::Upgrade is no longer a reexported open struct
Previously, trillium::Upgrade was a reexported trillium_http::Upgrade which was an open struct. For
ease of maintenance and stability, it is now a closed struct with accessors that is defined in the
trillium crate.
trillium::ReceivedBody is now trillium::RequestBody
Previously, trillium::ReceivedBody was a reexported trillium_http::ReceivedBody that was used
for both response bodies in trillium client and request bodies in trillium server. Now trillium has
its own RequestBody type.
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.
