AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/cli/src/asset_pipeline.rs

Latest change e888012a38cfe0d4c47ddf4b3eecfba5ad6b8ac1 - Wire CSS engine + asset pipeline into serve/dev by Ólafur Búi Ólafsson

//! Wire the static-asset pipeline ([`akurai_assets`]) into `serve`.
//!
//! On `akurai serve` startup every static asset under the frontend dir is read,
//! conservatively minified (CSS/JS only), and content-hashed into a fingerprinted
//! name (`styles.css` ⇒ `styles.<hash>.css`). The resulting manifest is held in
//! memory; nothing is written to disk and the original files are untouched.
//!
//! Serving rules (backward compatible):
//! - A request for a **fingerprinted** path that the manifest knows
//!   (`/styles.<hash>.css`) is served from memory with the processed (minified)
//!   bytes, the correct `Content-Type`, and
//!   `Cache-Control: public, max-age=31536000, immutable` — the URL is the cache
//!   key, so it can be cached forever.
//! - A request for the **plain** path (`/styles.css`) resolves exactly as before
//!   (raw file off disk, no immutable header). Projects that never reference a
//!   fingerprinted URL are completely unaffected.
//! - The manifest (plain ⇒ hashed name map) is exposed at `/api/_assets` so a
//!   page or tool can discover the fingerprinted URLs to link.
//!
//! Under `akurai dev` the pipeline is **disabled** (an empty manifest): dev wants
//! fresh, un-hashed assets and live-reload, not far-future caching.

use crate::mime;
use akurai_assets::{hashed_name, minify_css, minify_js};
use akurai_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

/// The far-future, never-revalidate cache policy for fingerprinted assets.
pub const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";

/// One processed asset, keyed in the manifest by its fingerprinted request path.
#[derive(Clone)]
struct Entry {
    /// Content-Type for the asset.
    content_type: String,
    /// Processed (minified where applicable) bytes that the hash is computed over.
    bytes: Vec<u8>,
}

/// In-memory fingerprint manifest. Empty in dev (and for any project with no
/// static assets), in which case every lookup misses and serving falls back to
/// the plain on-disk path.
#[derive(Clone, Default)]
pub struct Manifest {
    /// Fingerprinted request path ⇒ entry.
    by_hashed: BTreeMap<String, Entry>,
    /// Plain request path ⇒ fingerprinted request path (manifest for consumers).
    plain_to_hashed: BTreeMap<String, String>,
}

impl Manifest {
    /// An empty manifest — the dev-mode and no-assets case.
    pub fn empty() -> Manifest {
        Manifest::default()
    }

    /// Build the manifest by scanning every static asset under `root`. Additional
    /// in-memory assets (e.g. the generated `utilities.css`) are folded in via
    /// `extra` as `(plain_path, bytes)`.
    pub fn build(root: &Path, extra: &[(String, Vec<u8>)]) -> Manifest {
        let mut manifest = Manifest::default();
        let mut files: Vec<PathBuf> = Vec::new();
        walk(root, &mut files);
        files.sort();
        for path in &files {
            let Ok(raw) = fs::read(path) else { continue };
            let Some(rel) = path.strip_prefix(root).ok().and_then(|p| p.to_str()) else {
                continue;
            };
            let plain = format!("/{}", rel.replace('\\', "/"));
            manifest.insert(&plain, raw);
        }
        for (plain, bytes) in extra {
            manifest.insert(plain, bytes.clone());
        }
        manifest
    }

    /// Process and register one asset under its plain request path.
    fn insert(&mut self, plain_path: &str, raw: Vec<u8>) {
        let content_type = mime::for_path(plain_path).to_string();
        let bytes = process(plain_path, raw);
        // Hash the processed bytes so the fingerprint matches what is served.
        let file_name = plain_path.rsplit('/').next().unwrap_or(plain_path);
        let hashed_file = hashed_name(file_name, &bytes);
        let prefix = &plain_path[..plain_path.len() - file_name.len()];
        let hashed_path = format!("{prefix}{hashed_file}");
        self.plain_to_hashed
            .insert(plain_path.to_string(), hashed_path.clone());
        self.by_hashed.insert(
            hashed_path,
            Entry {
                content_type,
                bytes,
            },
        );
    }

    /// Look up a fingerprinted request path. `None` means "not a known hashed
    /// asset" — the caller should fall back to plain on-disk serving.
    pub fn hashed(&self, request_path: &str) -> Option<HashedAsset<'_>> {
        self.by_hashed.get(request_path).map(|e| HashedAsset {
            content_type: &e.content_type,
            bytes: &e.bytes,
        })
    }

    /// The manifest as JSON: `{ "/styles.css": "/styles.<hash>.css", ... }`. Used
    /// to back the `/api/_assets` endpoint so consumers can resolve hashed URLs.
    pub fn to_json(&self) -> Value {
        let pairs = self
            .plain_to_hashed
            .iter()
            .map(|(plain, hashed)| (plain.clone(), Value::Str(hashed.clone())))
            .collect();
        Value::Object(pairs)
    }

    /// The fingerprinted path for a plain path, if any (test/inspection helper).
    #[cfg(test)]
    pub fn hashed_path_for(&self, plain: &str) -> Option<&str> {
        self.plain_to_hashed.get(plain).map(String::as_str)
    }

    /// Number of fingerprinted assets in the manifest (used in the startup log).
    pub fn len(&self) -> usize {
        self.by_hashed.len()
    }
}

