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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 16:21:02 +01:00
co-authored by Claude Sonnet 5
parent 1d1b0eee01
commit 7694dcf120
6 changed files with 57 additions and 65 deletions
+43 -23
View File
@@ -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<EngineResponse, StatusCode> {
@@ -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)
}