From 7694dcf120867bc0cd4f64faa5cb0c6c8f8b0e35 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 16:21:02 +0100 Subject: [PATCH] Embed webapp into the binary with fs-embed, drop --static-dir ottyl was requiring webapp/dist to be shipped alongside the binary and served via ServeDir/ServeFile pointed at a --static-dir CLI arg. Embed the built webapp at compile time instead, so ottyl is a single self-contained executable with no runtime static directory dependency. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 11 +++++ src/api.rs | 66 ++++++++++++++++--------- src/main.rs | 6 +-- tickets/028-fs-embed-static-bundling.md | 37 -------------- 6 files changed, 57 insertions(+), 65 deletions(-) delete mode 100644 tickets/028-fs-embed-static-bundling.md diff --git a/Cargo.lock b/Cargo.lock index 939029a..b2fef05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,7 @@ dependencies = [ "fs-embed", "http-body-util", "humantime", + "include_dir", "pest", "pest_derive", "regex", diff --git a/Cargo.toml b/Cargo.toml index badeb38..da8d52e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ chrono = "0.4" humantime = "2" directories = "6.0.0" fs-embed = "0.2.4" +include_dir = "0.7.4" [dev-dependencies] tempfile = "3.27.0" diff --git a/README.md b/README.md index 87050cf..885f5c3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,17 @@ cli_command --verbose | ottyl ``` +# Building + +The webapp is embedded into the `ottyl` binary at compile time, so `webapp/dist` must exist *before* running `cargo build`: + +```bash +cd webapp && npm install && npm run build && cd .. +cargo build --release +``` + +The resulting binary is self-contained — no `webapp/dist` directory or `--static-dir` flag is needed at runtime. + # Architecture ottyl reads the logs via stdin and indexes it in-memory in a BTreeMap for property search. diff --git a/src/api.rs b/src/api.rs index ac0c825..405c49b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -16,11 +16,13 @@ use crate::profile::{self, Profile, ProfileError, ProfileStore}; use crate::settings::{CssError, Settings}; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::{header, StatusCode}; -use axum::response::{IntoResponse, Json}; +use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; use axum::Router; +use fs_embed::Dir as EmbedDir; use tokio::sync::oneshot; -use tower_http::services::{ServeDir, ServeFile}; + +static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist"); /// Shared, process-wide settings handle. Cheap to clone; lock is only held /// briefly since settings changes are rare (unlike the engine's hot path). @@ -86,19 +88,12 @@ impl ApiState { } } -pub fn router( - engine: EngineHandle, - static_dir: &str, - settings: SettingsHandle, - settings_path: PathBuf, -) -> Router { +pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router { let state = ApiState { engine, settings, settings_path: Arc::new(settings_path), }; - let index_html = format!("{static_dir}/index.html"); - let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html)); Router::new() .route("/api/logs", get(get_logs)) @@ -125,7 +120,39 @@ pub fn router( get(get_dashboard).put(save_dashboard).delete(delete_dashboard), ) .with_state(state) - .fallback_service(static_service) + .fallback(serve_webapp) +} + +/// Serves the webapp embedded in the binary (see `WEBAPP_DIST`), falling +/// back to `index.html` for any unmatched path so client-side routing works. +async fn serve_webapp(uri: axum::http::Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let file = WEBAPP_DIST + .get_file(path) + .or_else(|| WEBAPP_DIST.get_file("index.html")); + match file { + Some(file) => { + let mime = mime_for_extension(file.extension().unwrap_or("")); + let bytes = file.read_bytes().unwrap_or_default(); + ([(header::CONTENT_TYPE, mime)], bytes).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} + +fn mime_for_extension(extension: &str) -> &'static str { + match extension { + "html" => "text/html; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "svg" => "image/svg+xml", + "json" => "application/json", + "png" => "image/png", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + _ => "application/octet-stream", + } } async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result { @@ -725,10 +752,10 @@ mod tests { use tokio::sync::mpsc; use tower::ServiceExt; - /// Spins up a fresh engine + router pair, backed by a temp settings file - /// (and empty static dir), for one test. Returns the router, the engine - /// handle (so a test can ingest lines directly without an HTTP route for - /// it), and the `TempDir` guard (kept alive for the test's duration). + /// Spins up a fresh engine + router pair, backed by a temp settings file, + /// for one test. Returns the router, the engine handle (so a test can + /// ingest lines directly without an HTTP route for it), and the + /// `TempDir` guard (kept alive for the test's duration). fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) { let (engine_tx, engine_rx) = mpsc::channel(128); tokio::spawn(engine::run(engine_rx)); @@ -736,15 +763,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let settings = Arc::new(Mutex::new(Settings::load(&settings_path))); - let static_dir = dir.path().join("static"); - std::fs::create_dir_all(&static_dir).unwrap(); - let app = router( - engine_tx.clone(), - static_dir.to_str().unwrap(), - settings.clone(), - settings_path, - ); + let app = router(engine_tx.clone(), settings.clone(), settings_path); (app, engine_tx, settings, dir) } diff --git a/src/main.rs b/src/main.rs index d500cff..cb14529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,10 +30,6 @@ struct Args { #[arg(long, default_value_t = 0)] port: u16, - /// Directory containing the built webapp (index.html + assets). - #[arg(long, default_value = "webapp/dist")] - static_dir: String, - /// Path to the settings file. Defaults to a per-OS config location /// (e.g. `~/.config/ottyl/settings.json` on Linux). #[arg(long)] @@ -105,7 +101,7 @@ async fn main() { } } - let app = api::router(engine_tx, &args.static_dir, settings, settings_path); + let app = api::router(engine_tx, settings, settings_path); let listener = TcpListener::bind(("0.0.0.0", args.port)) .await .expect("failed to bind listener"); diff --git a/tickets/028-fs-embed-static-bundling.md b/tickets/028-fs-embed-static-bundling.md deleted file mode 100644 index bb16e2e..0000000 --- a/tickets/028-fs-embed-static-bundling.md +++ /dev/null @@ -1,37 +0,0 @@ -# 028 — Bundle the webapp with fs-embed, drop `--static-dir` - -## Summary - -`fs-embed` is already a `Cargo.toml` dependency but unused (`grep -rn -"fs_embed" src/` turns up nothing) — `src/api.rs` still serves the webapp via -`ServeDir`/`ServeFile` pointed at a `--static-dir` CLI arg -(`src/main.rs:35`), which requires shipping `webapp/dist` alongside the -binary. Embed the built webapp into the binary instead so `ottyl` is a single -self-contained executable. - -## Scope - -- Use `fs-embed` to embed `webapp/dist` at build time. -- Wire the embedded filesystem into `src/api.rs` in place of the - `ServeDir::new(static_dir).fallback(ServeFile::new(index_html))` setup - (`src/api.rs:100-101`), preserving the SPA fallback-to-`index.html` - behavior. -- Remove the `--static-dir` clap arg (`src/main.rs:30-35`) and its plumbing - through `api::router` (`src/api.rs:91`, `src/main.rs:108`). -- Update the test helper that builds a router against a temp static dir - (`src/api.rs:729-744`) to match the new embedding approach. -- `webapp/dist` must exist at `cargo build` time (i.e. the webapp build step - stays a prerequisite) — document that in the README's build instructions if - not already covered. - -## Acceptance criteria - -- `cargo build` (with `webapp/dist` present) produces a binary that serves - the webapp with no `--static-dir` flag and no `webapp/dist` directory - needed at runtime. -- `--static-dir` no longer appears in `--help` output or `Args`. -- Existing API tests pass against the embedded-filesystem router. - -## Depends on - -None.