mini-static 0.19.1

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
# PLAN.md — SPA-mode navigation for mini-static

## Where We Are

`mini-static` serves every request as a normal HTTP GET/HEAD against a file
under its canonicalized root (`src/server.rs::handle_request`). Multi-page
navigation today is entirely the browser's default behavior: clicking any
`<a href>` triggers a full document unload/request/parse/render cycle. There
is no client-side interception, no `history.pushState`, and no way to animate
between pages — a click looks identical to typing a new URL, and any
in-memory JS state on the page is discarded on every navigation.

The crate already has one precedent for injecting behavior into served HTML:
when `Server::with_live_reload()` is enabled, `handle_request` reads the full
HTML body into memory (gated by `self.broadcaster.is_some() &&
content_type.starts_with("text/html")`, `src/server.rs:920`) and calls
`reload::inject_reload_script` (`src/reload.rs:143`), which splices a fixed
`<script>` immediately before `</body>` (or `</BODY>`), falling back to
appending it if no closing body tag is found. That injected script opens an
`EventSource` to `LIVE_RELOAD_PATH` and either hot-swaps `<link
rel=stylesheet>` hrefs (CSS changes) or calls `location.reload()` (script/html/
other changes) — it does not touch normal link navigation at all.

Without live-reload enabled (the default), HTML responses are served
byte-for-byte unmodified.

## Where We Need To Be

Two new opt-in `Server` builder methods, both off by default (zero behavior
change for existing callers):

- `Server::with_spa_mode()` — enables SPA navigation, swap target is
  `document.body`.
- `Server::with_spa_root(selector: &str)` — enables SPA navigation, swap
  target is the element matched by CSS `selector` (implies `with_spa_mode`).

When enabled, every served `text/html` response gets one additional
`<script>` spliced before `</body>` (same anchor/fallback logic as the
live-reload script; the two compose — see Commit 3). In a browser, that
script:

1. **Intercepts navigation clicks.** Left-clicks (no modifier key) on
   same-origin `<a href>` elements are intercepted, *except* when the link:
   has `target` other than empty/`_self`, has a `download` attribute, has
   `rel="external"`, carries a `data-no-spa` attribute, or is a same-page
   hash-only link (same path+search, different `#fragment`) — those fall
   through to normal browser navigation untouched.
2. **Fetches and swaps.** On intercept: `fetch()`s the href. A non-OK
   response or a response whose `Content-Type` isn't `text/html` falls back
   to a real full navigation (`location.href = url`) — SPA nav degrades to
   normal nav, it never renders a broken page. On success: parses the
   response body, replaces the configured root element's `innerHTML` with
   the corresponding root content from the fetched document, sets
   `document.title`, and calls `history.pushState` with the fetch's final
   (post-redirect) `response.url`.
3. **Animates the swap.** Wraps the DOM swap in `document.startViewTransition()`
   when the browser supports it (a default cross-fade with zero required
   CSS; a site can override it via `::view-transition-old/new` rules), and
   swaps synchronously when it isn't supported — no broken behavior on
   browsers without View Transitions.
4. **Handles back/forward.** A `popstate` listener re-fetches and swaps to
   `location.href` without pushing a new history entry.
5. **Lets pages re-initialize.** After every *client-side* navigation (not
   the initial page load), dispatches
   `window.dispatchEvent(new CustomEvent('mini-static:navigate', {detail:{url}}))`.
   This exists because content swapped in via `innerHTML` never executes
   `<script>` tags — any per-page init logic must run on this event (in
   addition to normal `DOMContentLoaded`/inline execution on first load) to
   fire again on subsequent navigations.
6. **Preserves scroll intent.** Scrolls to the top of the page, or to the
   element matching the URL's `#fragment` if one exists in the new content.
7. **Ignores superseded navigations.** If a second navigation starts before
   the first's fetch resolves, the first's result is discarded instead of
   clobbering the second's.

With spa-mode disabled (the default), HTML response bytes are unaffected —
byte-identical to today. With spa-mode and live-reload both enabled, both
scripts are present in the response, both before `</body>`.

## Commits

