tower-serve-embedded 0.1.0

Embed content-hashed, cache-busted static web assets into your binary and serve them as a tower::Service, with a compile-time asset! macro for SSR templates.
Documentation

tower-serve-embedded

Embed content-hashed, cache-busted static web assets (CSS, JS, images) into your Rust binary — and serve them from any tower/axum stack.

It's in the same family as rust-embed, but tuned for serving web assets:

  • Content hashing + cache busting. Every file is exposed at a hashed URL that mirrors its location in your project — assets/css/style.css/assets/css/style.9f3a1c2b.css — so it can be served immutable with a one-year cache and still update the instant its content changes.
  • A compile-time asset! macro. asset!("assets/css/style.css") expands to the hashed URL as a &'static str for SSR templates (e.g. maud). Typos are compile errors.
  • A tower::Service that serves the embedded assets with Content-Type, ETag/304, and Cache-Control. Drop it into axum or any other tower-based stack.

Design goals: simple (point it at a directory, done), hot-reloadable in development (via build.rs + cargo:rerun-if-changed, so cargo watch rebuilds on change), and no proc macros — all codegen happens in a build script, so IDE support stays sharp.

There's no URL-prefix to configure: an asset's path under your crate root is its URL.

Crates

Crate Where it goes Purpose
tower-serve-embedded [dependencies] Runtime types, the asset! macro, the tower service.
tower-serve-embedded-build [build-dependencies] Walks the asset dir, hashes files, generates code.

Quickstart

# Cargo.toml
[dependencies]
tower-serve-embedded = "0.1"

[build-dependencies]
tower-serve-embedded-build = "0.1"
// build.rs
fn main() {
    tower_serve_embedded_build::Builder::new("assets") // dir relative to your crate root
        // .ignore_dir("lib")                           // skip a subdir, e.g. vendored deps
        .emit()
        .unwrap();
}
// src/main.rs (or any module)
tower_serve_embedded::embed!(); // brings `ASSETS` and the `asset!` macro into scope

Reference assets in templates

Refer to assets by their path relative to the crate root. asset! resolves at compile time to the full, cache-busted URL:

let css = asset!("assets/css/style.css"); // "/assets/css/style.9f3a1c2b.css"

It drops straight into any engine that interpolates &str, e.g. maud:

html! {
    link rel="stylesheet" href=(asset!("assets/css/style.css"));
    script src=(asset!("assets/js/app.js")) defer {}
}

Need a name that isn't known at compile time? Use the runtime equivalent:

let url: Option<&'static str> = ASSETS.url("assets/css/style.css");

Serve them

Each embedded path is already the full URL, so mount the service as a fallback — no nesting or prefix to keep in sync:

use axum::{Router, routing::get};

let app = Router::new()
    .route("/", get(index))
    .fallback_service(ASSETS.service());

The service handles GET/HEAD, returns 304 Not Modified for matching If-None-Match, and sets Content-Type, a strong ETag, and Cache-Control: public, max-age=31536000, immutable. It's a plain tower::Service, so wrap it in any tower layer (compression, tracing, …) you like.

Prefer not to use the fallback slot? Scope it to the asset directory instead — the service still matches the full path, so route it without stripping the prefix: Router::route_service("/assets/{*path}", ASSETS.service()).

Hot reloading

The build script emits cargo:rerun-if-changed for the asset directory and every file, so:

cargo watch -x run

Editing an asset re-runs the build script, recomputes the hash, and rebuilds — the new cache-busted URL flows through asset! automatically. Dev and release behave identically; there's no separate "debug" embedding mode to reason about.

Two mechanisms cooperate here, and it's worth knowing they're distinct:

  • cargo watch is a filesystem watcher; its job is to invoke cargo when a file changes. With no -w flags it watches the whole crate (both src and assets, ignoring target), so you don't need to enumerate paths.
  • The build script's cargo:rerun-if-changed lines are a cargo signal that decides whether to re-run the build script once cargo is invoked. They don't watch the filesystem, so they don't replace cargo watch — they're what makes the rebuild actually pick up the new bytes and hash.

Notes

  • Hashing is BLAKE3, truncated to 16 hex chars (64 bits) for filenames and ETags.
  • Hidden files/directories (names starting with .) and symlinks are skipped.
  • One asset set per crate: the generated asset! is #[macro_export]ed at your crate root.
  • Every served asset is treated as immutable. A stable, always-fresh HTML entry point (SPA index.html) is intentionally out of scope — keep that route in your app.

Examples

Runnable, self-contained setups live in examples/, each with its own build.rs, assets/, and main.rs:

Example Run How it serves assets
axum cargo run -p axum-example Mounts ASSETS.service() (a tower::Service) as a fallback.
actix cargo run -p actix-example Uses the framework-agnostic data API (ASSETS.get).
warp cargo run -p warp-example Uses the framework-agnostic data API (ASSETS.get).

axum is tower-based, so it mounts the service directly. actix-web and warp aren't, so they look assets up with Assets::get and build the response by hand — the asset! macro and the build.rs embedding step are identical across all three.

License

MIT OR Apache-2.0.