# 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.
```toml
[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 dependencies** — `cargo 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
```rust,no_run
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
```rust,no_run
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:
```sh
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.