### 1. `spa` module — client script + HTML splice (pure, unit-tested)
- Does: Make `reload::find_subsequence` `pub(crate)` (currently private,
  `src/reload.rs:154`) so it has one canonical implementation shared by both
  injectors instead of a second copy. Add `src/spa.rs` with
  `spa_script_tag(root_selector: Option<&str>) -> String` and
  `pub(crate) fn inject_spa_script(html: &mut Vec<u8>, root_selector: Option<&str>)`,
  which splices before `</body>`/`</BODY>` or appends, reusing
  `reload::find_subsequence` rather than duplicating it.
  The root selector is embedded as a JSON-escaped string literal (escape `"`,
  `\`, and control characters; additionally reject/escape any `</script`
  substring so the selector text can never terminate the injected `<script>`
  tag early) — this guards a misconfigured `with_spa_root` call from
  corrupting every HTML page the server serves, not just the one page a
  visitor is viewing.
  The generated script's behavior, precisely:
  - Click interception, guards (`target`, `download`, `rel="external"`,
    `data-no-spa`, hash-only same-page links), same-origin check — as
    described in "Where We Need To Be".
  - `navigate(url, push)`: `fetch()` first, in full, *before* any transition
    starts. Only after the fetched response is validated (OK + `text/html`)
    and parsed does the function proceed; a non-OK or non-HTML response
    triggers `location.href = url` and returns — no view transition is
    started for a fallback navigation.
  - The DOM mutation itself — `curRoot.innerHTML = newRootHtml; document.title = newTitle;`
    plus `history.pushState`/scroll — is the *only* thing wrapped in
    `document.startViewTransition(...)` when supported, so the transition
    callback returns immediately rather than awaiting network I/O (required
    by the View Transitions API's timing model — wrapping the fetch itself
    would stall or visually break the transition). `innerHTML` assignment is
    used deliberately over node replacement: it preserves event listeners and
    attributes bound to the persistent root element itself (relevant to
    `with_spa_root`, where the root container is expected to stay alive
    across navigations), only replacing its children.
  - `popstate` handling, `mini-static:navigate` dispatch, scroll-to-hash,
    and stale-navigation-token guard, all as described above.
- Verifies: unit tests (same shape as `reload.rs`'s existing ones): generated
  script contains `mini-static:navigate`; contains the configured selector
  literal when `Some(sel)` and references `document.body` (no selector
  literal) when `None`; a selector containing a `"` and one containing
  `</script` are each escaped such that the surrounding `<script>` tag stays
  intact (assert exactly one `<script>` open/close pair in the output);
  `inject_spa_script` inserts before `</body>` and `</BODY>`, appends when
  neither is present, and is no-op-safe on empty input. All run via
  `cargo test`, no server involved.
- Touches: new `src/spa.rs` (~200–260 LOC including tests), one-line
  visibility change in `src/reload.rs`.
- Reverts cleanly: yes — deletes an unwired, unreferenced module; the
  `find_subsequence` visibility bump is harmless to leave behind on its own
  since a `pub(crate)` function nobody calls outside its module doesn't
  trigger the dead-code lint the way an unused top-level function would.

### 2. `Server::with_spa_mode()` / `with_spa_root()` + wiring into `handle_request`
- Does: Add `spa_mode: bool` and `spa_root: Option<String>` fields to
  `Server`. Add `with_spa_mode(mut self) -> Self` (sets `spa_mode = true`
  only — leaves `spa_root` untouched either way) and
  `with_spa_root(mut self, selector: &str) -> Self` (sets both
  `spa_root = Some(selector.into())` and `spa_mode = true`), so the two
  compose regardless of call order: `.with_spa_root("#app").with_spa_mode()`
  and `.with_spa_mode().with_spa_root("#app")` both end up with spa-mode on
  and root `#app`.
  Change the existing `html_injection` gate in `handle_request`
  (`src/server.rs:920`) from
  `self.broadcaster.is_some() && content_type.starts_with("text/html")` to
  `(self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html")`  this is the load-bearing change; without it, enabling `with_spa_mode()`
  alone (no live-reload) leaves `broadcaster` `None` and the gate stays
  false, silently no-opping spa-mode. Then, at the existing splice step
  (`src/server.rs:968`), call `spa::inject_spa_script(&mut html, self.spa_root.as_deref())`
  when `self.spa_mode`, after (or independent of) the existing
  `reload::inject_reload_script` call. Because both calls key off the same
  widened `html_injection` bool, the sidecar-skip and Range-skip logic that
  already branches on that bool (`src/server.rs`, `sidecar`/`range_header`
  handling just below) covers spa-mode automatically — no separate change
  needed there.
- Verifies: new `tests/spa_mode.rs`, socket-level like `tests/live_reload.rs`:
  (a) spa-mode off (default) — response byte-identical to the unmodified
  fixture; (b) `with_spa_mode()` — response contains the script referencing
  `document.body`; (c) `with_spa_root("#app")` — response contains the script
  referencing `#app`, not `document.body`; (d) `with_spa_root("#app").with_spa_mode()`
  and `with_spa_mode().with_spa_root("#app")` both produce a response
  containing `#app` — proving the order-independence contract.
- Touches: `src/server.rs` (2 fields, ~30 LOC of builders, one gate-boolean
  edit, one added call), new `tests/spa_mode.rs` (~90 LOC).
- Reverts cleanly: yes, together with Commit 1. Reverting Commit 2 *alone*
  leaves `spa.rs`'s functions unused, which would fail this repo's
  `clippy -- -D warnings` CI gate until Commit 1 is reverted too — the two
  are meant to ship and revert as a pair, matching how this crate's own
  `DEV_PLAN.md` already sequences other features (pure function commit,
  then a wiring commit).

### 3. Compose spa-mode with live-reload; regression test
- Does: No production code change — composition already works, because each
  injector re-searches the buffer for `</body>` after the previous one's
  splice, so sequential calls both land before the (still-present) closing
  tag regardless of call order. Add the test that proves it.
- Verifies: extend `tests/spa_mode.rs` with a case enabling both
  `with_spa_mode()` and `with_live_reload()`; assert the response contains
  both the `EventSource` marker and the `mini-static:navigate` marker, each
  individually positioned before `</body>` — the test does not assert a
  relative order between the two scripts, since which one lands first is an
  implementation detail, not a contract either commit makes.
- Touches: `tests/spa_mode.rs` (+~30 LOC).
- Reverts cleanly: yes — pure test addition, no coupling.

### 4. Example + README docs
- Does: Add an unconditional `.with_spa_root("#app")` call to
  `examples/mini-static.rs` — unlike the CSS/JS-bundle demo, which is
  gated behind an env var / path check because it depends on an optional
  external binary (`lightningcss`/`esbuild`) actually being installed,
  spa-mode is pure Rust with no external dependency, so there's no reason to
  gate it; it's simply always on in the example. Add a small two-page demo
  fixture under the example's `public/` (shared `#app` wrapper plus
  persistent header outside it, each page logging to a visible element on
  `mini-static:navigate` to prove the reinit hook works), and a README
  section documenting: what the two builders do, the `data-no-spa` opt-out,
  the `mini-static:navigate` event contract with a listener snippet, the
  View Transitions progressive-enhancement behavior and how to customize it,
  the explicit limitation that inline `<script>`s inside the swapped root
  never execute, and the accepted tradeoff (see Open Questions) that a click
  on a same-origin link to a large non-HTML file is fetched once via JS
  before falling back to a real navigation — recommend `download` or
  `data-no-spa` on such links.
