mini-static 0.15.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::path::{Path, PathBuf};

use crate::css_bundler;
use crate::minify;
use crate::reload::ChangeType;
use crate::watcher::{Broadcaster, ChangeEvent};

/// Routes source-folder file changes to the pipeline that turns them into served output.
///
/// The watcher layer runs over *source folders only*. The designated output dir is never
/// watched and never a trigger: every pipeline below writes its own output, and a pipeline
/// that listened to its own output would re-trigger itself forever (the feedback-loop bug
/// this design fixes). Instead, after a build finishes, the pipeline broadcasts the reload
/// event for the output it wrote, so the browser still hot-swaps/reloads.
///
/// Dispatch is by the changed file's [`ChangeType`]:
/// - `Css` under a source folder or import root → rebuild the single CSS bundle.
/// - `Script` under a source folder → minify that one file into its mirrored output.
/// - anything else → re-broadcast the change so the client reloads and picks up whatever
///   external builder (e.g. a markdown renderer) wrote to the output.
pub(crate) struct SourcePipeline {
    source_folders: Vec<PathBuf>,
    bundle_roots: Vec<PathBuf>,
    output_dir: PathBuf,
    css_bundle_output: Option<PathBuf>,
    prune_output: bool,
    broadcaster: Broadcaster,
}

impl SourcePipeline {
    pub(crate) fn new(
        source_folders: Vec<PathBuf>,
        bundle_roots: Vec<PathBuf>,
        output_dir: PathBuf,
        css_bundle_output: Option<PathBuf>,
        prune_output: bool,
        broadcaster: Broadcaster,
    ) -> Self {
        SourcePipeline {
            source_folders,
            bundle_roots,
            output_dir,
            css_bundle_output,
            prune_output,
            broadcaster,
        }
    }

    /// Rebuild every enabled output once, then prune stale output if configured.
    ///
    /// Runs at server startup. Never during live-reload — see [`Self::prune_stale_output`].
    pub(crate) async fn full_build(&self) -> Result<(), SourceError> {
        let css_written = self.build_css().await?;

        self.build_all_js().await?;

        if self.prune_output {
            self.prune_stale_output(css_written.as_deref()).await?;
        }

        Ok(())
    }

    /// Handle a single change event for `path` (which must be under a watched source
    /// folder or import root), rebuilding output as needed and broadcasting the reload
    /// event for whatever was written.
    pub(crate) async fn process_change(
        &self,
        path: &Path,
        change_type: &ChangeType,
    ) -> Result<(), SourceError> {
        if self.css_bundle_output.is_some()
            && change_type == &ChangeType::Css
            && self.is_css_input(path)
        {
            if let Some(output) = self.build_css().await? {
                self.broadcast_change(&output);
            }
            return Ok(());
        }

        if change_type == &ChangeType::Script {
            if let Some(output) = self.build_js_file(path).await? {
                self.broadcast_change(&output);
            }
            return Ok(());
        }

        // No pipeline owns this file kind; hand the change straight to the browser so it
        // reloads and re-fetches whatever external builder produced the output.
        self.broadcaster.broadcast(ChangeEvent {
            path: path.to_path_buf(),
            change_type: change_type.clone(),
        });
        Ok(())
    }

    /// Rebuild the single CSS bundle from every source folder. Returns the output path it
    /// wrote, or `None` when no CSS sources exist (nothing to produce).
    async fn build_css(&self) -> Result<Option<PathBuf>, SourceError> {
        let Some(output) = &self.css_bundle_output else {
            return Ok(None);
        };

        if !self.has_css_sources() {
            return Ok(None);
        }

        let mut allowed_roots = self.source_folders.clone();
        allowed_roots.extend(self.bundle_roots.iter().cloned());

        css_bundler::bundle_css_sources(&allowed_roots, &self.source_folders, output)
            .await
            .map_err(SourceError::Css)?;

        Ok(Some(output.clone()))
    }

    /// Minify every `.js`/`.mjs` under every source folder into its mirrored output path.
    async fn build_all_js(&self) -> Result<(), SourceError> {
        for folder in &self.source_folders {
            let files = list_files(folder).await.map_err(SourceError::Io)?;
            for file in files {
                if is_script(&file) {
                    let output = self.mirror_output(folder, &file)?;
                    self.write_minified_js(&file, &output).await?;
                }
            }
        }
        Ok(())
    }

    /// Minify a single changed `.js`/`.mjs` into its mirrored output path.
    async fn build_js_file(&self, source: &Path) -> Result<Option<PathBuf>, SourceError> {
        let Some(folder) = self.containing_source_folder(source) else {
            return Ok(None);
        };

        if !is_script(source) {
            return Ok(None);
        }

        let output = self.mirror_output(folder, source)?;
        self.write_minified_js(source, &output).await?;
        Ok(Some(output))
    }

    /// The output path for `source`, mirroring its path relative to `folder` under the
    /// output dir.
    fn mirror_output(&self, folder: &Path, source: &Path) -> Result<PathBuf, SourceError> {
        let relative = source
            .strip_prefix(folder)
            .map_err(|_| SourceError::NotUnderSource(source.to_path_buf()))?;
        Ok(self.output_dir.join(relative))
    }

