Release notes
Everything that changed since v0.4.0. The release carries breaking changes, so it goes out as 0.5.0. If you are upgrading an existing application, read Breaking changes first; every entry there comes with the before and after.
Three things stand out. Topcoat now speaks the three long-lived transports a full-stack framework needs: WebSockets, server-sent events, and, on top of SSE, a Datastar integration. It sends mail, through a new topcoat-mail crate with SMTP, file, and in-memory transports. And it runs where it could not before: on WebAssembly and other serverless runtimes that hand you a request instead of a listener, and over Unix domain sockets behind a reverse proxy.
New features
WebSockets
Behind the websocket feature. A route becomes a WebSocket endpoint by taking a WebSocketUpgrade parameter and returning the response its on_upgrade builds.
use topcoat::{
Result,
router::{
Response, route,
content::websocket::{Message, WebSocketUpgrade},
},
};
#[route(GET "/echo")]
async fn echo(upgrade: WebSocketUpgrade) -> Result<Response> {
upgrade.on_upgrade(|mut socket| async move {
while let Some(Ok(message)) = socket.recv().await {
if socket.send(message).await.is_err() {
break;
}
}
})
}The extractor runs inside the handler like any other, so a session check or a cookie read composes with it: reject the request before calling on_upgrade. A malformed handshake is rejected with 400, a non-GET request with 405. Incoming pings are answered for you, subprotocols are negotiated with protocols, and message, frame, and write buffer sizes are bounded through the builder. WebSocket implements Stream and Sink, so the connection can be split into halves that read and write concurrently.
See the WebSocket guide and the websocket example.
Server-sent events
Behind the sse feature. A route returns Sse wrapping a stream of Events and the response streams them as text/event-stream.
#[route(GET "/events")]
async fn events() -> Result<Sse<impl Stream<Item = Result<Event>> + use<>>> {
let events = stream::iter(["one", "two"].map(|name| Ok(Event::new().data(name))));
Ok(Sse::new(events).keep_alive(KeepAlive::new()))
}keep_alive fills idle gaps so proxies do not drop a quiet stream, and last_event_id(cx) reads the Last-Event-ID header a reconnecting EventSource sends, so a stream can resume instead of replaying.
See the SSE guide and the sse example.
Datastar
Behind the datastar feature, which enables sse. Datastar drives page updates from the backend: data-* attributes bind reactive signals to elements, and the server answers with events that patch HTML and signal values into the page.
The Signals<T> extractor reads the signals an action sends, whether they arrive in the datastar query parameter or as a JSON body. PatchElements, PatchSignals, and ExecuteScript are the events to answer with. Each works as a standalone response and converts into an SSE Event, so the same types serve a one-shot update and a long-lived stream.
#[route(POST "/increment")]
async fn increment(Signals(counter): Signals<Counter>) -> Result<PatchSignals> {
PatchSignals::json(&Counter { count: counter.count + 1 })
}For plain request/response updates there are responder types (DatastarSelector, DatastarMode, DatastarOnlyIfMissing, and friends) that set the headers Datastar reads, placed before the body in a response tuple.
See the Datastar guide and the datastar example.
Behind the mail feature, plus mail-smtp for the SMTP transport. Declare a mail with the mail! macro and deliver it with send.
let mail = mail! {
from: ("Topcoat", "welcome@example.com"),
to: "ada@example.com",
subject: "Welcome, Ada!",
html: {
<h1>"Welcome!"</h1>
<p>"Your account is ready."</p>
},
}?;
send(cx, mail).await?;The html field is a view! body, so mail markup is written the same way as page markup, and a plain-text alternative is derived from it by default. Addresses can be strings, (name, address) pairs, or Mailbox values, alone or in collections. Field values can .await and ?, so a recipient list can be fetched inline.
Three transports ship: SmtpTransport with connection pooling and URL-based configuration, FileTransport writing .eml files during development, and MemoryTransport capturing sends for tests to assert on. Register one in a MailConfig on the router and the call sites do not change when you swap it. Implement Transport for anything else, such as a provider's HTTP API.
Alongside it, RouterBuilder::base_url registers the absolute URL the application is reachable at, which mail (and later feeds and sitemaps) needs to write links that leave the site.
See the mail guide, the mail! reference, and the mail example.
WebAssembly and other listener-less runtimes
topcoat builds for wasm32 now. Serving is the only part of the framework that needs tokio and hyper, and it sits behind the serve feature. Build without default features, leave serve off, and call Router::handle from whatever hands you the request.
Assets work there too. A bundle directory cannot be read at runtime, so embed the manifest instead and register it with AssetConfig::hosted_at, which resolves asset URLs against an external host without mounting any routes:
let manifest = Manifest::parse(include_str!("../dist/assets/manifest.toml"))?;
let router = Router::builder()
.assets(AssetConfig::hosted_at("https://static.example.com/assets", manifest))
.build();The CLI follows: topcoat asset bundle scans cdylib and dylib outputs, not just executables, so a wasm32 build that produces no binary still bundles.
Unix domain sockets
serve and serve_until now accept any Listener, implemented for TcpListener and, on Unix, UnixListener. That covers running behind nginx or Caddy over a socket path.
let path = "/run/my-app.sock";
let _ = std::fs::remove_file(path);
let listener = tokio::net::UnixListener::bind(path)?;
topcoat::serve(listener, router).awaitBinding fails if the socket file exists and dropping the listener does not remove it, so clear a stale file first, as above.
Mounting tower services
TowerRoute mounts a tower service (an axum router, a hyper service, a reverse proxy) as a route. Registered at a catch-all path with Methods::Any, it hands an entire URL subtree to the service with the original URI intact, which is what you want when migrating an existing application to Topcoat one route at a time.
let router = Router::builder()
.route(TowerRoute::new(Methods::Any, Path::new("/legacy/{*rest}"), legacy_app))
.build();TowerLayer, for running tower middleware as a layer, was already there and now sits in the same module. Errors from wrapped Topcoat routes pass through unchanged so outer layers can still catch them by type; errors the tower service itself produces surface as TowerServiceError.
See the tower guide.
Routes and pages for multiple methods
#[route] accepts a bracketed list or * in place of a single method, and #[page] accepts the same forms to serve something other than GET.
#[route([GET, POST] "/form")]
async fn form() -> Result<&'static str> { Ok("form") }
#[route(* "/webhook")]
async fn webhook() -> Result<&'static str> { Ok("received") }
#[page(POST "/signup")]
async fn signup(Form(input): Form<Signup>) -> Result {
view! { <h1>"Welcome, " (input.email)</h1> }
}A route declaring a specific method takes precedence over a * route at the same path, so a catch-all can sit under more specific handlers.
Recursive components
A component that calls itself, directly or through another component, used to fail to compile on the resulting future cycle. #[component(boxed)] on one component in the cycle breaks it.
#[component(boxed)]
async fn comment_thread(comment: &Comment) -> Result {
view! {
<li>
(&comment.body)
<ul>
for reply in &comment.replies {
comment_thread(comment: reply)
}
</ul>
</li>
}
}Signal shorthands
Writes that depend on the current value have a shorter spelling than set: toggle on a bool signal, increment and decrement on an f64 signal, and push_str on a String signal.
// Before
<button @click=$(|_e| count.set(count.get() + 1.0))>"+"</button>
// After
<button @click=$(|_e| count.increment())>"+"</button>Better build errors from topcoat dev
The dev server used to show only rustc's JSON diagnostics, so a failure cargo reports on stderr instead (a build script exiting non-zero, an unresolvable dependency, a malformed manifest, a --bin naming no target) showed up as a bare "build failed" with nothing to go on. Cargo's stderr is now captured and reported alongside the diagnostics.
Breaking changes
Layouts take a rendered Result, not a Slot future
A layout used to receive slot: Slot<'_>, a future it awaited wherever the child content belonged. It now receives the already-rendered slot: Result. The Slot type is gone.
// Before
#[layout]
async fn shell(slot: Slot<'_>) -> Result {
view! { <main>(slot.await?)</main> }
}
// After
#[layout]
async fn shell(slot: Result) -> Result {
view! { <main>(slot?)</main> }
}Migrating is mechanical: change the parameter type to Result, drop the topcoat::router::Slot import, and replace slot.await? with slot?.
The point of the change is that a layout can now inspect the child's error before rendering, which is what makes the branded error page in the router error guide work. The cost is ordering: the page is fully rendered before any layout body runs, where the old future let a layout emit its <head> first. Nothing streamed responses yet, so this is not a behavior regression today, but it does constrain a future streaming SSR design.
Router errors moved into router::error
The error constructors and types are no longer re-exported from the root of topcoat::router. They live in topcoat::router::error, which now has a guide of its own.
// Before
use topcoat::router::{NotFoundError, RouterErrorExt, SeeOther, not_found, see_other};
// After
use topcoat::router::error::{NotFoundError, RouterErrorExt, SeeOther, not_found, see_other};This covers not_found, unauthorized, forbidden, bad_request, method_not_allowed, internal_server_error, redirect, redirect_permanent, see_other, their error types, SeeOther, and RouterErrorExt. StatusCode stays at topcoat::router::StatusCode.
Request and response body types moved into router::content
The same treatment for the extractors and response wrappers, which also got a guide covering multipart, WebSockets, and SSE.
// Before
use topcoat::router::{Css, Form, Html, Json, Multipart, RawForm};
// After
use topcoat::router::content::{Css, Form, Html, Json, RawForm, multipart::Multipart};FromRequest, IntoResponse, IntoResponseParts, Body, and Bytes did not move; they are still at topcoat::router.
WebSocket support, added in this same release, lives at topcoat::router::content::websocket rather than topcoat::router::websocket. Only relevant if you tracked main between the two commits.
Tower support moved into router::tower
// Before
use topcoat::router::TowerLayer;
// After
use topcoat::router::tower::{TowerLayer, TowerRoute};Boolean attributes render as disabled=""
A true boolean attribute value used to render as disabled="true". It now renders as disabled="", matching the HTML convention for boolean attributes. false still omits the attribute entirely, as before.
The rendered markup changes, so update any snapshot test that asserts on it. Nothing changes for the browser: both forms mean the same thing to an HTML parser.
AssetConfig::hosted_at takes the base URL first
// Before
AssetConfig::hosted_at(AssetBundle::load().unwrap(), "https://cdn.example.com/assets")
// After
AssetConfig::hosted_at("https://cdn.example.com/assets", AssetBundle::load().unwrap())Asset is a handle, AssetId is the identifier
asset! used to return an Asset, a bare u64 ID. It now returns an Asset handle, and the ID type it used to be is called AssetId. Rendering an Asset in a view! is unchanged; only direct bundle lookups need adjusting.
// Before
let bundled = bundle.get(LOGO).expect("logo was bundled");
// After
let bundled = bundle.get(LOGO.id()).expect("logo was bundled");The handle exists to keep the embedded declaration alive. The bundler finds assets by scanning the compiled binary, and the declaration is now reachable only through the handle, so an asset is bundled only if some code path uses its handle. A declaration whose handle is never used can be optimized out, and the bundler will not see it. In exchange, the MSVC-specific /OPT:NOREF workaround is gone and asset discovery works the same way on every platform, including WebAssembly.
AssetBundle entries name a file instead of a path
// Before
let path = bundle.get(LOGO).unwrap().path();
// After
let path = bundle.dir().join(bundle.get(LOGO.id()).unwrap().name());An entry now names its file relative to the bundle directory rather than carrying an absolute path, because a bundle resolved from an embedded manifest has no directory on disk.
session::Config is session::SessionConfig
Config and ConfigBuilder were renamed to SessionConfig and SessionConfigBuilder, so they read unambiguously once several features register configuration on the router.
// Before
.sessions(Config::default())
// After
.sessions(SessionConfig::default())Serving is behind the serve feature
topcoat::serve, serve_until, and start moved behind a new serve feature. It is on by default and implied by full, so an application using default features needs no change. Only a build with default-features = false has to add serve alongside router. This allows for WASM deployments by disabling mio.
Route responds to a set of methods
Custom Route implementations replace fn method(&self) -> Method with fn methods(&self) -> Methods<'_>, and RouteFn::new now takes anything convertible into OwnedMethods (a Method still works). PageFn::new gained a leading methods argument and is no longer const; the const constructor is PageFn::const_new. This only affects code that builds routes by hand rather than through the macros.
Fixes
- The browser hung when a text expression rendered an owned string (#201).
topcoat fmtpanicked onsignaldeclarations inside aview!body, which meant any file using client reactivity was unformattable (#172).topcoat fmt --stdinexited with code 0 after a formatting failure, so editors and CI treated a failed format as success (#207).topcoat devhot reload never succeeded on Windows because the running executable held a file lock (#169).- Embedded asset declarations were stripped by the MSVC linker, so on Windows the bundler found no assets and every page panicked (#170). The asset handle change above replaces that workaround with a portable fix (#217).
- Boolean event fields returned raw JavaScript booleans instead of
Boolsurrogates, so expression methods on them failed (#168). - A double type assertion in DOM binding (#171).
- WebSocket handshake keys are now validated rather than accepted as-is (#215).
Documentation and tooling
- New guides for router errors, request and response content, tower, WebSockets, SSE, multipart, Datastar, and mail.
- New examples:
websocket,sse,datastar, andmail. - The
module_router!guide explains what the macro does and does not register, and howdiscoverand value registrations like the asset bundle compose with it. topcoat fmtformatsmail!bodies, and its--macrosflag is documented.- Agent skills for committing, opening pull requests, checking a change, writing prose, writing macros, and code style live in
.agents/skills. - A Nix devshell (
flake.nix) for a reproducible toolchain. - Release automation cuts one git tag and one GitHub release per Topcoat version instead of one per crate.
Contributors
Thanks to Amein Eskinder, Carl Lerche, Iqbal, Onyeka Obi, RA, Sean Aye, and seth for contributing to this release.