nano-web 1.4.2

Static file server built with Rust with pre-compressed in-memory caching
Documentation
use crate::compression::CompressedContent;
use crate::mime_types::{get_cache_control, get_mime_config};
use crate::response_buffer::{Encoding, ResponseBuffer};
use crate::template::render_template;
use anyhow::Result;
use dashmap::DashMap;
use rayon::prelude::*;
use rustc_hash::FxBuildHasher;
use std::hash::Hasher;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use tracing::{debug, error, info};
use walkdir::WalkDir;

type RouteMap = DashMap<Arc<str>, ResponseBuffer, FxBuildHasher>;

#[derive(Debug, Clone)]
struct CachedRoute {
    path: Arc<PathBuf>,
    modified: SystemTime,
}

type CachedRoutes = DashMap<Arc<str>, CachedRoute, FxBuildHasher>;

/// Pre-baked responses per encoding. Separate maps enable &str lookup against Arc<str> keys.
struct ResponseCache {
    identity: RouteMap,
    gzip: RouteMap,
    brotli: RouteMap,
    zstd: RouteMap,
}

impl ResponseCache {
    fn new() -> Self {
        Self {
            identity: DashMap::with_hasher(FxBuildHasher),
            gzip: DashMap::with_hasher(FxBuildHasher),
            brotli: DashMap::with_hasher(FxBuildHasher),
            zstd: DashMap::with_hasher(FxBuildHasher),
        }
    }

    fn get_map(&self, encoding: Encoding) -> &RouteMap {
        match encoding {
            Encoding::Identity => &self.identity,
            Encoding::Gzip => &self.gzip,
            Encoding::Brotli => &self.brotli,
            Encoding::Zstd => &self.zstd,
        }
    }

    fn get(&self, path: &str, encoding: Encoding) -> Option<ResponseBuffer> {
        // Try requested encoding first, fallback to identity for non-compressible files
        self.get_map(encoding)
            .get(path)
            .or_else(|| self.identity.get(path))
            .map(|e| e.value().clone())
    }

    fn insert(&self, path: Arc<str>, encoding: Encoding, buf: ResponseBuffer) {
        self.get_map(encoding).insert(path, buf);
    }
}

pub struct NanoWeb {
    routes: CachedRoutes,
    responses: ResponseCache,
}

impl Default for NanoWeb {
    fn default() -> Self {
        Self::new()
    }
}

impl NanoWeb {
    pub fn new() -> Self {
        Self {
            routes: DashMap::with_hasher(FxBuildHasher),
            responses: ResponseCache::new(),
        }
    }

    pub fn route_count(&self) -> usize {
        self.routes.len()
    }

    pub fn get_response(&self, path: &str, accept_encoding: &str) -> Option<ResponseBuffer> {
        let encoding = Encoding::from_accept_encoding(accept_encoding);
        self.responses.get(path, encoding)
    }

    pub fn populate_routes(&self, public_dir: &Path, config_prefix: &str) -> Result<()> {
        debug!("Starting route population from {:?}", public_dir);

        let file_paths: Vec<_> = WalkDir::new(public_dir)
            .into_iter()
            .filter_map(|entry| {
                entry.ok().and_then(|e| {
                    if e.file_type().is_file() {
                        Some((e.path().to_path_buf(), e.metadata().ok()?))
                    } else {
                        None
                    }
                })
            })
            .collect();

        info!("Processing {} files in parallel", file_paths.len());

        let routes: Vec<_> = file_paths
            .par_iter()
            .filter_map(|(file_path, metadata)| {
                match self.create_route(file_path, metadata, public_dir, config_prefix) {
                    Ok((url_path, route)) => Some((url_path, route)),
                    Err(e) => {
                        error!("Failed to create route for {:?}: {}", file_path, e);
                        None
                    }
                }
            })
            .collect();

        for (url_path, route) in routes {
            // Handle index files - create directory alias
            if url_path.ends_with("/index.html") {
                let dir_path: Arc<str> = if url_path.as_ref() == "/index.html" {
                    Arc::from("/")
                } else {
                    let dir = url_path.trim_end_matches("/index.html");
                    Arc::from(format!("{dir}/").as_str())
                };
                self.routes.insert(dir_path.clone(), route.clone());

                for encoding in Encoding::ALL {
                    if let Some(response) = self.responses.get(url_path.as_ref(), encoding) {
                        self.responses.insert(dir_path.clone(), encoding, response);
                    }
                }
            }

            self.routes.insert(url_path, route);
        }

        info!("Routes populated: {} routes", self.routes.len());
        Ok(())
    }

