# PLAN-extract-build.md — Extract the CSS/JS build pipeline into `mini-build`
## Where We Are
`mini-static` is two things in one crate. The serving core — request resolution,
streaming, conditional requests, ranges, connection lifecycle — is 2,891 lines across
`server.rs`, `resolve.rs`, `handler.rs`, `error.rs`, `reload.rs`, `spa.rs`. A CSS/JS
build pipeline is 1,358 lines (32% of the crate) across `css.rs`, `js.rs`, `source.rs`,
`tool.rs`, `watcher.rs`: it discovers files under registered source folders, shells out
to `lightningcss`/`esbuild`, and writes results into an output dir that **defaults to the
served root**.
Concretely, today:
- The same process both writes into and serves from the served root. `mini-static`
cannot claim to be read-only.
- Because it writes what it serves, the output dir is deliberately never watched — doing
so would feed each pipeline its own writes back into its own trigger. Live-reload
therefore watches *source* folders and infers that outputs followed. (As of 0.28.7 it
also watches the served root when no pipeline is configured — commit 0 below, already
shipped. The two-branch shape that leaves is exactly what B2 collapses.)
- The pipeline is the crate's only reason to enable `tokio`'s `process` feature.
- Per-file mode spawns one subprocess per file, sequentially: `css.rs:208` loops and
`await`s `run_tool` per file (`css.rs:219`). At roughly 1–5 ms per process spawn, 100
files is a half-second of serialized fork/exec. Nothing measures this — `benches/`
covers path resolution and the HTTP request path, both in microseconds.
- `css.rs`, `js.rs`, and `source.rs` sit at 73%, 77%, and 80% line coverage against
98–100% for `resolve.rs` and `error.rs`, and `tests/real_tools.rs` is `#[ignore]`d
because it needs `lightningcss`/`esbuild` on `PATH`.
Public pipeline surface to be removed: `with_source_folder`, `with_asset_folder`,
`with_output_dir`, `with_bundle_root`, `with_css_tool`, `with_js_tool`,
`with_prune_output`, `Server::build()`, and the exported `CssOptions`, `CssTool`,
`JsOptions`, `JsTool`.
Consumers: `mini-unified` and `mini-serve` use **none** of it (verified by grep over
their sources). The only in-repo user is `examples/mini-static.rs`. Outside the repo, one
website.
## Where We Need To Be
- A new standalone crate, `mini-build`, owns CSS/JS/asset processing. It has no HTTP
types and does not depend on `mini-static`; `mini-static` does not depend on it. They
compose through a directory: the builder writes one, the server serves one.
- `mini-static` never writes to disk. Its file access is read-only, and that is stated
as a property rather than an accident.
- `mini-static`'s live-reload watches **the directory it serves**. The
"never watch the output dir" constraint disappears, because the server no longer
produces that output — the feedback loop it guarded against is now impossible.
- `mini-static` no longer enables `tokio`'s `process` feature and no longer shells out
to anything.
- `mini-build` has its own benchmark suite measuring whole-tree build wall-clock, so the
optimizations that are currently invisible — parallel invocation, batching several
inputs into one tool call, incremental skip-unchanged, `esbuild`-style persistent
process — become measurable. This plan does **not** implement them; it creates the
place where they can be.
- `mini-build` CI installs `lightningcss` and `esbuild`, so its tool tests run by default
instead of being `#[ignore]`d.
- The removal is advertised loudly: `mini-static` 0.29.0 (0.x minor = breaking), with a
migration section at the top of the README — which `lib.rs` includes, so it reaches
docs.rs — showing before/after for every removed method.
## Commits
Commit 0 is independent of the extraction entirely and should land first. Phase A lands
in the new `mini-build` repo and breaks nothing. Phase B is the breaking change in
`mini-static`. Phase C updates consumers. Within a phase, each commit is independently
revertible; Phase B must not land before Phase A publishes.
### 0. Fix live-reload's watch targets — **DONE** (0.28.7, `5e95d18`)
- Does: `watch_targets()` (`src/server.rs:931`) returns `source_folders + bundle_roots +
asset_folders` and **never the served root**. So `Server::new(root).with_live_reload()`
with no pipeline configured watches nothing: the SSE stream opens, stays open, and
never emits. Editing a served file does nothing. Meanwhile `with_live_reload`'s own doc
comment states the watcher runs "over the server's root" and broadcasts "whenever a
served file is added, modified, or removed." The documented headline behavior of the
feature does not happen for a plain static site.
Add the served root to `watch_targets()`. This is a bug fix, not a refactor, and it is
**not** gated on the extraction — it is only listed here because the grill found it
while checking B2's assumptions.
**Provenance, established after the grill:** not a regression from the recent hardening
work. `f756e5e` originally called `start_watching(root_canon)` directly; `7a4362b`
("split source + output bundle/minification", shipped as **0.15.0**) replaced that with
the `watch_targets()` input list, and a configuration with no pipeline has watched
nothing ever since. The doc comment was never updated, so it kept describing the
pre-0.15.0 behavior.
- Verifies: a new test enables `with_live_reload()` with **no** source folders, connects
to the SSE stream, writes a file into the served root, and asserts a reload frame
arrives — the case no existing test covers (`tests/live_reload.rs` asserts broadcast
only at lines 160 and 209, both of which configure `with_source_folder`).
- Touches: `src/server.rs` (~10 LOC), `tests/live_reload.rs` (~50 LOC).
- Reverts cleanly: yes — entirely self-contained.
- **Correction, found while implementing:** this commit's original note said to "exclude
the output dir from the served-root walk" while the pipeline still writes there. That
does not work — `output_dir` *defaults to* the served root, so excluding it excludes
everything and fixes nothing. What shipped instead branches on `has_pipeline()`: with no
pipeline, watch the served root; with one, keep watching pipeline inputs, since
`SourcePipeline` already broadcasts its own outputs once written and its echo
suppression (`src/source.rs:289`) is keyed to input folders. So `watch_targets()` is now
a two-branch function, and B2's job is deleting the second branch rather than deleting
an exclusion.
### A1. Scaffold `mini-build` and move the pipeline modules verbatim — **DONE** (`7be1c0b`, `backstack/build`)
- Does: New crate. Move `css.rs`, `js.rs`, `source.rs`, `tool.rs` and their unit tests
across. "Verbatim" is not quite true and the difference is the whole risk of this
commit, so it is enumerated: `source.rs` imports `crate::reload::ChangeType` and
`crate::watcher::{Broadcaster, ChangeEvent}` (`src/source.rs:5-6`), and **both of those
modules stay in `mini-static`** — `reload::ChangeType` is the SSE payload type, and the
watcher still drives live-reload. So A1 must give `mini-build` its own change-type enum
and its own change channel rather than importing the server's. `css.rs`/`js.rs` depend
only on `crate::tool`, which moves with them; those two are genuinely verbatim.
No API design in this commit — relocation only, so any later diff is real signal. It
also stays **async and `tokio`-based**, even though A3 converts it to sync: moving and
rewriting in one diff would put both changes beyond the reach of the equivalence harness
A2 builds. Inheriting `tokio` here is deliberate and temporary.
- Verifies: every moved unit test passes in its new home, at the **same count per
module** as before the move — 7 css, 6 js, 9 source, 7 tool, matched exactly against
`mini-static`, so a silently dropped test file shows up as a number.
- Touches: new repo, ~1,200 LOC moved plus ~40 LOC replacing the two borrowed types.
- Reverts cleanly: yes — deleting a new repo affects nothing.
- **Correction: `tests/real_tools.rs` does not move.** The plan assumed it was a pipeline
test; every case in it builds a `Server` and calls `Server::build()`, so it is a
server-integration test of the pipeline, not a test of the pipeline. Rewriting it
against `SourcePipeline` is API work belonging to A2, and A5's tools-installed CI job
needs `mini-build`-native equivalents written from scratch. The file stays in
`mini-static` until B1 removes what it tests.
- **Note: four items have no caller yet** — `CssOptions::is_minify`, `JsOptions::is_minify`,
`tool::locate_on_path`, and `is_executable_file` were reached through
`Server::required_tool_binaries` and `Server::run_on`'s pre-bind check, both of which are
server glue that did not move. They carry `#[allow(dead_code)]` with a comment naming A2
as their incoming caller, rather than being made `pub` — which would be API design this
commit is explicitly not doing. A2 removes the annotations.
- **Note: the change channel came across as `src/change.rs`** — `ChangeType`, `ChangeEvent`,
and `Broadcaster` reproduced rather than shared, per the plan. `start_watching` stayed
behind; A4 needs its own. Four tests cover the new module, two ported from
`tests/unit/watcher.rs` and two new ones pinning extension classification, which decides
which pipeline owns a file and so is behavior rather than convenience.
### A2. `mini_build::Builder` public API
- Does: State the crate's purpose in its docs first — **"produce the directory a static
server serves"** — because that sentence is what decides scope questions like whether a
byte-identical asset copy belongs here (it does; see Open Questions). Then replace the
`Server` builder methods with a standalone `Builder`: `source_folder`, `asset_folder`,
`output_dir`, `css_tool`, `js_tool`, `prune_output`, and `build()`. Same semantics, same
overlap rejection, same `.min.*` bypass, same fail-loud tool-missing check — the
validation logic moves with the code rather than being rewritten.
- Verifies: **byte-identical output**, compared *in the same process run* against
`mini-static` 0.28.3 taken as a dev-dependency — **not** against committed golden files.
The distinction is load-bearing: the pipeline shells out to `lightningcss`/`esbuild`, so
its output depends on the installed tool version. Goldens generated once would fail
spuriously the day CI picks up a newer tool, and the natural response — regenerating
them — would silently destroy the only evidence the move preserved behavior. Running
both implementations against the same binaries in the same run compares the two things
that actually need comparing. This is the check that makes a 1,200-line move safe;
without it, "it compiles and the unit tests pass" is not evidence the pipeline still
does the same thing.
- Touches: `mini-build` (~200 LOC of API surface), tests (~150 LOC).
- Reverts cleanly: yes.
### A3. Convert to synchronous execution; drop `tokio` — **DONE** (`d4ea087`)
- Does: Replace `tokio::process` with `std::process` and `async fn` with plain functions.
Reimplement the 30 s `TOOL_TIMEOUT` and `kill_on_drop`, the only capabilities
`tokio::process` was providing. Remove `tokio` from `Cargo.toml`.
- **Deviation: the per-file loop stayed sequential.** The plan said to parallelize it here
with threads or `rayon`. That conflates a substrate change with an optimization, and
A5's benchmarks — the only thing that could show whether parallelism helps and by how
much — do not exist yet. Converting `await`-in-a-loop to a sequential sync loop is the
minimal faithful translation; parallelism is now a commit after A5, measured. This also
keeps A3's diff within reach of A2's harness, which compares bytes and would not notice
a race that only manifests under load.
Deliberately *not* in scope: batching multiple inputs into one tool invocation, a
persistent `esbuild` process, and incremental skip-unchanged. Those are the optimizations
the extraction exists to enable, and each deserves its own commit measured against A5's
benchmarks. This commit only changes the substrate.
- Verifies: A2's byte-identical comparison still passes — it is the whole reason this is a
separate commit, since it is what catches a mistranslation of the tool-invocation or
timeout logic. Plus a test asserting a tool that hangs is killed at the timeout, which is
the behavior most likely to be lost in the rewrite; and `cargo tree` showing no `tokio`.
- Touches: `mini-build` (~200 LOC changed), `Cargo.toml`.
- Reverts cleanly: yes — the async version is one revert away, and A2's harness proves
either direction is correct.
- Note, carried forward to whenever parallelism does land: it changes *timing*, not
output. If the byte-identical test ever passes serially and fails in parallel, that is a
real bug — two files racing on one output path — not a flaky test. Do not paper over it
with a retry.
- **What the harness earned:** the sync implementation produced output byte-identical to
`mini-static`'s async pipeline on the first run. Two implementations that no longer share
an execution model, agreeing on every byte, is a claim no amount of reading the diff
could have supported.
- **What the harness could not see:** it compares output bytes, so a rewrite that dropped
the tool timeout entirely would still pass for every tool that terminates — and hang
forever on the one that does not. Two tests cover that gap directly: a hanging tool is
killed at the timeout, and a timed-out child leaves no process behind. The second exists
because `std::process::Child` does not kill on drop the way tokio's did. Removing the
`kill()` call fails both, and makes the suite take 30 s instead of 2.6 s as the abandoned
child holds its pipes open — the leak is visible in the clock as well as the assertion.
### A4. Watch mode — **DONE** (`21f051c`)
- Does: Port the mtime-polling watcher so `Builder::watch()` rebuilds affected outputs on
source change — the same trigger logic live-reload drives today, minus the broadcaster.
This is the development workflow, and it is what makes the decoupled design work:
`mini-build` watches sources and writes the output dir; `mini-static` watches the dir it
serves and pushes the reload. Note what that buys — the "reload only after the output
exists" ordering the coupled design maintains deliberately becomes structural, since
`mini-static` cannot observe a change before the file is written.
- Verifies: a test edits a source file under `watch()` and asserts the corresponding
output is rewritten within a bounded interval; a bundle-mode edit rebuilds the bundle,
a per-file-mode edit rebuilds just that file.
- Touches: `mini-build` (~150 LOC incl. the watcher).
- Reverts cleanly: yes — `build()` stands alone without it.
- **Two decisions the async original never had to make.** Stopping: `mini-static`'s
watcher loops forever because its task dies with the process. A library handing back a
handle cannot do that, so `WatchHandle` signals an atomic and joins on drop — a
watcher outliving its handle is a leak that surfaces as builds running after the caller
believes it stopped. Failure reporting: `process_change` can fail, and the original
`eprintln!`'d. A library writing to its host's stderr uninvited is the same complaint
this plan levelled at `mini-static`, and an error queue nobody drains is unbounded, so
`watch` takes an `on_error` callback — a caller must name what happens to a failed
rebuild rather than inherit silence.
- **A real race, found by a failing test.** The baseline mtime snapshot was originally
taken *inside* the spawned thread, so a caller who edited a file immediately after
`watch()` returned could have that edit absorbed into the baseline and silently never
built. Invisible in casual use, reliable under a script. The snapshot now happens before
the thread starts, making "once `watch` returns, later writes are seen" a guarantee
rather than a race won by luck.
- **A test premise that was wrong, not the code.** The `on_error` test first used per-file
mode, which *deliberately* degrades a tool failure to a raw copy and returns `Ok`
(`css::build_css_file`). Written that way the test passed for the wrong reason — by
never failing at all. It now uses bundle mode, where failures genuinely propagate, and
says so, because the asymmetry is easy to trip over twice.
### A5. Build benchmarks, CI, publish 0.1.0 — **BUILT, NOT PUBLISHED** (`debaae9`)
- Does: `benches/build.rs` measuring whole-tree wall-clock for a fixture of N CSS and N
JS files; CI job installing `lightningcss`/`esbuild`; README; publish. **Confirm the
crate name is free on crates.io before A1**, not here — discovering it at publish time
means renaming a finished crate. Note publishes are permanent: a bad one can be yanked
but not replaced, so this is the one commit in the plan with no true revert.
- Verifies: `cargo bench` reports a baseline (record the numbers in the bench's module
docs, as `mini-static` does); CI runs the tool tests un-ignored.
- Touches: `mini-build` (~150 LOC), CI config, README.
- Reverts cleanly: everything except the publish, which is why the publish is held.
- **Publishing is deliberately not done.** It is the one irreversible step in the plan —
crates.io versions can be yanked but never replaced — and it is outward-facing. Awaiting
an explicit go-ahead rather than inferring one from "proceed". The name is confirmed
free (checked against a control query, since a rate-limited response is
indistinguishable from "available" if you do not look).
- **The benchmark found the real number, and it is far worse than this plan assumed.**
The plan estimated 1–5 ms per process spawn. Measured: **~23 ms**, linear in file count
(23.9 ms/file at 10 files, 23.1 ms/file at 50). Fifty CSS files take 1.155 s to bundle;
mirroring fifty files takes 13.8 ms. Roughly **90% of a real build is process startup**,
which no amount of tuning discovery or copying can touch — only spawning fewer processes
does. Baseline table recorded in `benches/build.rs`.
| case | 10 files | 100 files | 400 files |
|---|---|---|---|
| `per_file` (passthrough) | 3.30 ms | 26.3 ms | 105.5 ms |
| `asset_mirror` | 2.11 ms | 13.8 ms | 54.0 ms |
| `css_bundle` (spawns per file) | 238.8 ms | 1.155 s at 50 | not run |
- **Correction to A1's correction:** `real_tools.rs` was rewritten against `Builder`
rather than ported, as A1 predicted. It is **not** `#[ignore]`d, unlike the original:
the tests detect the binaries and skip loudly when absent, so a developer without them
still gets a green suite while CI — which installs both — genuinely exercises them. An
`#[ignore]`d test does not run even on a machine that could run it, which is the worse
failure: the coverage exists and nobody benefits.
- Coverage 85.52%, above the standing 80% gate.
### B1. Remove the pipeline from `mini-static` — **DONE** (0.29.0, published)
- Does: Delete `css.rs`, `js.rs`, `source.rs`, `tool.rs`; remove the eight builder
methods, `Server::build()`, and the four exported option/tool types; remove the
startup-build and tool-presence-check blocks from `run_on`; drop `tokio`'s `process`
feature. Remove `StaticError::PipelineSetup` and `StaticError::Build`, now unreachable.
No deprecation shims: with no consumers to shim for, a stub that silently does nothing
is worse than a compile error next to a migration guide.
- Verifies: the full remaining suite passes; `cargo tree` no longer shows `tokio/process`
in the enabled feature set; a grep asserts no `Command`/`Stdio` remains in `src/`; and
— the point of the whole exercise — a grep asserts `src/` contains no filesystem write
call, making "the server never writes" checkable rather than aspirational.
- Touches: `src/` (−~1,200 LOC), `src/lib.rs`, `Cargo.toml`.
- Reverts cleanly: yes, but only meaningfully alongside B2.
- Note: removing `StaticError::{PipelineSetup, Build}` is breaking even though the enum is
`#[non_exhaustive]` — that attribute forces consumers to carry a wildcard arm, but any
code *naming* those variants stops compiling. Intended and advertised in B3.
- **Resolved: nothing else needed a bump.** Under 0.x semver each minor is its own
compatibility range, so `mini-unified` and `mini-docs` (pinned `0.19`) and the three
`castra` servers (pinned `0.7.1`) do not resolve to 0.29 and therefore do not break.
They are simply stale — none of them receives any of the 0.22–0.29 security work until
someone bumps them deliberately, which is a separate decision from this migration.
- **B2 was folded in rather than sequenced.** Removing the pipeline made `watch_targets`'
second branch unreachable, so the compiler required the collapse in the same commit;
splitting them would have meant committing code that does not build.
- Verified, not asserted: `src/` contains no filesystem write call, no `Command`/`Stdio`,
and `Cargo.toml` no longer enables `tokio`'s `process` feature. 3,239 lines removed
against 113 added. Coverage rose 83.9% → 88.92%, since the modules dragging it down
(`css.rs` 73%, `js.rs` 77%, `source.rs` 80%) left with the pipeline.
### B2. Collapse `watch_targets()` to the served root — **DONE** (folded into B1)
- Does: Commit 0 left `watch_targets()` with two branches — served root when
`has_pipeline()` is false, pipeline inputs otherwise. B1 removes pipelines entirely, so
`has_pipeline()` and the whole second branch become dead code. Delete both, along with
the `with_bundle_root` vestige, leaving a function that returns the served root. Say in
the docs *why* it collapses: nothing writes what the server watches any more, so the
feedback loop the second branch existed to avoid cannot occur.
- Verifies: the live-reload suite passes with the served root as the sole watch target —
including `live_reload_broadcasts_for_a_served_file_with_no_pipeline_configured`, which
commit 0 added and which must keep passing unchanged; plus a test that writes into the
served root while the server runs and asserts exactly one frame, proving no self-trigger
loop now that nothing writes.
- Touches: `src/server.rs`, `src/watcher.rs` (~60 LOC removed), `tests/live_reload.rs`.
- Reverts cleanly: yes — reverting restores redundant watch targets, not broken ones,
because commit 0 owns the behavior consumers rely on.
- Note: watching the served root walks the whole *built site* every 500 ms, where the old
targets were smaller source trees. For a large site that is a real per-tick cost, and it
arrives with commit 0 rather than here. Event-driven watching (`notify`) is the fix if
it bites; it is not in scope.
### B3. Advertise the break; 0.29.0 — **DONE**
- Does: README migration section at the very top — before/after for all eight methods,
naming `mini-build` and its version — plus a line in the status blockquote. Update
`examples/mini-static.rs` to drop the pipeline demo, and the `Dockerfile` to remove the
now-doubly-unnecessary `pkg-config`/`libssl-dev` (nothing in the tree links OpenSSL).
Bump to **0.29.0**: under 0.x semver the minor bump *is* the breaking-change signal.
- Verifies: `cargo test --doc` passes (the README is compiled as crate docs, so migration
snippets must be real code or fenced as `text`); the example builds and runs.
- Touches: `README.md`, `examples/mini-static.rs`, `Dockerfile`, `Cargo.toml`.
- Reverts cleanly: yes.
### C1. Update the website — **DONE** (`fef7c1d` in `Documents/victor`)
- Does: Switch the site's build to `mini-build`, pinned to 0.1, run ahead of serving.
- Verifies: the site's CSS/JS outputs are byte-identical to what it produced before the
switch, and it serves correctly from the built directory.
- Touches: the website repo.
- Reverts cleanly: yes — no directory structure moved, so a revert is a pin change.
- **Byte-identical, verified against the same sources.** `styles.css` and `scripts.js`
both hash identically between the old `mini-static` pipeline and `mini-build`.
- **The comparison failed first, for a methodology reason worth recording.** The "before"
build ran in a `git worktree` at HEAD while the "after" ran in the working tree, which
had uncommitted edits to `src/styles/typography.css` and `src/styles/variables.css` — a
theme change in progress. Different inputs, so of course different outputs; the CSS
differed while the JS matched, precisely because no JS source had been touched. I had
truncated `git status` with `head -3` and taken it for the whole list. Copying the two
modified sources into the worktree made the comparison valid, and the hashes then
matched exactly. **A byte-comparison across two trees is only evidence if the inputs are
provably the same.**
- **Consumer scope was wider than this plan claimed, and it still came out fine.** The
plan said `mini-unified` and `mini-serve` were the only in-repo consumers. A search
across all of `~/Documents` found six crates depending on `mini-static`: those two plus
`mini-docs`, and three `castra` frontend servers. None uses the pipeline API —
`mini-docs`' apparent hits were its own `Builder::build()`, and it pins 0.19 regardless.
The website really is the only consumer that breaks.
- **What the migration actually looks like:** `configured_server` keeps only serving
concerns (404 page, spa root, transition); a new `configured_asset_builder` holds the
source folders, tools, and asset folder. `build_static` calls the builder and is no
longer async. `main.rs` builds explicitly before constructing the server — the server
no longer builds anything — and in debug builds starts `Builder::watch`, so the
intended chain runs for real: `mini-build` watches `src/` and writes `public/`,
`mini-static` watches `public/` and pushes the reload. That second half only works
because of commit 0, which is why the website's `mini-static` pin moved to `0.28`.
- **Finished across three commits** as each dependency became available: `fef7c1d`
(migration, path dependency), `260e08a` (published `mini-build` 0.1), `5fc9d61`
(`mini-static` 0.29 plus the benches).
- **The benches were missed the first time.** C1 checked `src/` for pipeline API use and
found the two entry points, but `benches/request_serving.rs` and
`benches/asset_pipeline.rs` also called `Server::build()`. They only surfaced when the
0.29 bump made them fail to compile. A grep scoped to `src/` is not a grep of the crate.
### A6. Batch tool invocations — **DONE** (`5d55398`, `49c0d08`, `a4b5cb7`)
- Does: Send many inputs to one tool invocation instead of one per file, in bundle mode
and per-file mode, for both CSS and JS. Not in the original plan — it exists because A5's
benchmarks made the cost impossible to ignore.
- Result, measured before and after on the same fixture:
| case | 10 files | 50 files |
|---|---|---|
| `css_bundle` before → after | 238.8 → 194 ms | 1155 → **195 ms** |
| `per_file_minify` before → after | 236 → 189 ms | 1128 → **192 ms** |
| `css_bundle_flat` (one directory) | 36.5 ms | 48.5 ms |
**5.9x** on fifty files in both modes, and — the part that matters more — cost stopped
scaling with file count. Ten files and fifty now cost the same, because both sit in the
same eight directories, and it is directories that decide how many processes spawn. The
eight-directory fixture is deliberately adversarial; a real site keeps stylesheets in one
or two folders and sees the `_flat` column.
- **Grouping by directory is correctness, not caution.** `lightningcss --output-dir`
*flattens*: `one/shared.css` and `two/shared.css` batched together collide and one
silently vanishes. Verified against the real binary before designing, not assumed.
(`esbuild --outdir` preserves structure, but both use the same strategy rather than
depending on the difference.)
- **The per-file fallback preserves a contract batching would otherwise break.** Per-file
mode promises a malformed source degrades to a raw copy *while its neighbours are still
minified*. Batched, one bad file fails the whole invocation — so a failed batch rebuilds
its group one file at a time. The naive alternative (copy the whole group raw) is caught
by a dedicated test.
- **A correction to A5's reported number.** A5 recorded ~23 ms/file as the tool's startup.
Wrong: `lightningcss` itself takes ~5 ms, and bare `std::process::Command` costs the same
~19 ms as this crate's wrapper — so it is Rust's spawn on this platform, where `fork`
scales with the parent's address space, not the tool and not our code.
- Coverage 86.07%. `js.rs` was left at 60.5% by the mirrored batch path until tests were
written for it directly — a copied implementation is exactly the kind that drifts
unnoticed.
## Open Questions
- **Where the watcher lives.** Both crates need mtime polling: `mini-build` to trigger
rebuilds, `mini-static` to trigger reloads. The recommendation is to duplicate ~137
lines rather than introduce a third crate for one polling loop — S6's "write it twice
if you must" over a coupling that would bind two otherwise-independent crates. Revisit
only if a third consumer appears.
- ~~**Does `mini-build` need watch mode at all (A4)?**~~ **Resolved: yes.** It is the
development workflow — sources rebuild, the server notices the output, the browser
reloads. Kept in A4. Consequence to accept: two 500 ms polling loops now run in series,
so worst-case reload latency roughly doubles. Event-driven watching (`notify`) is the
fix if that becomes annoying, and it is a `mini-build` concern — one of the tunings
extraction exists to make possible.
- ~~**Asset folders may not belong in a *build* crate.**~~ **Resolved: they belong in
`mini-build`**, on the condition that the crate's stated purpose is *"produce the
directory a static server serves"* rather than *"run bundlers"* — under that framing a
byte-identical copy is in scope, and it earns its keep over plain `cp -r` because asset
changes should trigger reload and stale-output pruning like anything else. A2 should
write that purpose statement into the crate docs first, since it is what makes the
answer non-arbitrary.
Rejected alternative, recorded because it is the tempting one: give `mini-static`
multi-root serving so requests fall through to asset directories with no copy at all —
no duplication, no staleness. It is rejected because the crate's security story is one
root, canonicalized once, with `starts_with` as the boundary. N roots means N
boundaries, precedence rules, and paths resolvable two ways: a meaningful tax on the
single thing this crate must never get wrong, paid to avoid a file copy.
- **Does `mini-static` still need `run_on`'s pre-bind check hook?** With tools gone, the
only pre-bind failure left is the bind itself. Confirm nothing else depended on that
ordering before deleting it in B1.
- **Version signalling.** 0.29.0 follows 0.x convention, but a reader skimming the
changelog may not register a minor bump as breaking. The README's top-of-file migration
section is doing the real advertising work here; make sure the crates.io description or
the status blockquote carries it too, since that is what a consumer sees first.
- ~~**Async or sync for `mini-build`?**~~ **Resolved: sync — but converted after the move,
not during it.** Once the pipeline is out of the server it spawns subprocesses and waits
on them: process-level, CPU-bound parallelism, not task concurrency. `std::process` plus
threads (or `rayon`) says that directly and drops `tokio` — the largest dependency — from
a crate whose purpose is to be independently optimizable. What `tokio` was actually
buying is `kill_on_drop` and an easy 30 s timeout; in std that is a watchdog thread per
child, roughly twenty lines, and it gets cheaper once batching cuts concurrent children
from one-per-file to about one-per-core.
The sequencing correction matters more than the choice: deciding this *before* A1 would
have merged a 1,200-line relocation with an async-to-sync rewrite into one diff, at the
exact moment no equivalence harness exists. A1 therefore stays verbatim and async, A2
builds the byte-identical harness, and A3 does the conversion behind it — where a
mistranslation surfaces as a byte difference rather than as a bug in someone's site.
## Grill Verdict
Round: 1
Status: PASS
Findings resolved:
- [B2, G1] **The grill found a live bug, not a plan defect.** `watch_targets()`
(`src/server.rs:931`) returns source, bundle, and asset folders and never the served
root, so `Server::new(root).with_live_reload()` with no pipeline configured watches
nothing and its SSE stream never emits — while `with_live_reload`'s doc comment states
the watcher runs "over the server's root" and fires "whenever a served file is added,
modified, or removed." No test covers it: the two broadcast tests
(`tests/live_reload.rs:160,209`) both configure `with_source_folder`. Live-reload's
headline behavior does not work for a plain static site. Promoted out of B2 into a
standalone commit 0 that is independent of the extraction and should land immediately;
B2 is reduced to deleting what commit 0 makes redundant.
- [Commit 0, G3] Adding the served root while the pipeline still writes there would
recreate the feedback loop the current design avoids. **The grill's proposed remedy —
"exclude the output dir" — was itself wrong, and implementation caught it:** the output
dir defaults to the served root, so the exclusion would have excluded everything and
fixed nothing. The shipped fix branches on `has_pipeline()` instead. Recorded rather
than quietly amended, because a grill finding whose fix is also wrong is worth knowing
about — the lens caught the risk, not the remedy.
- [A1, G3/G4] "Move verbatim" concealed the actual coupling: `source.rs` imports
`reload::ChangeType` and `watcher::{Broadcaster, ChangeEvent}`, both of which *stay* in
`mini-static`. Enumerated, with the replacement work (own change-type, own channel)
costed into the commit.
- [A2, G3] The byte-identical check was specified in a way that could quietly prove
nothing: pipeline output depends on the installed `lightningcss`/`esbuild` version, so
committed goldens would fail spuriously on a tool upgrade and then be regenerated —
destroying the evidence. Now specified as an in-run comparison against `mini-static`
0.28.3 as a dev-dependency, same binaries, same process.
- [A5, G1] Publishing assumed the `mini-build` name is free. Moved the check to before A1
and noted that a publish is the plan's one irreversible step.
- [B1, G1] Removing `StaticError::{PipelineSetup, Build}` breaks code naming those
variants despite `#[non_exhaustive]`. Stated.
- [B1, G1] `mini-unified` needs a dependency bump to 0.29 with no code change — absent
from the plan entirely. Noted.
- [B1, G2→kept] Added a grep assertion that `src/` contains no write call, so "the server
never writes" becomes a checked property rather than a claim.
- [B2, G1] Watching the served root polls the whole built site every 500 ms, a larger walk
than the source trees it replaces. Recorded, attributed to commit 0 where it actually
lands, with `notify` named as the escape hatch.
- [C1, G3] "Revert by pinning back" assumed the source layout is untouched. Now says to
keep a directory reorganization in its own commit or lose the cheap revert.
- [A1/A2, G2] Verbatim relocation would decide async-vs-sync for `mini-build` by default.
Raised as an explicit decision due before A1.
Findings accepted as tradeoffs:
- [Whole plan, G2] Two 500 ms polling loops in series during development, roughly doubling
worst-case reload latency. Reason: it is the price of decoupling, it is dev-only, and
the fix (event-driven watching) belongs in `mini-build` where it can be tuned
independently — which is the extraction's stated purpose.
- [A4, G2] `mini-build` gains watch mode with exactly one known consumer. Reason:
confirmed as the development workflow rather than speculative generality; without it the
decoupled dev loop does not exist.