feat(web): SPA skeleton embedded in the binary (#60)
This commit was merged in pull request #60.
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
"#;
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user