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-web/src/static_bundle.rs
T
Miguel PalhasandClaude Opus 5 401afbc812 feat(maestro): add a nix flake and Home Manager module
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>
2026-08-22 10:53:40 +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-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"));
}
}