mini-build 0.1.0

Builds the directory a static server serves: CSS/JS bundling and minification via external tools, plus asset mirroring.
Documentation

mini-build

Builds the directory a static server serves: CSS bundling and minification, JS bundling and minification, and byte-identical asset mirroring — all delegated to external CLI tools. No HTTP, no serving, no runtime.

Status: pre-release, extracted from mini-static 0.28.x.

[dependencies]
mini-build = "0.1"

Scope

Produce the directory a static server serves. That sentence decides what belongs here. An asset folder mirrored byte-for-byte is in scope even though nothing transforms it, because the output directory is not complete without it. Serving that directory, watching it for a browser's benefit, and choosing its cache headers are all somebody else's job.

This crate has no runtime dependenciescargo tree shows the crate alone. It is synchronous: a build spawns subprocesses and waits on them, which the standard library already expresses, and an async runtime would be weight with nothing to buy.

Why it is separate

This pipeline lived inside mini-static until 0.29.0. Splitting it apart gained three things beyond a smaller server:

  • The server can be read-only. Previously one process both wrote into and served from the same directory — the output dir defaulted to the served root.
  • Live-reload got simpler. The output dir was deliberately never watched, because watching it would feed each pipeline its own writes back into its trigger. With the builder in a separate process that loop is impossible, so the server can just watch what it serves.
  • Build performance became measurable. A static server's benchmarks are microsecond request latencies; a build is milliseconds over a file tree. Neither is visible in the other's suite. See benches/build.rs.

Usage

use mini_build::{Builder, CssOptions, CssTool, JsOptions, JsTool};
use std::path::Path;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Builder::new(Path::new("./public"))?
        .source_folder(Path::new("./src/styles"))?
        .asset_folder(Path::new("./src/assets"))?
        .css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true))
        .js_tool(JsTool::Esbuild, JsOptions::new().minify(true))?
        .build()?;
    Ok(())
}

Every path is canonicalized and checked as it is registered, so a misconfiguration is reported while the builder is being assembled rather than partway through a build that has already written files. Overlap is rejected in both directions: a source folder inside the output dir would have the build read its own writes, and an output dir inside a source folder would have it write into its own inputs.

Tool availability is checked before any file is written — a build that will fail for want of esbuild fails before it half-populates the output dir. A tool configured for neither bundling nor minifying spawns no process, so its binary is not required.

Watch mode

use mini_build::Builder;
use std::path::Path;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let watching = Builder::new(Path::new("./public"))?
        .source_folder(Path::new("./src/styles"))?
        .watch(|e| eprintln!("rebuild failed: {e}"))?;
    // Outputs stay current until `watching` is dropped.
    watching.stop();
    Ok(())
}

watch builds once and then keeps the output dir in sync until the handle drops. The initial build's failures come back through the Result; later rebuild failures go to the callback, because by then there is no call left to return from. The callback is required rather than optional: a rebuild that fails silently leaves the output dir holding stale bytes while you believe it is current.

Dropping the handle stops the watcher and joins its thread. A watcher that outlives its handle is a leak that shows up as builds running after you thought they had stopped.

External tools

This crate does not install or manage the tools it invokes:

npm install -g lightningcss-cli esbuild

bundle and minify are independent toggles per language, and all four combinations are valid: passthrough copy, per-file minify, bundle only, or bundle and minify. *.min.css and *.min.js bypass minification — running a minifier over already-minified input is wasted work at best and a correctness risk at worst.

Per-file mode degrades to a raw copy when a tool rejects a file, rather than failing the build: one malformed source should not take a whole site offline. Bundle failures do propagate, since a partial bundle is worse than an unchanged one. Every invocation is bounded by a 30-second timeout, after which the child is killed rather than abandoned.

Performance

benches/build.rs measures whole-tree wall clock, and process startup is what a build spends its time on — about 19 ms per spawn, of which only ~5 ms is the tool itself and the rest is std::process::Command on this platform.

Inputs are therefore batched one invocation per source directory rather than one per file, in bundle mode and per-file mode alike. Fifty scattered files dropped from 1155 ms to 195 ms bundling, and from 1128 ms to 192 ms minifying per file — and, more to the point, stopped scaling with file count at all: ten files and fifty now cost the same, because both sit in the same eight directories. The same fifty files in a single directory cost 48 ms, which is the shape a hand-authored site actually has.

Per-file mode still promises that a malformed source degrades to a raw copy while its neighbours are minified normally. A failed batch therefore rebuilds its group one file at a time rather than giving up on the directory — the slow path costs a spawn per file, but only in the case that was already going wrong.

Batching per directory rather than all at once is a correctness requirement, not caution: lightningcss --output-dir distinguishes its results by basename, so one/shared.css and two/shared.css batched together would silently leave one of them out of the build.

Still on the table, each to be argued against the recorded baseline: staging inputs under unique names so a whole tree is one invocation rather than one per directory, a persistent esbuild process, and skipping unchanged files.