fix(maestro): stop embedding web bundle in the registry

The registry serves no UI — that is maestro-web's job (connection-
orchestration spec §"UI hosting"). The rust-embed of web/dist was
legacy from NAP-218, before maestro-web was extracted (NAP-241), and it
made the registry (and thus the umbrella maestro binary) fail to compile
whenever web/dist was absent — e.g. a fresh cargo install --git after
the tracked .gitkeep was dropped. Remove web_assets.rs, the rust-embed
dep, and the / + /assets routes; the registry is now JSON/WS API only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 06:40:23 +00:00
co-authored by Claude Opus 4.8
parent 278c81d2bc
commit 212ebbf120
5 changed files with 6 additions and 151 deletions
-1
View File
@@ -19,7 +19,6 @@ jsonwebtoken.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
rand.workspace = true
rust-embed.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
+2 -11
View File
@@ -18,7 +18,6 @@ use uuid::Uuid;
use crate::events::events_ws;
use crate::jwt::issue_attach_jwt;
use crate::state::{Registry, RegistryError, RPC_TIMEOUT};
use crate::web_assets;
/// Shared state plumbed into client-facing handlers.
#[derive(Debug, Clone)]
@@ -31,9 +30,9 @@ pub struct ClientApiState {
/// Mount client-facing routes on the given router. Caller is responsible for
/// applying the cookie middleware (we do that in [`crate::server::router`]).
pub fn routes(state: ClientApiState) -> Router {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). Only the JSON/WS API here.
Router::new()
.route("/", get(index))
.route("/assets/*file", get(asset))
.route("/api/daemons", get(list_daemons))
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/:id", axum::routing::delete(delete_session))
@@ -42,14 +41,6 @@ pub fn routes(state: ClientApiState) -> Router {
.with_state(state)
}
async fn index() -> Response {
web_assets::serve_index()
}
async fn asset(Path(file): Path<String>) -> Response {
web_assets::serve_asset(&file)
}
async fn list_daemons(State(s): State<ClientApiState>) -> Json<Vec<DaemonInfo>> {
let infos: Vec<DaemonInfo> = s
.registry
-1
View File
@@ -21,7 +21,6 @@ pub mod run;
pub mod server;
pub mod state;
pub mod token;
pub mod web_assets;
pub use run::run;
-134
View File
@@ -1,134 +0,0 @@
//! Embedded static assets for the web client bundle.
//!
//! The Vite build in `web/` writes its output to `web/dist/`; `rust-embed`
//! picks that up at `cargo build` time and embeds the files into the binary.
//! That keeps deploy artifacts to a single executable.
//!
//! When the `web/dist` tree is empty (typical for a fresh checkout without
//! `pnpm build`), the handlers below fall back to a "build the web bundle
//! first" placeholder so the rest of the registry still works for testing.
//!
//! See NAP-218.
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../web/dist/"]
struct WebAssets;
const FALLBACK_INDEX: &str = "<!doctype html><meta charset=utf-8><title>maestro</title>\
<h1>maestro registry</h1>\
<p>Web bundle not built. Run <code>pnpm --dir web install</code> \
then <code>pnpm --dir web build</code>, then rebuild the registry.</p>";
fn content_type_for(path: &str) -> &'static str {
let ext = path.rsplit('.').next().unwrap_or("");
match ext {
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"map" => "application/json",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn cache_headers(path: &str) -> &'static str {
// Vite emits hashed filenames under /assets/. Those can be cached
// aggressively. Everything else (index.html, the favicon, etc.) should
// not be cached.
if path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
}
}
fn serve(path: &str) -> Option<Response> {
let asset = WebAssets::get(path)?;
let body = asset.data.into_owned();
Some(
(
[
(header::CONTENT_TYPE, content_type_for(path)),
(header::CACHE_CONTROL, cache_headers(path)),
],
body,
)
.into_response(),
)
}
/// Serve `dist/index.html` from the embed; fall back to a placeholder
/// pointing the operator at `pnpm build` when the bundle hasn't been built.
pub fn serve_index() -> Response {
if let Some(resp) = serve("index.html") {
return resp;
}
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
FALLBACK_INDEX,
)
.into_response()
}
/// Serve a hashed asset under `dist/assets/<file>`.
pub fn serve_asset(file: &str) -> Response {
// Reject anything that tries to escape the `assets/` prefix. `rust-embed`
// already won't return parent-directory paths, but we keep the explicit
// check so the surface is obvious.
if file.contains("..") || file.starts_with('/') {
return StatusCode::NOT_FOUND.into_response();
}
let path = format!("assets/{file}");
match serve(&path) {
Some(resp) => resp,
None => StatusCode::NOT_FOUND.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_type_routes_common_extensions() {
assert_eq!(content_type_for("foo.js"), "text/javascript; charset=utf-8");
assert_eq!(content_type_for("a/b.css"), "text/css; charset=utf-8");
assert_eq!(content_type_for("x.svg"), "image/svg+xml");
assert_eq!(content_type_for("index.html"), "text/html; charset=utf-8");
assert_eq!(content_type_for("noext"), "application/octet-stream");
}
#[test]
fn cache_assets_immutable_index_no_cache() {
assert!(cache_headers("assets/index-abc.js").contains("immutable"));
assert_eq!(cache_headers("index.html"), "no-cache");
}
#[test]
fn serve_asset_rejects_traversal() {
let resp = serve_asset("../secret");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn serve_index_returns_html() {
let resp = serve_index();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(ct.starts_with("text/html"));
}
}
+4 -4
View File
@@ -666,13 +666,13 @@ async fn events_ws_pushes_snapshot_then_deltas() {
}
#[tokio::test]
async fn index_serves_placeholder() {
async fn root_serves_no_ui() {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). `/` has no route.
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client.get(h.base()).send().await.unwrap();
assert!(resp.status().is_success());
let body = resp.text().await.unwrap();
assert!(body.contains("maestro"));
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------