- Verifies: manual — `cargo run --example mini-static`, click between the
  two demo pages in a real browser; confirm the URL updates without a full
  reload, a transition is visible, back/forward works, the on-page log shows
  the event firing each navigation, and a plain click-and-load still works
  with JS disabled. This is the one commit in the plan whose primary
  verification is manual rather than `cargo test` — see Open Questions.
- Touches: `examples/mini-static.rs` (~20 LOC), 2 new small HTML fixtures,
  `README.md` (+~40–60 lines).
- Reverts cleanly: yes — additive docs/example, nothing depends on it.

## Open Questions

- **Accepted tradeoff: large non-HTML files get double-fetched on a missed
  opt-out.** A same-origin link to a PDF/zip/etc. without `download` or
  `data-no-spa` is `fetch()`ed in full by the interceptor before the
  content-type check falls back to a real navigation, which then re-requests
  it — a real cost for large files. A code-level heuristic (e.g. skip
  interception based on file extension) was considered and rejected: mini-static
  serves arbitrary content with no forced extension convention, so an
  extension check would be its own source of false negatives. Mitigation is
  documentation only (Commit 4: recommend `download`/`data-no-spa` on
  non-page links), not code.
- **No headless-browser test coverage.** This crate has zero browser-
  automation test infra today; the actual click-interception, transition,
  popstate, and reinit-event behavior can only be verified manually via
  Commit 4's example. Adding `fantoccini`/`chromiumoxide`-based automated
  browser tests is a meaningfully bigger lift than the feature itself —
  recommend deferring unless a real regression shows up, but flagging since
  it's a real gap relative to this crate's otherwise fully-automated test
  culture.
