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
//! Embed content-hashed static web assets into your binary and serve them with `tower`.
//!
//! `tower-serve-embedded` is similar to [`rust-embed`](https://docs.rs/rust-embed) but tailored
//! for *serving* web assets from a `tower`/`axum` stack: ordinary files are embedded and exposed at
//! a content-hashed URL that mirrors their location in your crate
//! (`assets/css/style.css` → `/assets/css/style.9f3a1c2b.css`), so they can be served `immutable`
//! with a one-year cache and still update instantly when their content changes.
//!
//! # How it works
//!
//! The heavy lifting (walking the directory, hashing, MIME detection, codegen) happens at build
//! time in [`tower-serve-embedded-build`](https://docs.rs/tower-serve-embedded-build), called
//! from your `build.rs`. There are **no proc macros** — the generated code is plain data plus a
//! tiny `macro_rules!`, so IDE support stays excellent.
//!
//! ```ignore
//! // build.rs
//! fn main() {
//!     tower_serve_embedded_build::Builder::new("assets").emit().unwrap();
//! }
//! ```
//!
//! ```ignore
//! // src/main.rs
//! tower_serve_embedded::embed!(); // pulls in `ASSETS` and the `asset!` macro
//!
//! // Reference assets by their path relative to the crate root. Resolved at compile time —
//! // typos are compile errors:
//! //   link rel="stylesheet" href=(asset!("assets/css/style.css"))
//! //   => "/assets/css/style.9f3a1c2b.css"
//!
//! // Serve them: generated asset URLs are already full paths, so mount as a fallback.
//! //   Router::new().fallback_service(ASSETS.service())
//! ```
//!
//! See `examples/` in the repository for complete, runnable setups (`axum`, `actix`, `warp`).

mod service;

pub use service::ServeEmbedded;

/// The `Cache-Control` value sent for content-hashed URLs and for assets in an `immutable_dir`:
/// a one-year, `public`, `immutable` cache. The bytes behind such a URL never change.
pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";

/// A single embedded asset: its content plus the metadata needed to serve it.
///
/// Values of this type are generated at build time and stored in a `static` slice; you do not
/// construct them by hand.
///
/// Every file records its stable, non-hashed [`url`](Self::url). Files that are content-hashed
/// (the default) are served at their cache-busted [`hashed_url`](Self::hashed_url); files in an
/// `immutable_dir` are not re-hashed, have `hashed_url == None`, and are served at their plain
/// [`url`](Self::url).
#[derive(Debug, Clone, Copy)]
pub struct EmbeddedFile {
    /// The stable, non-hashed URL mirroring the file's path under the crate root with a leading
    /// slash, e.g. `/assets/css/style.css`. This URL is served only for assets in an
    /// `immutable_dir`; ordinary assets are served only at their [`hashed_url`](Self::hashed_url).
    pub url: &'static str,
    /// The full, content-hashed, cache-busted URL, e.g. `/assets/css/style.9f3a1c2b.css`, served
    /// `immutable`. `None` for assets in an `immutable_dir` (already immutable, so not re-hashed).
    /// When present, this is what the generated `asset!` macro and [`Assets::url`] prefer.
    pub hashed_url: Option<&'static str>,
    /// The original path of the file relative to the **crate root**, e.g. `assets/css/style.css`
    /// — the key you pass to `asset!` and [`Assets::get_logical`].
    pub logical_path: &'static str,
    /// The raw file contents (via `include_bytes!`).
    pub bytes: &'static [u8],
    /// The guessed MIME type, e.g. `text/css`.
    pub content_type: &'static str,
    /// A strong, quoted `ETag` derived from the content hash, e.g. `"\"9f3a1c2b…\""`.
    pub etag: &'static str,
    /// The hex content hash used for cache busting and the `ETag`.
    pub hash: &'static str,
}

/// A served URL mapped to the file that answers it and the `Cache-Control` to send.
///
/// One generated file produces one route: its [`hashed_url`](EmbeddedFile::hashed_url) for
/// ordinary assets, or its plain [`url`](EmbeddedFile::url) for assets in an `immutable_dir`.
/// Generated at build time and sorted by `url` for binary search; you do not construct these by
/// hand outside of tests.
#[derive(Debug, Clone, Copy)]
pub struct Route {
    /// The served URL this route matches.
    pub url: &'static str,
    /// Index into the [`Assets`] file slice of the file that answers this URL.
    pub file: usize,
    /// The `Cache-Control` value to send. Generated routes use
    /// `Some(`[`IMMUTABLE_CACHE_CONTROL`]`)` because every served generated URL is immutable.
    pub cache_control: Option<&'static str>,
}

