mini-static 0.16.0

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

use crate::tool;

/// External tools mini-static knows how to invoke for JS bundling/minification.
///
/// `#[non_exhaustive]` so a future preset is an additive variant, not a
/// semver-breaking change for downstream `match` expressions.
///
/// # Installation
///
/// mini-static does not install or manage these binaries — only looks them up on
/// `PATH` at server startup and fails loudly if missing (see [`crate::Server::with_js_tool`]).
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsTool {
    /// <https://esbuild.github.io>, invoked via its `esbuild` CLI (npm package
    /// `esbuild`, also distributable as a standalone platform binary).
    Esbuild,
    /// Copies the entry/source file to the output file unchanged. Test-only.
    #[cfg(test)]
    TestEcho,
    /// Always fails with `ToolError::NotFound`. Test-only.
    #[cfg(test)]
    TestMissing,
}

impl JsTool {
    pub(crate) fn binary_name(&self) -> &'static str {
        match self {
            JsTool::Esbuild => "esbuild",
            #[cfg(test)]
            JsTool::TestEcho => "cp",
            #[cfg(test)]
            JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
        }
    }

    pub(crate) fn install_hint(&self) -> &'static str {
        match self {
            JsTool::Esbuild => {
                "install via `npm install -g esbuild` (or add it as a project \
                 devDependency and put its bin/ on PATH)"
            }
            #[cfg(test)]
            JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
        }
    }

    fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
        match self {
            JsTool::Esbuild => {
                let mut args = vec![OsString::from(entry)];
                if bundle {
                    args.push(OsString::from("--bundle"));
                }
                if minify {
                    args.push(OsString::from("--minify"));
                }
                let mut outfile = OsString::from("--outfile=");
                outfile.push(output);
                args.push(outfile);
                args
            }
            #[cfg(test)]
            JsTool::TestEcho => vec![entry.into(), output.into()],
            #[cfg(test)]
            JsTool::TestMissing => vec![],
        }
    }
}

/// Configuration for [`crate::Server::with_js_tool`]: independent `bundle`/`minify`
/// toggles, all four combinations valid.
///
/// Unlike CSS (which discovers and concatenates every source file with no ambiguity),
/// JS module graphs are entry-point-driven — concatenating independent files risks
/// scope collisions and undefined evaluation order. `bundle` mode therefore requires
/// an explicit entry point via [`JsOptions::bundle_entry`]; without it, `bundle: true`
/// has no effect (see [`crate::Server::with_js_tool`], which validates this at
/// configuration time).
#[derive(Debug, Clone, Default)]
pub struct JsOptions {
    bundle: bool,
    minify: bool,
    entry: Option<PathBuf>,
    bundle_output_name: Option<String>,
}

impl JsOptions {
    /// Neither bundle nor minify — JS is copied through unchanged.
    pub fn new() -> Self {
        JsOptions::default()
    }

    /// Minify JS via the configured [`JsTool`], per file, mirroring each source file's
    /// relative path into the output dir (no bundling).
    pub fn minify(mut self, minify: bool) -> Self {
        self.minify = minify;
        self
    }

    /// Enable single-entry-point bundling: `entry` is bundled (optionally minified,
    /// per [`Self::minify`]) into `<output_dir>/<output_name>` via the configured
    /// [`JsTool`]. `entry` must lie under a registered source folder — validated by
    /// [`crate::Server::with_js_tool`], not here.
    pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
        self.bundle = true;
        self.entry = Some(entry.to_path_buf());
        self.bundle_output_name = Some(output_name.into());
        self
    }

    pub(crate) fn is_bundle(&self) -> bool {
        self.bundle
    }

    pub(crate) fn is_minify(&self) -> bool {
        self.minify
    }

    pub(crate) fn entry(&self) -> Option<&Path> {
        self.entry.as_deref()
    }

    pub(crate) fn output_file_name(&self) -> Option<&str> {
        self.bundle_output_name.as_deref()
    }
}

/// Errors from JS tool orchestration.
#[derive(Debug)]
pub(crate) enum JsError {
    WriteOutput { path: PathBuf, reason: String },
    Tool(tool::ToolError),
}

impl std::fmt::Display for JsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            JsError::WriteOutput { path, reason } => {
                write!(f, "failed to write {}: {reason}", path.display())
            }
            JsError::Tool(e) => write!(f, "{e}"),
        }
    }
}

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

impl From<tool::ToolError> for JsError {
    fn from(e: tool::ToolError) -> Self {
        JsError::Tool(e)
    }
}

/// Bundle `entry` (and its resolved module graph) into `output_path`, per `options`.
///
/// # Errors
///
/// Returns `Err` without touching `output_path` on tool failure — a broken rebuild
/// leaves the previous good output in place.
pub(crate) async fn build_js_bundle(
    js_tool: JsTool,
    options: &JsOptions,
    entry: &Path,
    output_path: &Path,
) -> Result<(), JsError> {
    if !options.bundle && !options.minify {
        return copy_file(entry, output_path);
    }
    Ok(run_tool(js_tool, true, options.minify, entry, output_path).await?)
}