    /// Minify `source` with the JS minifier and write the result to `output`.
    ///
    /// A malformed source degrades to its raw bytes rather than failing the pipeline: the
    /// server must still serve the file and keep the browser in sync (mirrors the
    /// on-the-fly minify path's "serve unminified on failure" contract). The degradation
    /// is logged, not silent.
    async fn write_minified_js(&self, source: &Path, output: &Path) -> Result<(), SourceError> {
        let bytes = tokio::fs::read(source).await.map_err(SourceError::Io)?;
        let output_bytes = match minify::minify(&bytes, ChangeType::Script) {
            Ok(minified) => minified,
            Err(e) => {
                eprintln!(
                    "source pipeline: minify failed for {}, serving raw bytes: {e}",
                    source.display()
                );
                bytes.into()
            }
        };

        if let Some(parent) = output.parent() {
            tokio::fs::create_dir_all(parent)
                .await
                .map_err(SourceError::Io)?;
        }
        tokio::fs::write(output, &output_bytes)
            .await
            .map_err(SourceError::Io)?;
        Ok(())
    }

    /// Broadcast a reload event for `output`, deriving its change type from the output's
    /// own extension so the browser hot-swaps/reloads exactly as it would for that file.
    fn broadcast_change(&self, output: &Path) {
        self.broadcaster.broadcast(ChangeEvent {
            path: output.to_path_buf(),
            change_type: ChangeType::from_path(output),
        });
    }

    /// True when `path` lives under a source folder or a CSS `@import` root — i.e. a CSS
    /// change there must trigger a rebundle.
    fn is_css_input(&self, path: &Path) -> bool {
        self.source_folders
            .iter()
            .chain(self.bundle_roots.iter())
            .any(|root| path.starts_with(root))
    }

    /// The source folder containing `path`, if any.
    fn containing_source_folder(&self, path: &Path) -> Option<&PathBuf> {
        self.source_folders
            .iter()
            .find(|folder| path.starts_with(folder))
    }

    /// True when at least one `.css` file exists under the source folders.
    fn has_css_sources(&self) -> bool {
        for folder in &self.source_folders {
            if walk_dir(folder).any(|path| is_css(&path)) {
                return true;
            }
        }
        false
    }

    /// Remove stale output at build time, never during live-reload.
    ///
    /// The only output this server can own *by identity* is the CSS bundle file — a single
    /// exact path that no hand-written file shares. If bundling is enabled but no CSS
    /// sources remain, the leftover bundle is removed. Per-file JS outputs are deliberately
    /// NOT auto-pruned: their mirrored paths can coincide with hand-written files, and
    /// deleting files the server doesn't own is a surprise (A1) the caller can't opt into
    /// by accident.
    async fn prune_stale_output(&self, css_written: Option<&Path>) -> Result<(), SourceError> {
        let Some(bundle) = &self.css_bundle_output else {
            return Ok(());
        };

        let wrote_bundle = css_written.is_some_and(|written| written == bundle);
        if wrote_bundle {
            return Ok(());
        }

        if tokio::fs::metadata(bundle).await.is_err() {
            return Ok(());
        }

        tokio::fs::remove_file(bundle)
            .await
            .map_err(SourceError::Io)?;
        eprintln!("pruned stale css bundle output: {}", bundle.display());
        Ok(())
    }
}

/// Why [`SourcePipeline`] could not produce output for a change or build.
#[derive(Debug)]
pub(crate) enum SourceError {
    /// The CSS bundle step failed.
    Css(css_bundler::CssBundlerError),
    /// A filesystem operation failed.
    Io(std::io::Error),
    /// A changed path was not under the source folder claimed to contain it.
    NotUnderSource(PathBuf),
}

impl std::fmt::Display for SourceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SourceError::Css(e) => write!(f, "css bundle failed: {e}"),
            SourceError::Io(e) => write!(f, "io error: {e}"),
            SourceError::NotUnderSource(p) => {
                write!(f, "path not under any source folder: {}", p.display())
            }
        }
    }
}

impl std::error::Error for SourceError {}

/// True if `path` names a CSS file (by extension).
fn is_css(path: &Path) -> bool {
    path.extension().and_then(|e| e.to_str()) == Some("css")
}

/// True if `path` names a script the minifier accepts (`js`/`mjs`).
fn is_script(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|e| e.to_str()),
        Some("js" | "mjs")
    )
}

/// Recursively list every file under `dir`.
///
/// Bounded by the filesystem: `walk_dir` pushes directories onto a stack and terminates
/// when none remain — a directory tree is finite, so this loop always ends.
async fn list_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    let mut dirs = vec![dir.to_path_buf()];

    while let Some(current) = dirs.pop() {
        let mut entries = tokio::fs::read_dir(&current).await?;
        while let Some(entry) = entries.next_entry().await? {
            let path = entry.path();
            if entry.file_type().await?.is_dir() {
                dirs.push(path);
            } else {
                files.push(path);
            }
        }
    }

    Ok(files)
}

/// Synchronously walk `dir` and yield every file path. Used by the cheap existence check
/// in [`SourcePipeline::has_css_sources`] (runs at build boundaries, not per request).
fn walk_dir(dir: &Path) -> impl Iterator<Item = PathBuf> {
    let mut dirs = vec![dir.to_path_buf()];
    std::iter::from_fn(move || {
        while let Some(current) = dirs.pop() {
            let Ok(entries) = std::fs::read_dir(&current) else {
                continue;
            };
            for entry in entries.flatten() {
                let path = entry.path();
                if entry.file_type().is_ok_and(|t| t.is_dir()) {
                    dirs.push(path);
                } else {
                    return Some(path);
                }
            }
        }
        None
    })
}