- **Link-exclusion rules are a judgment call.** The proposed exclusions
  (`target`, `download`, `rel="external"`, `data-no-spa`, hash-only) follow
  common precedent (Turbo, htmx boost) but aren't derived from anything in
  this codebase — worth confirming no existing backstack-generated site
  relies on link behavior this would change before Commit 4 ships.
- **Selector-not-found fallback.** If `with_spa_root(sel)` is configured but
  `sel` matches nothing on either the current or fetched page, the proposed
  behavior is silent fallback to a full `location.href` navigation (same
  policy as a fetch failure). Confirm that's preferred over a console error
  or throwing.

## Grill Verdict
Round: 1
Status: PASS

Findings resolved:
- [Commit 1, G1] Root selector embedded into the script/HTML with no
  escaping strategy → JSON-string-escape the selector, additionally guard
  against `</script` breaking the tag; added unit tests for both.
- [Commit 1, G3] Fetch-before-transition ordering was implementation-critical
  but unstated → made explicit: `startViewTransition`'s callback wraps only
  the synchronous DOM mutation, never the network fetch.
- [Commit 1, G3] Swap mechanism (`innerHTML` vs. node replacement) unstated,
  though it determines whether listeners on a persistent root container
  survive navigation → specified `innerHTML` assignment, with the reason
  (preserves the container's own listeners/attributes) stated inline.
- [Commit 1, G4] Dangling "see Open Questions" pointer for sharing
  `find_subsequence` with `reload.rs`, never actually added as a question →
  resolved directly: `find_subsequence` becomes `pub(crate)` and is reused,
  not duplicated.
- [Commit 2, G1/G4] Exact gate-boolean change
  (`broadcaster.is_some()``broadcaster.is_some() || spa_mode`) was implied
  but not spelled out, risking a silent no-op for spa-only configs → written
  out verbatim in the commit, flagged as the load-bearing change.
- [Commit 2, G4] Sidecar-skip/Range-skip inheriting spa-mode coverage via the
  same bool wasn't stated → added as an explicit reassurance sentence.
- [Commit 2, G3] Builder call-order contract
  (`with_spa_root` then `with_spa_mode`, or reverse) was unstated →
  `with_spa_mode` now only sets its own flag and never clears `spa_root`, so
  both call orders converge on the same state; added a test case covering
  both orders.
- [Commit 3, G3] Test was at risk of asserting an unstated ordering contract
  between the two injected scripts → reworded to check presence/position
  independently, not relative order.
- [Commit 4, G2/G3/G4] "Env-var-gated, mirroring the CSS/JS demo pattern" was
  a misleading reference (that pattern exists for optional-external-binary
  gating, which doesn't apply here) → simplified to always-on in the example.

Findings accepted as tradeoffs:
- [Commit 1, G1] Large non-HTML file links get speculatively fetched once
  before falling back to real navigation, absent an opt-out attribute.
  Reason: a code-level heuristic (extension sniffing) would be its own
  source of false negatives given mini-static's arbitrary-content-type
  serving model; mitigated via documentation (Commit 4) instead of code.
  Logged in Open Questions.