/// Process a single JS file (bundle disabled) into its mirrored `output` path.
///
/// A malformed source degrades to a raw copy rather than failing the pipeline — the
/// server must still serve the file and keep the browser in sync. The degradation is
/// logged, not silent.
pub(crate) async fn build_js_file(
    js_tool: JsTool,
    options: &JsOptions,
    source: &Path,
    output: &Path,
) -> Result<(), JsError> {
    if !options.minify || is_already_minified(source) {
        return copy_file(source, output);
    }

    if let Err(e) = run_tool(js_tool, false, true, source, output).await {
        eprintln!(
            "js tool: minify failed for {}, serving raw bytes: {e}",
            source.display()
        );
        return copy_file(source, output);
    }
    Ok(())
}

async fn run_tool(
    js_tool: JsTool,
    bundle: bool,
    minify: bool,
    entry: &Path,
    output: &Path,
) -> Result<(), tool::ToolError> {
    if let Some(parent) = output.parent() {
        let _ = fs::create_dir_all(parent);
    }
    let args = js_tool.args(bundle, minify, entry, output);
    tool::execute(
        js_tool.binary_name(),
        js_tool.install_hint(),
        js_tool.binary_name(),
        &args,
        output,
        tool::TOOL_TIMEOUT,
    )
    .await
}

/// True if `path`'s filename indicates it's already minified (`*.min.js`). Such files
/// should be served as-is — running a minifier on already-minified input is wasted
/// work at best and a correctness risk at worst.
fn is_already_minified(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".min.js"))
}

fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
    if let Some(parent) = output.parent() {
        fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
            path: output.to_path_buf(),
            reason: e.to_string(),
        })?;
    }
    fs::copy(source, output).map_err(|e| JsError::WriteOutput {
        path: output.to_path_buf(),
        reason: e.to_string(),
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn bundle_mode_runs_the_tool_against_the_entry() {
        let src = TempDir::new().unwrap();
        let out = TempDir::new().unwrap();
        let entry = src.path().join("main.js");
        let output = out.path().join("bundle.js");
        fs::write(&entry, "const x = 1;").unwrap();

        build_js_bundle(
            JsTool::TestEcho,
            &JsOptions::new().bundle_entry(&entry, "bundle.js"),
            &entry,
            &output,
        )
        .await
        .unwrap();

        assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
    }

    #[tokio::test]
    async fn bundle_false_minify_false_is_a_passthrough_copy() {
        let src = TempDir::new().unwrap();
        let out = TempDir::new().unwrap();
        let entry = src.path().join("main.js");
        let output = out.path().join("main.js");
        fs::write(&entry, "const x = 1;").unwrap();

        build_js_bundle(JsTool::TestMissing, &JsOptions::new(), &entry, &output)
            .await
            .unwrap();

        assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
    }

    #[tokio::test]
    async fn a_failing_bundle_tool_leaves_previous_output_untouched() {
        let src = TempDir::new().unwrap();
        let out = TempDir::new().unwrap();
        let entry = src.path().join("main.js");
        let output = out.path().join("bundle.js");
        fs::write(&entry, "const x = 1;").unwrap();
        fs::write(&output, "/* previous good build */").unwrap();

        let result = build_js_bundle(
            JsTool::TestMissing,
            &JsOptions::new()
                .bundle_entry(&entry, "bundle.js")
                .minify(true),
            &entry,
            &output,
        )
        .await;

        assert!(result.is_err());
        assert_eq!(
            fs::read_to_string(&output).unwrap(),
            "/* previous good build */"
        );
    }

    #[tokio::test]
    async fn per_file_mode_with_minify_false_copies_through_unchanged() {
        let src = TempDir::new().unwrap();
        let out = TempDir::new().unwrap();
        let source = src.path().join("app.js");
        let output = out.path().join("app.js");
        fs::write(&source, "const x = 1;").unwrap();

        build_js_file(JsTool::TestMissing, &JsOptions::new(), &source, &output)
            .await
            .unwrap();

        assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
    }

    #[tokio::test]
    async fn per_file_mode_already_minified_skips_the_tool() {
        let src = TempDir::new().unwrap();
        let out = TempDir::new().unwrap();
        let source = src.path().join("app.min.js");
        let output = out.path().join("app.min.js");
        fs::write(&source, "const x=1;").unwrap();

        build_js_file(
            JsTool::TestMissing,
            &JsOptions::new().minify(true),
            &source,
            &output,
        )
        .await
        .unwrap();

        assert_eq!(fs::read_to_string(&output).unwrap(), "const x=1;");
    }

    #[tokio::test]
    async fn per_file_mode_degrades_to_raw_copy_when_the_tool_fails() {
        let src = TempDir::new().unwrap();
        let out = TempDir::new().unwrap();
        let source = src.path().join("app.js");
        let output = out.path().join("app.js");
        fs::write(&source, "const x = 1;").unwrap();

        build_js_file(
            JsTool::TestMissing,
            &JsOptions::new().minify(true),
            &source,
            &output,
        )
        .await
        .unwrap();

        assert_eq!(
            fs::read_to_string(&output).unwrap(),
            "const x = 1;",
            "a failing tool must degrade to serving the raw source, not fail the pipeline"
        );
    }
}