Bundle frontend into axum binary using include_dir
Tags: rust, axum, frontend, bundle
Published: 2024-04-14 01:45:34

---
title: Bundle frontend into axum binary using include_dir
published: true
description: instructions on how to bundle frontend into axum server binary with help of include_dir
tags: rust, axum, frontend, bundle
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2024-04-13 23:42 +0000
---

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`](https://docs.rs/include_dir/latest/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:
```toml
[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:
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`):
```rust
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](https://vitejs.dev/guide/):
```bash
$ npm create vite@latest frontend -- --template react
```

The only other thing that we need to do is to install frontend dependencies:
```bash
$ npm install
```

### 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:
```rust
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:
```rust 
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](https://docs.rs/mime_guess/) and cache duration based on file type.

### Building frontend

In order to trigger the frontend build we need to create `build.rs` file:
```rust
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:
```rust
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
```bash
$ cargo run
```

or we can install [cargo-watch](https://watchexec.github.io/#cargo-watch) and run it like so:
```bash
$ 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`:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/prvdsed97x4nr5ia2nyf.png)

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