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 servedimmutablewith a one-year cache and still update the instant its content changes. Already-versioned assets can be marked withimmutable_dirand 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 strfor SSR templates (e.g.maud). Typos are compile errors. - A
tower::Servicethat serves the embedded assets withContent-Type,ETag/304, andCache-Control. Drop it into axum or any othertower-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
[]
= "0.1"
[]
= "0.1"
// build.rs
// src/main.rs (or any module)
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.9f3a1c2b.css"
It drops straight into any engine that interpolates &str, e.g. maud:
html!
Need a name that isn't known at compile time? Use the runtime equivalent:
let url: = ASSETS.url;
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 ;
let app = new
.route
.fallback_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
new
.immutable_dir // 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"
Hot reloading
The build script emits cargo:rerun-if-changed for the asset directory and every file, so:
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 watchis a filesystem watcher; its job is to invoke cargo when a file changes. With no-wflags it watches the whole crate (bothsrcandassets, ignoringtarget), so you don't need to enumerate paths.- The build script's
cargo:rerun-if-changedlines 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 replacecargo 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!()definesASSETSand a crate-localasset!macro at the invocation site. Put it at the crate root if you wantcrate::asset!(...)everywhere in your crate. - Ordinary assets are served only at their cache-busted URL (
immutable);immutable_dirassets are served only at their plain URL (immutable). A stable, always-fresh HTML entry point (SPAindex.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.