# tower-serve-embedded
Embed content-hashed, cache-busted static web assets (CSS, JS, images) into your Rust binary —
and serve them from any [`tower`](https://crates.io/crates/tower)/[`axum`](https://crates.io/crates/axum)
stack.
It's in the same family as [`rust-embed`](https://crates.io/crates/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`](https://maud.lambda.xyz)). 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
| `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
```toml
# Cargo.toml
[dependencies]
tower-serve-embedded = "0.1"
[build-dependencies]
tower-serve-embedded-build = "0.1"
```
```rust
// 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();
}
```
```rust
// 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.
```rust
let css = asset!("assets/css/style.css"); // "/assets/css/style.9f3a1c2b.css"
```
It drops straight into any engine that interpolates `&str`, e.g. `maud`:
```rust
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:
```rust
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:
```rust
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`](#immutable-already-versioned-assets) 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`:
```rust
// 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:
```rust
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:
```sh
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/`](examples), each with its own `build.rs`,
`assets/`, and `main.rs`:
| [`axum`](examples/axum) | `cargo run -p axum-example` | Mounts `ASSETS.service()` (a `tower::Service`) as a fallback. |
| [`actix`](examples/actix) | `cargo run -p actix-example` | Uses the framework-agnostic data API (`ASSETS.resolve`). |
| [`warp`](examples/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.
[`Assets::resolve`]: https://docs.rs/tower-serve-embedded/latest/tower_serve_embedded/struct.Assets.html#method.resolve
## License
MIT OR Apache-2.0.