feat(web): SPA skeleton embedded in the binary (#60)
ci / web (push) Successful in 33s
ci / rust (push) Successful in 57s
e2e / e2e (push) Successful in 1m7s

This commit was merged in pull request #60.
This commit is contained in:
2026-08-22 21:07:40 +01:00
parent 94c334ffe9
commit 514628f089
22 changed files with 2277 additions and 2 deletions
+2
View File
@@ -16,6 +16,8 @@ arr-compat = { workspace = true }
arr-db = { workspace = true }
arr-meta = { workspace = true }
axum = { workspace = true }
include_dir = { workspace = true }
mime_guess = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
+127
View File
@@ -0,0 +1,127 @@
//! Resolve the SPA bundle embedded by `web.rs` via `include_dir!`.
//!
//! Three modes, in order:
//!
//! * `ARR_WEB_DIST` set — a prebuilt bundle (Nix, CI, release pipeline) is
//! embedded as-is. Missing or empty means a broken pipeline: fail.
//! * pnpm available — the default developer path: build `web/` and embed
//! `web/dist`. Failures in the build itself fail loudly.
//! * pnpm missing, debug profile — embed a generated placeholder page and
//! warn. This keeps `cargo clippy`/`cargo test` working on runners with no
//! node toolchain (the CI rust job, DESIGN.md §12).
//!
//! A release build never gets the placeholder: shipping a binary whose UI is
//! an apology defeats the single-binary acceptance test, so that combination
//! panics instead.
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=ARR_WEB_DIST");
let dist = match env::var_os("ARR_WEB_DIST") {
Some(prebuilt) => PathBuf::from(prebuilt),
None => build_or_placeholder(),
};
let index = dist.join("index.html");
let bundled = std::fs::metadata(&index).is_ok_and(|m| m.is_file() && m.len() > 0);
assert!(
bundled,
"no SPA bundle at {} (missing or empty index.html) — check the vite \
outDir or the ARR_WEB_DIST override",
index.display()
);
// `include_dir!` expands `$VAR` against the env rustc is invoked with,
// which is what `cargo:rustc-env` sets.
println!("cargo:rustc-env=ARR_WEB_BUNDLE_DIR={}", dist.display());
}
fn build_or_placeholder() -> PathBuf {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"));
let web_dir = manifest_dir.join("../../web");
// Rebuild only when a frontend input changes, not on unrelated Rust
// recompiles. The dist watch matters too: the inputs decide whether this
// script reruns, but `include_dir!` reads dist, and without this watch an
// incremental build can regenerate dist yet keep the old bytes embedded.
for rel in [
"src",
"index.html",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"vite.config.ts",
"tsconfig.json",
"dist",
] {
println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
}
if !tool_works("pnpm") {
let profile = env::var("PROFILE").unwrap_or_default();
assert!(
profile != "release",
"arr embeds the web SPA at build time and needs pnpm + node for a \
release build. Install them, or point ARR_WEB_DIST at a prebuilt \
bundle."
);
println!(
"cargo:warning=pnpm not found; embedding a placeholder page instead of \
the web SPA (debug builds only)"
);
return write_placeholder();
}
run(
Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&web_dir),
"pnpm install --frozen-lockfile",
);
run(
Command::new("pnpm").arg("build").current_dir(&web_dir),
"pnpm build",
);
web_dir.join("dist")
}
fn write_placeholder() -> PathBuf {
let out = PathBuf::from(env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("placeholder");
std::fs::create_dir_all(&out).expect("create placeholder dir");
std::fs::write(out.join("index.html"), PLACEHOLDER_HTML).expect("write placeholder");
out
}
fn tool_works(tool: &str) -> bool {
Command::new(tool)
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
fn run(cmd: &mut Command, label: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
assert!(status.success(), "`{label}` failed with {status}");
}
const PLACEHOLDER_HTML: &str = r#"<!doctype html>
<meta charset="utf-8">
<title>arr — 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: #eee; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
<h1>arr is running</h1>
<p>but this binary was built without the web bundle (no pnpm on the build
machine). Rebuild with pnpm + node installed, or set <code>ARR_WEB_DIST</code>
to a prebuilt bundle.</p>
"#;
+2
View File
@@ -1,6 +1,7 @@
//! arr — reconcile loop and process entry point. See DESIGN.md §8.
mod config;
mod web;
use std::process::ExitCode;
use std::sync::Arc;
@@ -94,6 +95,7 @@ async fn run() -> Result<(), Error> {
let app = arr_api::router(state)
.merge(arr_compat::router(compat))
.fallback(web::serve)
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(config.bind_addr)
+137
View File
@@ -0,0 +1,137 @@
//! The embedded SPA. See DESIGN.md §11: `web/` is built by Vite and compiled
//! into this binary via `include_dir`, so `cargo run` serves the whole UI
//! with no external files. `build.rs` resolves which directory gets embedded.
use axum::http::{header, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir};
static BUNDLE: Dir<'_> = include_dir!("$ARR_WEB_BUNDLE_DIR");
/// Fallback handler for everything the API router did not claim.
pub async fn serve(uri: Uri) -> Response {
serve_path(uri.path())
}
/// Serve a path from the bundle.
///
/// Content-hashed files under `assets/` are immutable, so they get a year of
/// `immutable` cache. The shell and everything else is `no-cache`: the shell
/// references the current hashed asset names and a stale cached copy would
/// point at files that no longer exist after a rebuild.
///
/// A miss falls back to the shell only for extensionless paths (client-side
/// routes). A miss that names a file — a stale `assets/index-OLD.js` — is a
/// real 404: serving HTML where the browser expects JS blanks the page. And
/// `/api/*` never falls through to HTML; an unknown API path is a 404.
fn serve_path(path: &str) -> Response {
let trimmed = path.trim_start_matches('/');
if trimmed == "api" || trimmed.starts_with("api/") {
return not_found();
}
if trimmed.is_empty() {
return serve_index();
}
match BUNDLE.get_file(trimmed) {
Some(file) => {
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),
],
file.contents(),
)
.into_response()
}
None if names_a_file(trimmed) => not_found(),
None => serve_index(),
}
}
fn serve_index() -> Response {
match BUNDLE.get_file("index.html") {
Some(file) => (
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
file.contents(),
)
.into_response(),
// build.rs guarantees index.html exists in whatever it embedded.
None => not_found(),
}
}
/// Whether the last segment carries an extension, i.e. names a concrete file
/// rather than a client-side route.
fn names_a_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")],
"not found",
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn content_type(response: &Response) -> String {
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned()
}
#[test]
fn the_shell_is_embedded_html() {
let response = serve_path("/");
assert_eq!(response.status(), StatusCode::OK);
assert!(content_type(&response).starts_with("text/html"));
}
#[test]
fn an_extensionless_route_falls_back_to_the_shell() {
let response = serve_path("/movies/42");
assert_eq!(response.status(), StatusCode::OK);
assert!(content_type(&response).starts_with("text/html"));
}
#[test]
fn a_missing_hashed_asset_is_a_404_never_html() {
let response = serve_path("/assets/index-deadbeef.js");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(!content_type(&response).starts_with("text/html"));
}
#[test]
fn unknown_api_paths_never_fall_through_to_html() {
let response = serve_path("/api/nope");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(!content_type(&response).starts_with("text/html"));
}
#[test]
fn the_shell_is_never_cached() {
let cache = serve_path("/")
.headers()
.get(header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
assert_eq!(cache.as_deref(), Some("no-cache"));
}
}