If you have a frontend client that is tightly coupled with backend it might make sense to bake the frontend files inside backend binary. This way you can deploy only backend and be sure that frontend will be deployed as well.
There are some proposals on how to do it already such as https://github.com/tokio-rs/axum/issues/1698.
But we'll be using include_dir
.
Setup
Our project will have the following structure:
├── src/ -> server source code
│ ├── routes/
│ │ ├── mod.rs
│ │ ├── healthcheck.rs
│ │ └── frontend.rs
│ └── main.rs
├── frontend/
│ ├── dist/ -> folder that we'll be baking into binary
│ ...
├── Cargo.toml
└── build.rs
First, let's prepare the Cargo.toml
and specify all dependencies:
[package]
name = "axum_include_dir"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.5"
tokio = { version = "1", features = ["full"] }
mime_guess = "2.0.4"
include_dir = "0.7.3"
time = "0.3.36"
Healthcheck route
Now we can start implementing our routes. We need health
route for two reasons:
- it's a good idea in general to have a
health
in order to determine whether backend is up and running - in our case we want to make sure that our solution won't rewrite backend routes (in case if we have files with the same name as routes)
Here's our simple route(src/routes/healthcheck.rs
):
use axum::{Router, routing::get};
pub(crate) fn router() -> Router {
Router::new().route("/health", get(|| async { "ok" }))
}
Frontend
We can easily generate the frontend with vite:
$ npm create vite@latest frontend -- --template react
The only other thing that we need to do is to install frontend dependencies:
$ npm install
Frontend router
Frontend router would be responsible for multiple things:
- calling
include_dir
in order to bake thefrontend/dist
folder into the server binary - serving static files that were bundled
- handling default files, like
index.html
if we called a route that points to a folder - handling not found errors
Here is the full src/routes/frontend.rs
file:
use std::path::PathBuf;
use axum::{
http::{StatusCode, header},
response::{Response, IntoResponse},
Router,
routing::get,
extract::Path,
body::Body
};
use include_dir::{include_dir, Dir, File};
use mime_guess::{Mime, mime};
use time::Duration;
const ROOT: &str = "";
const DEFAULT_FILES: [&str; 2] = ["index.html", "index.htm"];
const NOT_FOUND: &str = "404.html";
static FRONTEND_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/frontend/dist");
async fn serve_asset(path: Option<Path<String>>) -> impl IntoResponse {
let serve_file = |file: &File, mime_type: Option<Mime>, cache: Duration, code: Option<StatusCode>| {
Response::builder()
.status(code.unwrap_or(StatusCode::OK))
.header(header::CONTENT_TYPE, mime_type.unwrap_or(mime::TEXT_HTML).to_string())
.header(header::CACHE_CONTROL, format!("max-age={}", cache.as_seconds_f32()))
.body(Body::from(file.contents().to_owned()))
.unwrap()
};
let serve_not_found = || {
match FRONTEND_DIR.get_file(NOT_FOUND) {
Some(file) => serve_file(file, None, Duration::ZERO, Some(StatusCode::NOT_FOUND)),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File Not Found"))
.unwrap()
}
};
let serve_default = |path: &str| {
for default_file in DEFAULT_FILES.iter() {
let default_file_path = PathBuf::from(path).join(default_file);
if FRONTEND_DIR.get_file(default_file_path.clone()).is_some() {
return serve_file(
FRONTEND_DIR.get_file(default_file_path).unwrap(),
None,
Duration::ZERO,
None,
);
}
}
serve_not_found()
};
match path {
Some(Path(path)) => {
if path == ROOT {
return serve_default(&path);
}
FRONTEND_DIR.get_file(&path).map_or_else(
|| {
match FRONTEND_DIR.get_dir(&path) {
Some(_) => serve_default(&path),
None => serve_not_found()
}
},
|file| {
let mime_type = mime_guess::from_path(PathBuf::from(path.clone())).first_or_octet_stream();
let cache = if mime_type == mime::TEXT_HTML {
Duration::ZERO
} else {
Duration::days(365)
};
serve_file(file, Some(mime_type), cache, None)
},
)
}
None => serve_not_found()
}
}
pub(crate) fn router() -> Router {
Router::new()
.route("/", get(|| async { serve_asset(Some(Path(String::from(ROOT)))).await }))
.route("/*path", get(|path| async { serve_asset(Some(path)).await }))
}
Here we bundle frontend/dist
with:
static FRONTEND_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/frontend/dist");
On each request we try to get the file first by using FRONTEND_DIR.get_file(&path)
and if None
is returned we check if the path
is a folder with FRONTEND_DIR.get_dir(&path)
.
In case of the folder we try to find one of the DEFAULT_FILES
array inside it and serve them. If we can't find neither a file nor a folder we try to return 404.html
file if it exists in the FRONTEND_DIR
or a simple File Not Found
message with 404
code. In order to add 404.html
in a frontend generated by vite
you need to simply create that file in frontend/public
folder.
We also try to guess the mime type with the help of mime_guess
crate and cache duration based on file type.
Building frontend
In order to trigger the frontend build we need to create build.rs
file:
use std::process::Command;
fn main() {
let output = Command::new("npm")
.args(&["run", "build"])
.current_dir("frontend")
.output()
.expect("Failed to execute command");
if !output.status.success() {
panic!("Command executed with failing error code");
}
}
Each time we'll run cargo run
the frontend will be built as well.
Merging routes
Now that we have all routes ready we can merge them in main.rs
file and launch axum
server:
mod routes;
use axum::Router;
use crate::routes::{healthcheck, frontend};
#[tokio::main]
async fn main() {
let app = Router::new()
.merge(healthcheck::router())
.merge(frontend::router());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Running the server
To run server we can either run
$ cargo run
or we can install cargo-watch and run it like so:
$ RUST_BACKTRACE=1 cargo watch -x run -w src -w frontend/src
This way every time that you change either server source code or frontend source code cargo will rebuild both.
And there it is on localhost:3000
:
The frontend that is served right from inside of server binary.
Top comments (0)