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);
}
}