April 3, 2022

A Rust web server / frontend setup like it's 2022 (with axum and yew)

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

  • 2022-04-19: Fixed Trunk command (trunk serve, not trunk 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 using SpaRouter

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-edit for adding dependencies to Cargo.toml using cargo add <dependency>. Install with cargo install cargo-edit.
  • cargo-watch for restarting commands on file change with cargo watch .... Install with cargo install cargo-watch.

Project structure

The directory and file structure is simple, we will setup two individual Rust projects server and frontend. We will not use a Rust workspace for this setup so that both projects can be (re)compiled at the same time without stepping on each others toes, resulting in a full re-build for one or both projects, completely preventing a quick feedback loop.

flowchart TD; root([web-wasm-project]) server([server]); frontend([frontend]); root --- server; root --- frontend; server --- cargo_1(Cargo.toml) server --- main_1(src/main.rs) frontend --- cargo_2(Cargo.toml) frontend --- trunk_2(Trunk.toml) frontend --- index_2(index.html) frontend --- main_2(src/main.rs)

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 "target/\n/dist/" > .gitignore
git init

Server

Using cargo-edit we add the dependencies for the server:

cd server
cargo add \
  axum \
  axum-extra +spa \
  clap +derive \
  log \
  tokio +full \
  tower \
  tower-http +full \
  tracing \
  tracing-subscriber

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

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

Let’s first add all the dependencies we (eventually) need:

cargo add \
  console_error_panic_hook \
  gloo-net \
  log \
  wasm-bindgen-futures \
  wasm-logger \
  yew \
  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::render(switch)} />
        </BrowserRouter>
    }
}

fn main() {
    wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
    console_error_panic_hook::set_once();
    yew::start_app::<App>();
}

We are already using the yew router to make it easier to extend the app later. Running trunk serve 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 and let’s also prepare it to make backend requests later. Create a file frontend/Trunk.toml with the content:

[build]
target = "index.html"
dist = "../dist"

[[proxy]]
backend = "http://[::1]:8081/api/"

We can now modify the server to deliver the pre-compiled files. We make three changes to the server code:

  1. We will allow to configure a static_dir directory that defaults to the dist/ foldler in the project root directory. This is where we have configured Trunk to output compiled files in Trunk.toml above.
  2. We will use tower_http::services::ServeDir to actually create a response for requests matching a file path. We will do that by adding a fallback handler to the server-side Router.
  3. We move our “hello” route to /api/hello, we will query that later from the frontend to showcase client-server interaction.
@@ -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(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 in the server/ 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 9315ab4..d85cdd2 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:

  • cd server; cargo run -- --port 8081
  • cd frontend; trunk serve

When you open your web browser at localhost:8080/hello-server you should now see:

But what happens when we serve the app statically?

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:

@@ -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,27 @@ 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) => match res.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}"))))

As was pointed out on reddit, the axum-extra crate does provide a SpaRouter that implements a similar behavior. So for our example a router setup like the following will suffice:

let app = Router::new()
    .route("/api/hello", get(hello))
    .merge(axum_extra::routing::SpaRouter::new("/assets", opt.static_dir))
    .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));

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' & \
 bash -c 'cd server; cargo watch -x 'run -- --port 8081')

prod.sh will build the frontend and run the server:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

pushd frontend
# note: when using SpaRouter this needs to be
#   "trunk build --public-url /assets/"
trunk build
popd

pushd server
cargo run --release -- --port 8080
popd

And this is it! A complete starter pack for a full Rust web app!

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.

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.

© Robert Krahn 2009-2022