Ships `maestro`, `maestro-web` and the SPA bundle as flake packages, plus a `services.maestro` Home Manager module that runs the daemon and the web UI as systemd user services. `maestro-web`'s build.rs now honours MAESTRO_WEB_DIST and hands the resolved bundle path to `include_dir!` through MAESTRO_WEB_BUNDLE_DIR. Without it the Nix build would have to run `pnpm install` mid-compile, which the sandbox has no network for. Unset, the pnpm path is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
6.4 KiB
Rust
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-web — 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-web 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-web --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"));
|
|
}
|
|
}
|