    fn create_route(
        &self,
        file_path: &Path,
        metadata: &std::fs::Metadata,
        public_dir: &Path,
        config_prefix: &str,
    ) -> Result<(Arc<str>, CachedRoute)> {
        let content = std::fs::read(file_path)?;

        let modified = metadata.modified()?;
        let mime_config = get_mime_config(file_path);

        // Apply templating if needed
        let processed_content = if mime_config.is_templatable {
            match render_template(&String::from_utf8_lossy(&content), config_prefix) {
                Ok(templated) => templated.into_bytes(),
                Err(e) => {
                    error!("Template rendering failed for {:?}: {}", file_path, e);
                    content
                }
            }
        } else {
            content
        };

        let compressed = CompressedContent::new(processed_content, mime_config.is_compressible)?;
        let etag = Self::generate_etag(&compressed.plain);
        let last_modified = Self::format_http_date(modified);

        let ct: Arc<str> = Arc::from(mime_config.mime_type.as_str());
        let etag: Arc<str> = Arc::from(etag.as_str());
        let lm: Arc<str> = Arc::from(last_modified.as_str());
        let cc: Arc<str> = Arc::from(get_cache_control(&mime_config.mime_type));
        let vary = mime_config.is_compressible;

        let route = CachedRoute {
            path: Arc::new(file_path.to_path_buf()),
            modified,
        };

        let url_path = Self::file_path_to_url(file_path, public_dir)?;

        self.responses.insert(
            url_path.clone(),
            Encoding::Identity,
            ResponseBuffer::new(
                compressed.plain.clone(),
                ct.clone(),
                None,
                etag.clone(),
                lm.clone(),
                cc.clone(),
                vary,
            ),
        );

        if let Some(data) = &compressed.gzip {
            self.responses.insert(
                url_path.clone(),
                Encoding::Gzip,
                ResponseBuffer::new(
                    data.clone(),
                    ct.clone(),
                    Some("gzip"),
                    etag.clone(),
                    lm.clone(),
                    cc.clone(),
                    vary,
                ),
            );
        }

        if let Some(data) = &compressed.brotli {
            self.responses.insert(
                url_path.clone(),
                Encoding::Brotli,
                ResponseBuffer::new(
                    data.clone(),
                    ct.clone(),
                    Some("br"),
                    etag.clone(),
                    lm.clone(),
                    cc.clone(),
                    vary,
                ),
            );
        }

        if let Some(data) = &compressed.zstd {
            self.responses.insert(
                url_path.clone(),
                Encoding::Zstd,
                ResponseBuffer::new(
                    data.clone(),
                    ct.clone(),
                    Some("zstd"),
                    etag.clone(),
                    lm.clone(),
                    cc.clone(),
                    vary,
                ),
            );
        }

        Ok((url_path, route))
    }

    fn file_path_to_url(file_path: &Path, public_dir: &Path) -> Result<Arc<str>> {
        let relative = file_path.strip_prefix(public_dir)?;
        let url_path = format!("/{}", relative.to_string_lossy().replace('\\', "/"));
        Ok(Arc::from(url_path.as_str()))
    }

    fn generate_etag(content: &[u8]) -> String {
        let mut hasher = rustc_hash::FxHasher::default();
        hasher.write(content);
        format!("\"{:x}\"", hasher.finish())
    }

    fn format_http_date(time: SystemTime) -> String {
        httpdate::fmt_http_date(time)
    }

    /// Dev mode: refresh route if file changed on disk
    pub fn refresh_if_modified(
        &self,
        url_path: &str,
        public_dir: &Path,
        config_prefix: &str,
    ) -> Result<bool> {
        let Some(route_ref) = self.routes.get(url_path) else {
            return Ok(false);
        };
        let route = route_ref.value().clone();
        drop(route_ref);

        let metadata = std::fs::metadata(&*route.path)?;
        if metadata.modified()? > route.modified {
            debug!("File modified, refreshing: {:?}", route.path);
            let (new_url, new_route) =
                self.create_route(&route.path, &metadata, public_dir, config_prefix)?;
            self.routes.insert(new_url, new_route);
            return Ok(true);
        }
        Ok(false)
    }
}