tower-serve-embedded 0.2.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 ordinary 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. Already-versioned assets can be marked with immutable_dir and served at their plain, non-hashed URL.
  • A compile-time asset! macro. asset!("assets/css/style.css") expands to the generated served 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
        // .immutable_dir("lib")                        // already-versioned deps: serve as-is, don't re-hash
        .emit()
        .unwrap();
}
// src/main.rs (or any module)
tower_serve_embedded::embed!(); // brings `ASSETS` and the `asset!` macro into scope

Call embed!() at the crate root if you want to use the macro from other modules as crate::asset!(...).

Reference assets in templates

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

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 generated asset URL is already the full path, 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 for generated routes. Ordinary assets are served only at their cache-busted URL; assets under immutable_dir are served only at their plain, non-hashed URL. 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()).

Immutable, already-versioned assets

Some assets are already immutable because their name encodes a version — vendored libraries like assets/lib/htmx-1.9.10.min.js, or anything you pull from a CDN. Re-hashing those just produces an uglier URL. Mark their directory with immutable_dir:

// build.rs
tower_serve_embedded_build::Builder::new("assets")
    .immutable_dir("lib") // relative to the embedded dir
    .emit()
    .unwrap();

Files under it are still embedded and served, but only at their original URL (/assets/lib/htmx-1.9.10.min.js) — no extra hash — with the one-year immutable cache. asset! and ASSETS.url(..) return that plain path:

let htmx = asset!("assets/lib/htmx-1.9.10.min.js"); // "/assets/lib/htmx-1.9.10.min.js"

Hot reloading

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

cargo watch -x run

Editing an ordinary 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 module: embed!() defines ASSETS and a crate-local asset! macro at the invocation site. Put it at the crate root if you want crate::asset!(...) everywhere in your crate.
  • Ordinary assets are served only at their cache-busted URL (immutable); immutable_dir assets are served only at their plain URL (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.resolve).
warp cargo run -p warp-example Uses the framework-agnostic data API (ASSETS.resolve).

axum is tower-based, so it mounts the service directly. actix-web and warp aren't, so they look assets up with Assets::resolve 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.