From 077e9e958e93d12b969de5357469a3736d23edf0 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Mon, 25 May 2026 09:48:49 +0000 Subject: [PATCH] feat(maestro): embed and serve web bundle from registry NAP-218. The registry's `GET /` now serves the built web bundle from `web/dist/` (embedded into the binary at compile time via rust-embed) instead of the NAP-215 placeholder, and `GET /assets/*` serves the hashed JS/CSS chunks Vite produces. When `web/dist` is empty (fresh checkout without `pnpm build`), a small fallback page tells the operator to run the JS build. That keeps `cargo run -p maestro-registry` usable without a node toolchain installed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 74 ++++++++++++ Cargo.toml | 1 + crates/maestro-registry/Cargo.toml | 1 + crates/maestro-registry/src/client_api.rs | 15 +-- crates/maestro-registry/src/lib.rs | 1 + crates/maestro-registry/src/web_assets.rs | 135 ++++++++++++++++++++++ 6 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 crates/maestro-registry/src/web_assets.rs diff --git a/Cargo.lock b/Cargo.lock index 1b3326b..f368e81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,6 +970,7 @@ dependencies = [ "maestro-protocol", "rand 0.8.6", "reqwest", + "rust-embed", "serde", "serde_json", "tempfile", @@ -1362,6 +1363,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1453,6 +1488,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.28" @@ -1545,6 +1589,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2107,6 +2162,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2267,6 +2332,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index eb924b3..9d2b344 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ maestro-protocol = { path = "crates/maestro-protocol" } nix = { version = "0.29", default-features = false, features = ["term", "signal"] } rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] } +rust-embed = "8" serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" diff --git a/crates/maestro-registry/Cargo.toml b/crates/maestro-registry/Cargo.toml index d1edd6b..c73b628 100644 --- a/crates/maestro-registry/Cargo.toml +++ b/crates/maestro-registry/Cargo.toml @@ -22,6 +22,7 @@ futures-util.workspace = true jsonwebtoken.workspace = true maestro-protocol.workspace = true rand.workspace = true +rust-embed.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/maestro-registry/src/client_api.rs b/crates/maestro-registry/src/client_api.rs index fa1e946..23f8e1f 100644 --- a/crates/maestro-registry/src/client_api.rs +++ b/crates/maestro-registry/src/client_api.rs @@ -18,6 +18,7 @@ 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)] @@ -32,6 +33,7 @@ pub struct ClientApiState { pub fn routes(state: ClientApiState) -> Router { 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)) @@ -40,13 +42,12 @@ pub fn routes(state: ClientApiState) -> Router { .with_state(state) } -async fn index() -> impl IntoResponse { - // Placeholder until the real web bundle lands in NAP-218. - ( - [("content-type", "text/html; charset=utf-8")], - "maestro

maestro registry

\ -

The web client is not bundled yet — see NAP-218.

", - ) +async fn index() -> Response { + web_assets::serve_index() +} + +async fn asset(Path(file): Path) -> Response { + web_assets::serve_asset(&file) } async fn list_daemons(State(s): State) -> Json> { diff --git a/crates/maestro-registry/src/lib.rs b/crates/maestro-registry/src/lib.rs index e4b5ae3..1d29276 100644 --- a/crates/maestro-registry/src/lib.rs +++ b/crates/maestro-registry/src/lib.rs @@ -20,6 +20,7 @@ pub mod jwt; pub mod server; pub mod state; pub mod token; +pub mod web_assets; pub use auth::{issue_cookie, verify_cookie, COOKIE_NAME}; pub use jwt::{issue_attach_jwt, ATTACH_JWT_TTL}; diff --git a/crates/maestro-registry/src/web_assets.rs b/crates/maestro-registry/src/web_assets.rs new file mode 100644 index 0000000..478cffe --- /dev/null +++ b/crates/maestro-registry/src/web_assets.rs @@ -0,0 +1,135 @@ +//! 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 = + "maestro\ +

maestro registry

\ +

Web bundle not built. Run pnpm --dir web install \ + then pnpm --dir web build, then rebuild the registry.

"; + +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 { + 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/`. +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")); + } +}