/// A resolved fingerprinted asset: the processed bytes and their content type.
pub struct HashedAsset<'a> {
    pub content_type: &'a str,
    pub bytes: &'a [u8],
}

/// Conservatively process an asset's bytes by extension: minify CSS and JS,
/// leave everything else (images, fonts, JSON, …) byte-for-byte. Non-UTF-8 CSS/JS
/// is passed through untouched rather than risking a lossy round-trip.
fn process(plain_path: &str, raw: Vec<u8>) -> Vec<u8> {
    let ext = plain_path
        .rsplit('.')
        .next()
        .unwrap_or("")
        .to_ascii_lowercase();
    match ext.as_str() {
        "css" => match std::str::from_utf8(&raw) {
            Ok(text) => minify_css(text).into_bytes(),
            Err(_) => raw,
        },
        "js" | "mjs" => match std::str::from_utf8(&raw) {
            Ok(text) => minify_js(text).into_bytes(),
            Err(_) => raw,
        },
        _ => raw,
    }
}

/// A request is a static asset if its last segment has an extension other than
/// `.html`. Mirrors `cmd_serve::is_static_asset`.
fn is_static(path: &Path) -> bool {
    match path.extension().and_then(|e| e.to_str()) {
        Some(ext) => !ext.eq_ignore_ascii_case("html"),
        None => false,
    }
}

fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(meta) = entry.metadata() else { continue };
        if meta.is_dir() {
            walk(&path, out);
        } else if is_static(&path) {
            out.push(path);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use akurai_assets::fingerprint;

    /// Scratch base: `CARGO_TARGET_TMPDIR` when present, else this crate's own
    /// `target/` — never `/tmp`. Mirrors the helper in `collections_api.rs`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("assets-test-tmp"),
        }
    }

    fn write(path: &Path, body: &[u8]) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, body).unwrap();
    }

    #[test]
    fn fingerprints_and_minifies_css() {
        let dir = scratch_base().join("assets_css");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("styles.css"), b"a {\n  color: red;\n}\n");
        let m = Manifest::build(&dir, &[]);

        let hashed = m.hashed_path_for("/styles.css").unwrap();
        // Hash is computed over the MINIFIED bytes.
        let expected = fingerprint(b"a{color:red}");
        assert_eq!(hashed, format!("/styles.{expected}.css"));

        let asset = m.hashed(hashed).unwrap();
        assert_eq!(asset.bytes, b"a{color:red}");
        assert_eq!(asset.content_type, "text/css; charset=utf-8");
    }

    #[test]
    fn plain_path_is_not_in_hashed_lookup() {
        // The plain path must MISS the hashed lookup so serving falls back to the
        // on-disk file (backward compatible).
        let dir = scratch_base().join("assets_plain");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("app.js"), b"let x = 1; // note\n");
        let m = Manifest::build(&dir, &[]);
        assert!(m.hashed("/app.js").is_none());
        let hashed = m.hashed_path_for("/app.js").unwrap();
        assert!(m.hashed(hashed).is_some());
    }

    #[test]
    fn nested_assets_keep_their_path() {
        let dir = scratch_base().join("assets_nested");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("img/logo.svg"), b"<svg></svg>");
        let m = Manifest::build(&dir, &[]);
        let hashed = m.hashed_path_for("/img/logo.svg").unwrap();
        assert!(hashed.starts_with("/img/logo."));
        assert!(hashed.ends_with(".svg"));
        // Images are passed through byte-for-byte (no minification).
        assert_eq!(m.hashed(hashed).unwrap().bytes, b"<svg></svg>");
    }

    #[test]
    fn html_files_are_not_fingerprinted() {
        let dir = scratch_base().join("assets_skip_html");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("index.html"), b"<h1>hi</h1>");
        write(&dir.join("styles.css"), b"a{color:red}");
        let m = Manifest::build(&dir, &[]);
        assert_eq!(m.len(), 1);
        assert!(m.hashed_path_for("/index.html").is_none());
    }

    #[test]
    fn extra_in_memory_asset_is_registered() {
        let dir = scratch_base().join("assets_extra");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        let m = Manifest::build(
            &dir,
            &[(
                "/utilities.css".to_string(),
                b".flex{display:flex}".to_vec(),
            )],
        );
        let hashed = m.hashed_path_for("/utilities.css").unwrap();
        assert!(hashed.starts_with("/utilities.") && hashed.ends_with(".css"));
        assert_eq!(m.hashed(hashed).unwrap().bytes, b".flex{display:flex}");
    }

    #[test]
    fn manifest_json_maps_plain_to_hashed() {
        let dir = scratch_base().join("assets_json");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("styles.css"), b"a{color:red}");
        let m = Manifest::build(&dir, &[]);
        let json = m.to_json().to_json();
        assert!(json.contains("\"/styles.css\""));
        assert!(json.contains(".css\""));
    }

    #[test]
    fn empty_manifest_misses_everything() {
        let m = Manifest::empty();
        assert!(m.hashed("/styles.abc.css").is_none());
        assert_eq!(m.len(), 0);
    }
}