/// The outcome of resolving a request path against an [`Assets`] set: which file to serve and the
/// `Cache-Control` to send with it. Returned by [`Assets::resolve`].
#[derive(Debug, Clone, Copy)]
pub struct Resolved {
    /// The matched file.
    pub file: &'static EmbeddedFile,
    /// The `Cache-Control` to set, or `None` to send none for custom route tables.
    pub cache_control: Option<&'static str>,
}

/// A collection of [`EmbeddedFile`]s plus the [`Route`] table that maps served URLs to them.
///
/// You normally get one via the generated `ASSETS` static (see [`embed!`]), not by calling
/// [`Assets::new`] yourself.
#[derive(Debug, Clone, Copy)]
pub struct Assets {
    files: &'static [EmbeddedFile],
    /// Sorted by [`Route::url`] so [`resolve`](Self::resolve) can binary-search.
    routes: &'static [Route],
}

impl Assets {
    /// Construct an asset set from its files and route table. `routes` **must** be sorted by
    /// [`Route::url`], and each [`Route::file`] must index into `files`; the build script
    /// guarantees both for the generated `ASSETS` static.
    pub const fn new(files: &'static [EmbeddedFile], routes: &'static [Route]) -> Self {
        Self { files, routes }
    }

    /// Resolve a served URL — a [`hashed_url`](EmbeddedFile::hashed_url) like
    /// `/assets/css/style.9f3a1c2b.css`, or the plain [`url`](EmbeddedFile::url) of an
    /// `immutable_dir` asset — to the file that answers it and the `Cache-Control` to send. This is
    /// the lookup [`ServeEmbedded`] uses; hand-rolled (non-`tower`) integrations call it to build a
    /// response themselves.
    pub fn resolve(&self, url: &str) -> Option<Resolved> {
        let files = self.files;
        let i = self.routes.binary_search_by_key(&url, |r| r.url).ok()?;
        let route = self.routes[i];
        Some(Resolved {
            file: &files[route.file],
            cache_control: route.cache_control,
        })
    }

    /// Look up a file by its crate-root-relative [`logical_path`](EmbeddedFile::logical_path),
    /// e.g. `assets/css/style.css`.
    pub fn get_logical(&self, logical: &str) -> Option<&'static EmbeddedFile> {
        self.files.iter().find(|f| f.logical_path == logical)
    }

    /// The URL to reference an asset by, given its crate-root-relative path, e.g.
    /// `url("assets/css/style.css")` → `Some("/assets/css/style.9f3a1c2b.css")`. Returns the
    /// cache-busted [`hashed_url`](EmbeddedFile::hashed_url) when there is one, otherwise the plain
    /// [`url`](EmbeddedFile::url) (for `immutable_dir` assets).
    ///
    /// This is the runtime equivalent of the `asset!` macro, for cases where the asset name isn't
    /// known at compile time. Prefer `asset!` when you can — it's checked at compile time.
    pub fn url(&self, logical: &str) -> Option<&'static str> {
        self.get_logical(logical)
            .map(|f| f.hashed_url.unwrap_or(f.url))
    }

    /// Iterate over every embedded file.
    pub fn iter(&self) -> impl Iterator<Item = &'static EmbeddedFile> {
        self.files.iter()
    }

    /// How many files are embedded.
    pub fn len(&self) -> usize {
        self.files.len()
    }

    /// Whether there are no embedded files.
    pub fn is_empty(&self) -> bool {
        self.files.is_empty()
    }

    /// A [`tower::Service`](tower_service::Service) that serves these assets.
    ///
    /// Generated asset URLs are already full paths, so mount the service as a fallback (no nesting
    /// or prefix stripping):
    ///
    /// ```ignore
    /// Router::new().fallback_service(ASSETS.service())
    /// ```
    pub fn service(&'static self) -> ServeEmbedded {
        ServeEmbedded::new(self)
    }
}

/// Pull in the assets generated by `tower-serve-embedded-build`.
///
/// Call this once at module scope (typically at the crate root in `main.rs` or `lib.rs`). It
/// expands to an `include!` of the build script's generated file, bringing into scope:
///
/// - `pub static ASSETS: tower_serve_embedded::Assets` — the embedded asset set, and
/// - `asset!` — a crate-local compile-time macro mapping a crate-root-relative path to its URL.
///
/// The generated `asset!` macro is deliberately not emitted with `#[macro_export]`, because Rust
/// treats `macro_export` macros generated by another macro as future-incompatible when they are
/// referenced by absolute paths. If you invoke `embed!()` at the crate root, you can use
/// `crate::asset!(...)` from other modules.
///
/// ```ignore
/// tower_serve_embedded::embed!();
///
/// fn css() -> &'static str { crate::asset!("assets/css/style.css") } // "/assets/css/style.9f3a1c2b.css"
/// ```
#[macro_export]
macro_rules! embed {
    () => {
        include!(concat!(env!("OUT_DIR"), "/embed_assets.rs"));
    };
}