frontend: cut over Rust binaries to ServeDir; delete legacy assets

Phase 4 of #273 — the actual switch. Both axum routers now serve
their static surface via `tower_http::services::ServeDir` mounted
as a fallback service, reading the dist path from `HIVE_STATIC_DIR`
(set by Phase 3's NixOS module wiring).

Deletes:
- `hive-c0re/assets/{index.html, app.js, dashboard.css}`
- `hive-ag3nt/assets/{index.html, app.js, agent.css, stats.html,
   stats.js, screen.html}`
- The whole `hive-fr0nt/` crate (workspace member dropped, both
  hive-c0re and hive-ag3nt drop their `hive-fr0nt.workspace = true`
  dep). Its contents now live as `@hive/shared` under
  `frontend/packages/shared/`.

Rust changes:
- `hive-c0re/src/dashboard.rs`: remove `serve_index`, `serve_css`,
  `serve_app_js`, `serve_shared_js`, `serve_marked_js`,
  `serve_favicon` (all six `include_str!` handlers); replace their
  routes with a single `.fallback_service(ServeDir::new(static_dir))`
  on the router. Fail closed (anyhow::bail) if `HIVE_STATIC_DIR` is
  unset or not a directory at startup.
- `hive-ag3nt/src/web_ui.rs`: remove `serve_index`, `serve_css`,
  `serve_app_js`, `serve_shared_js`, `serve_marked_js`,
  `serve_stats`, `serve_stats_js`, `serve_screen`; same
  `fallback_service` pattern. `serve_icon` stays (consumes
  `/etc/hyperhive/icon.svg` + `branding/hyperhive.svg` fallback,
  neither of which lives under the frontend dist).
- `AgentLink` URLs for stats/screen switched from `/stats` / `/screen`
  to `/stats.html` / `/screen.html` since ServeDir doesn't auto-
  append the extension and the on-disk filename is the natural URL
  post-cutover.
- `Cargo.toml` (workspace): drop `hive-fr0nt` member + workspace
  dep, add `tower-http = { version = "0.6", features = ["fs"] }`.
- `hive-c0re/Cargo.toml` + `hive-ag3nt/Cargo.toml`: drop the
  `hive-fr0nt.workspace = true` dep, add `tower-http.workspace =
  true`.

Docs updated:
- `CLAUDE.md`: file map reflects `frontend/` (was `hive-fr0nt/` +
  `assets/`) and the ServeDir/HIVE_STATIC_DIR shape.
- `docs/web-ui.md` 'Shape (shared by both)' section: describes the
  ServeDir fallback + bundled-by-esbuild surface, no more
  `include_str!` references.
- `docs/terminal-rendering.md`: src paths point at
  `frontend/packages/{agent,shared}/src/`; marked is the npm dep,
  not vendored UMD.

Validation:
- `cargo check --workspace` — clean (5 warnings, all pre-existing
  in `rebuild_queue.rs`, none on changed files).
- `cargo clippy --workspace --all-targets` — clean (11 warnings,
  same pre-existing source).
- `cd frontend && npm run build` from the prior commit's lockfile
  produces the dist directories the new routers consume:
    dashboard: `dist/{index.html, static/{app.js, dashboard.css}}`
    agent:     `dist/{index.html, stats.html, screen.html,
                       static/{app.js, stats.js, agent.css}}`
  (favicon.svg lands in dashboard/ during the nix build —
  `nix/frontend.nix` install phase copies `branding/hyperhive.svg`
  there, since it's outside the npm tree.)

Refs #273.
This commit is contained in:
iris 2026-05-23 13:21:37 +02:00 committed by Mara
parent 2ecf15bb6f
commit 229c4292e9
24 changed files with 143 additions and 10122 deletions

View file

@ -24,6 +24,7 @@ use axum::{
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use tower_http::services::ServeDir;
use crate::client;
use crate::events::Bus;
@ -88,6 +89,19 @@ pub async fn serve(
turn_lock: TurnLock,
) -> Result<()> {
let gui_vnc_port = read_gui_json();
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
.map(PathBuf::from)
.context(
"HIVE_STATIC_DIR env var not set — point it at the merged \
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
)?;
if !static_dir.is_dir() {
anyhow::bail!(
"HIVE_STATIC_DIR ({}) is not a directory",
static_dir.display()
);
}
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
let state = AppState {
label,
login,
@ -99,11 +113,6 @@ pub async fn serve(
gui_vnc_port,
};
let app = Router::new()
.route("/", get(serve_index))
.route("/static/agent.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/static/hive-fr0nt.js", get(serve_shared_js))
.route("/static/marked.js", get(serve_marked_js))
.route("/api/state", get(api_state))
.route("/events/stream", get(events_stream))
.route("/events/history", get(events_history))
@ -116,12 +125,17 @@ pub async fn serve(
.route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session))
.route("/api/loose-ends", get(api_loose_ends))
.route("/stats", get(serve_stats))
.route("/static/stats.js", get(serve_stats_js))
.route("/api/stats", get(api_stats))
.route("/screen", get(serve_screen))
.route("/screen/ws", get(screen_ws))
.route("/icon", get(serve_icon))
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
// falls through to the merged dist. ServeDir auto-appends
// `.html` when the URL is a bare path that matches a file
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
// screen.html`). Per-agent `extraFiles` additions are
// already layered into this same directory (see
// hyperhive.frontend.mergedDist in nix).
.fallback_service(ServeDir::new(&static_dir))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?;
@ -201,68 +215,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024)
}
async fn serve_index() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/index.html"),
)
}
async fn serve_css() -> impl IntoResponse {
// Prepend the shared palette/typography so per-page styles only need
// to declare what's actually page-specific. One HTTP request, no
// per-asset cache to invalidate.
let body = format!(
"{}\n{}\n{}",
hive_fr0nt::BASE_CSS,
hive_fr0nt::TERMINAL_CSS,
include_str!("../assets/agent.css"),
);
([("content-type", "text/css")], body)
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
async fn serve_shared_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::TERMINAL_JS,
)
}
async fn serve_marked_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::MARKED_JS,
)
}
async fn serve_stats() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/stats.html"),
)
}
async fn serve_stats_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/stats.js"),
)
}
async fn serve_screen() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/screen.html"),
)
}
/// This agent's icon. Serves the operator-configured SVG from
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
/// option) when present, otherwise the bundled default hyperhive logo.
@ -585,8 +537,13 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
let mut links = Vec::new();
// Note: the URLs are the actual HTML files served out of the
// frontend dist (`stats.html` / `screen.html`); after the #273
// backend/frontend split the harness serves these as static
// files via ServeDir rather than via Rust routes, so the URL
// has to be the on-disk filename.
links.push(AgentLink {
url: "/stats".to_owned(),
url: "/stats.html".to_owned(),
icon: "📊".to_owned(),
label: "stats".to_owned(),
kind: AgentLinkKind::Container,
@ -594,7 +551,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
if gui_enabled {
links.push(AgentLink {
url: "/screen".to_owned(),
url: "/screen.html".to_owned(),
icon: "🖥".to_owned(),
label: "screen".to_owned(),
kind: AgentLinkKind::Container,