//! 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#"
but the frontend bundle hasn't been built yet. Run:
pnpm -C web install
pnpm -C web build
cargo build -p maestro-web --release
or, from the repo root: just web.