This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/static_bundle.rs
T
Miguel PalhasandClaude Opus 5 4fc90aabfb feat(maestro): serve the web UI from the daemon
The browser reached the daemon through maestro-web, a second binary that
served the SPA and reverse-proxied /api/* — two TCP connections and a
WebSocket frame re-encode on every keystroke and every byte of PTY output.
On one machine that hop buys nothing.

The daemon now embeds the bundle and serves it off its own listener, behind
a default-on `web` feature. `--no-default-features` builds without pnpm or
node; MAESTRO_WEB_DIST still takes a prebuilt bundle.

The router's fallback answers an unmatched /api/* with 404 rather than the
SPA shell, so a client expecting JSON gets a status it can read instead of
HTML.

Removes maestro-web and the [web] config section. An existing [web] block
is ignored, not rejected. The installer deletes a leftover
maestro-web.service on upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:23:18 +01:00

184 lines
6.4 KiB
Rust

//! Embedded SPA bundle.
//!
//! The bundle is consumed at compile time via `include_dir!`, reading whatever
//! directory `build.rs` resolved into `MAESTRO_WEB_BUNDLE_DIR` — either the
//! `web/dist` it just built with pnpm, or a prebuilt bundle handed over in
//! `MAESTRO_WEB_DIST`. Either way `build.rs` fails loudly if that directory
//! holds no `index.html`, so the placeholder below is defensive only; a
//! successful build always embeds the real bundle.
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir};
static BUNDLE: Dir<'_> = include_dir!("$MAESTRO_WEB_BUNDLE_DIR");
/// Serve `/index.html`, falling back to a tiny placeholder when the bundle
/// hasn't been built yet. Used both for `GET /` and SPA-route fallthrough
/// (so refreshing on `#/sessions/foo` doesn't 404).
///
/// The shell is sent `Cache-Control: no-cache` so the browser revalidates it
/// on every load. It must not be cached: `index.html` references the current
/// content-hashed `/assets/*` filenames, and a redeploy changes those hashes.
/// A stale cached shell would point at asset names that no longer exist.
pub fn serve_index() -> Response {
match BUNDLE.get_file("index.html") {
Some(f) => html_response(f.contents()),
None => placeholder(),
}
}
/// Serve any path beneath the bundle (e.g. `assets/index-deadbeef.js`).
///
/// Content-hashed files under `assets/` are immutable (the hash changes when
/// the contents do), so they get a long `immutable` cache. Everything else
/// (`sw.js`, `manifest.webmanifest`, icons) is `no-cache` so redeploys take
/// effect immediately.
///
/// A miss only falls through to the SPA shell for *extensionless* paths (real
/// client-side routes like `/sessions/foo`). A miss on a path that names a file
/// — anything with an extension, e.g. a stale `assets/index-OLDHASH.js` —
/// returns **404**, never the HTML shell. Serving HTML where the client expects
/// JS makes the page silently blank after a redeploy rotates the hashes.
pub fn serve_path(path: &str) -> Response {
let trimmed = path.trim_start_matches('/');
if trimmed.is_empty() {
return serve_index();
}
match BUNDLE.get_file(trimmed) {
Some(f) => {
let mime = mime_guess::from_path(trimmed).first_or_octet_stream();
let cache = if trimmed.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
(
[
(header::CONTENT_TYPE, mime.as_ref()),
(header::CACHE_CONTROL, cache),
],
f.contents().to_vec(),
)
.into_response()
}
None if looks_like_file(trimmed) => not_found(),
// SPA fallback: an extensionless route falls through to index.html so
// the client-side router can resolve it.
None => serve_index(),
}
}
/// Whether the last path segment carries a filename extension (e.g.
/// `assets/index-abc123.js`, `favicon.ico`). Such paths name a concrete file;
/// a miss is a genuine 404 rather than a SPA route.
fn looks_like_file(path: &str) -> bool {
path.rsplit('/').next().is_some_and(|seg| seg.contains('.'))
}
fn not_found() -> Response {
(
StatusCode::NOT_FOUND,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
Body::from("not found"),
)
.into_response()
}
fn html_response(bytes: &[u8]) -> Response {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
bytes.to_vec(),
)
.into_response()
}
fn placeholder() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
Body::from(PLACEHOLDER_HTML),
)
.into_response()
}
const PLACEHOLDER_HTML: &str = r#"<!doctype html>
<meta charset="utf-8">
<title>maestro — bundle not built</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em; line-height: 1.5; }
code { background: #f3f3f3; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
<h1>maestro is running</h1>
<p>but the frontend bundle hasn't been built yet. Run:</p>
<pre><code>pnpm -C web install
pnpm -C web build
cargo build -p maestro --release</code></pre>
<p>or, from the repo root: <code>just web</code>.</p>
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholder_is_html() {
let resp = serve_index();
// Either the real index or the placeholder — both must be HTML.
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(ct.starts_with("text/html"), "ct={ct}");
}
#[test]
fn unknown_extensionless_route_falls_back_to_index() {
let resp = serve_path("/some/missing/route");
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"), "ct={ct}");
}
#[test]
fn missing_hashed_asset_404s_instead_of_serving_html() {
// A stale shell requesting an old asset hash must get 404, not the
// HTML shell — otherwise the browser executes HTML as JS and blanks.
let resp = serve_path("/assets/index-deadbeef.js");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(!ct.starts_with("text/html"), "ct={ct}");
}
#[test]
fn index_is_sent_no_cache() {
let cc = serve_index()
.headers()
.get(header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
assert_eq!(cc.as_deref(), Some("no-cache"));
}
#[test]
fn looks_like_file_distinguishes_assets_from_routes() {
assert!(looks_like_file("assets/index-abc123.js"));
assert!(looks_like_file("favicon.ico"));
assert!(!looks_like_file("sessions/foo"));
assert!(!looks_like_file("some/missing/route"));
}
}