DEV Community

Konstantin
Konstantin

Posted on

Bundle frontend into axum binary using include_dir

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Healthcheck route

Now we can start implementing our routes. We need health route for two reasons:

  1. it's a good idea in general to have a health in order to determine whether backend is up and running
  2. 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" }))
}
Enter fullscreen mode Exit fullscreen mode

Frontend

We can easily generate the frontend with vite:

$ npm create vite@latest frontend -- --template react
Enter fullscreen mode Exit fullscreen mode

The only other thing that we need to do is to install frontend dependencies:

$ npm install
Enter fullscreen mode Exit fullscreen mode

Frontend router

Frontend router would be responsible for multiple things:

  1. calling include_dir in order to bake the frontend/dist folder into the server binary
  2. serving static files that were bundled
  3. handling default files, like index.html if we called a route that points to a folder
  4. 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 }))
}
Enter fullscreen mode Exit fullscreen mode

Here we bundle frontend/dist with:

static FRONTEND_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/frontend/dist");
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Running the server

To run server we can either run

$ cargo run
Enter fullscreen mode Exit fullscreen mode

or we can install cargo-watch and run it like so:

$ RUST_BACKTRACE=1 cargo watch -x run -w src -w frontend/src
Enter fullscreen mode Exit fullscreen mode

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:
Image description

The frontend that is served right from inside of server binary.

Top comments (0)