WebAssembly tooling for Rust has made big improvements and has matured tremendously over the previous two years. The build and packaging steps are now simpler thanks to tools like Trunk, as well as being able to write frontend code with various frameworks such as yew or dioxus. Also support for wasm is now supported in quite a few crates, basic ones such as chrono or higher level libraries such as plotters.
Additionally, the available options on the Rust server-side have increased. With projects like tower that provide reusable building blocks for clients / servers, web servers like axum came about that allow to quickly put together web apps without much boilerplate.
In this walkthrough I will describe my current default project setup for web projects that use Rust for frontend and backend. It is suitable for typical single-page web apps that use WASM/JS for rendering and routing. I have used it for dashboards, browser games and such but it should be suitable for any web app that wants to use a Rust server and frontend. I am choosing axum for the server part and yew for the frontend but it should work similarly with other choices. As for the features it will provide:
- Running server / frontend in dev mode with automatic reload
- Running server / client in production mode with pre-compiled sources
- Serving additional files from a
dist/
directory - Stub to allow adding more custom server logic
You can find the full code at https://github.com/rksm/axum-yew-setup. You can also use this code as a template to generate a new project using cargo-generate: cargo generate rksm/axum-yew-template
.
Table of Contents
- Changelog
- Tools required
- Project structure
- Server
- Frontend
- Making the file server support a SPA app
- Conveniently running it
Changelog
- 2023-03-13: No more axum-extra SPA router (thanks u/harunahi and u/LiftingBanana)
- 2023-01-15: Don’t need cargo-edit anymore (thanks u/lucca_huguet)
- 2023-01-09: Update for yew v0.20 (thanks to reddit user dvmitto for the nudge!)
- 2022-09-13: Note about
strip = "symbols"
breaks wasm code - 2022-07-11: Trunk now seems to support workspace projects and won’t recompile from scratch when the server code changes. So this guide now uses a workspace setup. Also fix adding the dependencies, there was an issue with specifying the crate features.
- 2022-04-19: Fixed Trunk command (
trunk serve
, nottrunk dev
) and slightly shorter invocation of cargo watch. Thanks @trysetnull! - 2022-04-14: Update requirements (Rust wasm target and trunk always-rebuild-from-scratch-issue)
- 2022-04-08: Fix for the
prod.sh
script when usingSpaRouter
Tools required
This walkthrough relies on the following Rust tooling:
- The Rust wasm32 target. You can install it with
rustup target add wasm32-unknown-unknown
(The target specification “triple” is the architecture, vendor and OS type. “unknown” means don’t assume a runtime). - Trunk, a WASM web application bundler for Rust. Install with
cargo install trunk
. - cargo-watch for restarting commands on file change with
cargo watch ...
. Install withcargo install cargo-watch
.
Project structure
The directory and file structure is simple, we will setup a rust workspace with server
and frontend
subdirectories that host the server and frontend sub-projects.
We can set this up like that:
mkdir web-wasm-project
cd web-wasm-project
cargo new --bin server --vcs none
cargo new --bin frontend --vcs none
echo -e '[workspace]\nmembers = ["server", "frontend"]' > Cargo.toml
echo -e "target/\ndist/" > .gitignore
git init
Server
First, we add the depencies for the server:
cd server
cargo add \
axum \
log \
tower \
tracing \
tracing-subscriber \
clap --features clap/derive \
tokio --features tokio/full \
tower-http --features tower-http/full
We can now add code for starting the axum server and serving a simple text output to get started. Edit server/src/main.rs
to match:
use axum::{response::IntoResponse, routing::get, Router};
use clap::Parser;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
// Setup the command line interface with clap.
#[derive(Parser, Debug)]
#[clap(name = "server", about = "A server for our wasm project!")]
struct Opt {
/// set the listen addr
#[clap(short = 'a', long = "addr", default_value = "::1")]
addr: String,
/// set the listen port
#[clap(short = 'p', long = "port", default_value = "8080")]
port: u16,
}
#[tokio::main]
async fn main() {
let opt = Opt::parse();
let app = Router::new().route("/", get(hello));
let sock_addr = SocketAddr::from((
IpAddr::from_str(opt.addr.as_str()).unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
opt.port,
));
println!("listening on http://{}", sock_addr);
axum::Server::bind(&sock_addr)
.serve(app.into_make_service())
.await
.expect("Unable to start server");
}
async fn hello() -> impl IntoResponse {
"hello from server!"
}
The server can be started with cargo run --bin server
from the project root directory. You should see the “hello from server!” response at [::1]:8080 and localhost:8080 (if you want to use the IPv4 address).
Adding logging to the server
For an HTTP server it is useful to log requests. Axum and tower already use tracing to provide async-aware structured logging. We can setup tracing-subscriber to log those traces to stdout and also extend the command-line interface to accept setting the log verbosity via cli arguments.
diff --git a/server/src/main.rs b/server/src/main.rs
index 0000000..0000000 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -2,11 +2,17 @@ use axum::{response::IntoResponse, routing::get, Router};
use clap::Parser;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
+use tower::ServiceBuilder;
+use tower_http::trace::TraceLayer;
// Setup the command line interface with clap.
#[derive(Parser, Debug)]
#[clap(name = "server", about = "A server for our wasm project!")]
struct Opt {
+ /// set the log level
+ #[clap(short = 'l', long = "log", default_value = "debug")]
+ log_level: String,
+
/// set the listen addr
#[clap(short = 'a', long = "addr", default_value = "::1")]
addr: String,
@@ -20,14 +26,23 @@ struct Opt {
async fn main() {
let opt = Opt::parse();
- let app = Router::new().route("/", get(hello));
+ // Setup logging & RUST_LOG from args
+ if std::env::var("RUST_LOG").is_err() {
+ std::env::set_var("RUST_LOG", format!("{},hyper=info,mio=info", opt.log_level))
+ }
+ // enable console logging
+ tracing_subscriber::fmt::init();
+
+ let app = Router::new()
+ .route("/", get(hello))
+ .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
let sock_addr = SocketAddr::from((
IpAddr::from_str(opt.addr.as_str()).unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
opt.port,
));
- println!("listening on http://{}", sock_addr);
+ log::info!("listening on http://{}", sock_addr);
axum::Server::bind(&sock_addr)
.serve(app.into_make_service())
We will modify the server later to serve static content as well but for now let’s stop it (Ctrl-C) and first setup the frontend.
Frontend
We will use Trunk to bundle and serve (for development) the frontend code. You can install trunk with cargo install trunk
. (As of this writing trunk version 0.16 is the most recent version and what was used for testing.)
Let’s first add all the dependencies we (eventually) need:
cd frontend;
cargo add \
console_error_panic_hook \
gloo-net \
log \
wasm-bindgen-futures \
wasm-logger \
yew --features yew/csr \
yew-router
We are missing frontend/index.html
which Trunk will use as the base html template. Create that file and edit it to match:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="shortcut icon"type="image/x-icon" href="data:image/x-icon;,">
<title>Yew App</title>
</head>
<body>loading...</body>
</html>
Now edit frontend/src/main.rs
for rendering a simple yew component:
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Clone, Routable, PartialEq)]
enum Route {
#[at("/")]
Home,
}
fn switch(routes: Route) -> Html {
match routes {
Route::Home => html! { <h1>{ "Hello Frontend" }</h1> },
}
}
#[function_component(App)]
fn app() -> Html {
html! {
<BrowserRouter>
<Switch<Route> render={switch} />
</BrowserRouter>
}
}
fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
console_error_panic_hook::set_once();
yew::Renderer::<App>::new().render();
}
We are already using the yew router to make it easier to extend the app later. Running trunk serve
(from the frontend directory) will now start the frontend in dev mode at localhost:8080. Making a change to the frontend code will automatically reload that page. Hot-reload & Rust — finally :)
Connecting frontend and server
Right now, the frontend and server only run individually but are not connected in a way that would allow them to run together in development or production mode. For production mode (serving pre-compiled frontend files without Trunk) to work properly we will need to serve those static files. To also allow a single-page app (SPA) app that uses different URL path in the frontend, we need to fallback to serve index.html
for non-existing paths.
Let’s first modify the frontend to compile into a dist/
directory in the project root folder where files can be found by the server. Create a file frontend/Trunk.toml
with the content:
[build]
target = "index.html"
dist = "../dist"
We can now modify the server to deliver the pre-compiled files. We make three changes to the server code:
- We will allow to configure a
static_dir
directory that defaults to thedist/
folder in the project root directory. This is where we have configured Trunk to output compiled files inTrunk.toml
above. - We will use
tower_http::services::ServeDir
to actually create a response for requests matching a file path. We will do that by adding afallback
handler to the server-sideRouter
. - We move our “hello” route to
/api/hello
, we will query that later from the frontend to showcase client-server interaction.
diff --git a/server/src/main.rs b/server/src/main.rs
index 0000000..0000000 100644
@@ -1,8 +1,11 @@
+use axum::body::{boxed, Body};
+use axum::http::{Response, StatusCode};
use axum::{response::IntoResponse, routing::get, Router};
use clap::Parser;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
-use tower::ServiceBuilder;
+use tower::{ServiceBuilder, ServiceExt};
+use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
// Setup the command line interface with clap.
@@ -20,6 +23,10 @@ struct Opt {
/// set the listen port
#[clap(short = 'p', long = "port", default_value = "8080")]
port: u16,
+
+ /// set the directory where static files are to be found
+ #[clap(long = "static-dir", default_value = "./dist")]
+ static_dir: String,
}
#[tokio::main]
@@ -34,7 +41,16 @@ async fn main() {
tracing_subscriber::fmt::init();
let app = Router::new()
- .route("/", get(hello))
+ .route("/api/hello", get(hello))
+ .fallback_service(get(|req| async move {
+ match ServeDir::new(opt.static_dir).oneshot(req).await {
+ Ok(res) => res.map(boxed),
+ Err(err) => Response::builder()
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
+ .body(boxed(Body::from(format!("error: {err}"))))
+ .expect("error response"),
+ }
+ }))
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
let sock_addr = SocketAddr::from((
If you now run trunk build
in the frontend/
directory and cargo run --bin server
in the root directory, the server should startup again, this time bringing up the yew app at localhost:8080 again, but served statically!
Custom server requests and frontend routing
Let’s query the /api/hello
from the frontend.
First we will create a new yew component whose whole purpose it is to make a HTTP request to /api/hello
and display it. Add it to frontend/main.rs
:
#[function_component(HelloServer)]
fn hello_server() -> Html {
let data = use_state(|| None);
// Request `/api/hello` once
{
let data = data.clone();
use_effect(move || {
if data.is_none() {
spawn_local(async move {
let resp = Request::get("/api/hello").send().await.unwrap();
let result = {
if !resp.ok() {
Err(format!(
"Error fetching data {} ({})",
resp.status(),
resp.status_text()
))
} else {
resp.text().await.map_err(|err| err.to_string())
}
};
data.set(Some(result));
});
}
|| {}
});
}
match data.as_ref() {
None => {
html! {
<div>{"No server response"}</div>
}
}
Some(Ok(data)) => {
html! {
<div>{"Got server response: "}{data}</div>
}
}
Some(Err(err)) => {
html! {
<div>{"Error requesting data from server: "}{err}</div>
}
}
}
}
In addition we will also setup an additional frontend route to render the new component at localhost:8080/hello-server:
diff --git a/frontend/src/main.rs b/frontend/src/main.rs
index 0000000..0000000 100644
--- a/frontend/src/main.rs
+++ b/frontend/src/main.rs
@@ -1,3 +1,5 @@
+use gloo_net::http::Request;
+use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
@@ -5,11 +7,14 @@ use yew_router::prelude::*;
enum Route {
#[at("/")]
Home,
+ #[at("/hello-server")]
+ HelloServer,
}
fn switch(routes: &Route) -> Html {
match routes {
Route::Home => html! { <h1>{ "Hello Frontend" }</h1> },
+ Route::HelloServer => html! { <HelloServer /> },
}
}
Now let’s test it! Start the server on port 8081 and the frontend in dev mode with a proxy for api requests:
cargo run --bin server -- --port 8081
cd frontend; trunk serve --proxy-backend=http://[::1]:8081/api/
When you open your web browser at localhost:8080/hello-server you should now see:
But what happens when we serve the app statically?
cd frontend; trunk build
cargo run --bin server
- localhost:8080/hello-server
Oh no.
Making the file server support a SPA app
The reason for that is of course that the server tries to find the path /hello-server
in the file system due to the fallback handler as no other server-side route matches it. The client-side routes require index.html
to be loaded so that the JavaScript / WASM code can handle the request as we expect in a single-page application. In order to make it work we will need to have our fallback handler not give a 404 response when it cannot find a resource but return the index file:
diff --git a/server/src/main.rs b/server/src/main.rs
index 0000000..0000000 100644
@@ -3,7 +3,9 @@ use axum::http::{Response, StatusCode};
use axum::{response::IntoResponse, routing::get, Router};
use clap::Parser;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
+use std::path::PathBuf;
use std::str::FromStr;
+use tokio::fs;
use tower::{ServiceBuilder, ServiceExt};
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
@@ -43,8 +45,28 @@ async fn main() {
let app = Router::new()
.route("/api/hello", get(hello))
.fallback(get(|req| async move {
- match ServeDir::new(opt.static_dir).oneshot(req).await {
- Ok(res) => res.map(boxed),
+ match ServeDir::new(&opt.static_dir).oneshot(req).await {
+ Ok(res) => {
+ let status = res.status();
+ match status {
+ StatusCode::NOT_FOUND => {
+ let index_path = PathBuf::from(&opt.static_dir).join("index.html");
+ let index_content = match fs::read_to_string(index_path).await {
+ Err(_) => {
+ return Response::builder()
+ .status(StatusCode::NOT_FOUND)
+ .body(boxed(Body::from("index file not found")))
+ .unwrap()
+ }
+ Ok(index_content) => index_content,
+ };
+
+ Response::builder()
+ .status(StatusCode::OK)
+ .body(boxed(Body::from(index_content)))
+ .unwrap()
+ }
+ _ => res.map(boxed),
+ }
+ }
Err(err) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(boxed(Body::from(format!("error: {err}"))))
Conveniently running it
For running the dev or static version I prefer putting the necessary cargo commands into scripts. dev.sh
runs both Trunk and the server at the same time. The server is started using cargo-watch, a useful utility to detect file changes. In combination with trunk serve
this will auto-reload both client and server!
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
(trap 'kill 0' SIGINT; \
bash -c 'cd frontend; trunk serve --proxy-backend=http://[::1]:8081/api/' & \
bash -c 'cargo watch -- cargo run --bin server -- --port 8081')
prod.sh
will build the frontend and run the server:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
pushd frontend
trunk build
popd
cargo run --bin server --release -- --port 8080 --static-dir ./dist
And this is it! A complete starter pack for a full Rust web app!
A note about a bug stripping symbols in wasm code
Please note that there is an open rust compiler bug when enabling stripping symbols that breaks wasm code.
As reported by @malaire, a setting like profile.release.strip = true
will result in the example above not loading.
For the time being, only strip = "debuginfo"
is working.
A note on Trunk watch and serve
Should you observe that trunk serve
is rebuilding your frontend project from scratch each time you save a file, this can be one of two things:
There is an open issue with the file watcher that won’t debounce file change events. Just recently (2022-04-13) a fix was merged that is currently only on Trunk master. Use cargo install trunk --git https://github.com/thedodd/trunk
for installing the most recent version. (Update on 2023-01-09: This seems now to be in the Trunk release version as well.)
On Linux even this did not fix the behavior for me. I needed to specify I separate cargo target dir to untangle rust-analyzer from trunks build directory: Using bash -c 'cd frontend; CARGO_TARGET_DIR=target-trunk trunk serve'
in dev.sh
fixed that.
What to deploy
If you want to deploy just the server and dist repo to some machine, you will only need to build the frontend and the server binary (cd server; cargo build --release
), take server/target/release/server
and dist/
and put it into the same directory. If you then start the server with --static-dir ./dist
it will run as well.