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:
co-authored by
Claude Sonnet 5
parent
1d1b0eee01
commit
7694dcf120
Generated
+1
@@ -650,6 +650,7 @@ dependencies = [
|
|||||||
"fs-embed",
|
"fs-embed",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"humantime",
|
"humantime",
|
||||||
|
"include_dir",
|
||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ chrono = "0.4"
|
|||||||
humantime = "2"
|
humantime = "2"
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
fs-embed = "0.2.4"
|
fs-embed = "0.2.4"
|
||||||
|
include_dir = "0.7.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -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
|
# Architecture
|
||||||
|
|
||||||
ottyl reads the logs via stdin and indexes it in-memory in a BTreeMap for property search.
|
ottyl reads the logs via stdin and indexes it in-memory in a BTreeMap for property search.
|
||||||
|
|||||||
+43
-23
@@ -16,11 +16,13 @@ use crate::profile::{self, Profile, ProfileError, ProfileStore};
|
|||||||
use crate::settings::{CssError, Settings};
|
use crate::settings::{CssError, Settings};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::response::{IntoResponse, Json};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use fs_embed::Dir as EmbedDir;
|
||||||
use tokio::sync::oneshot;
|
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
|
/// Shared, process-wide settings handle. Cheap to clone; lock is only held
|
||||||
/// briefly since settings changes are rare (unlike the engine's hot path).
|
/// briefly since settings changes are rare (unlike the engine's hot path).
|
||||||
@@ -86,19 +88,12 @@ impl ApiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(
|
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
|
||||||
engine: EngineHandle,
|
|
||||||
static_dir: &str,
|
|
||||||
settings: SettingsHandle,
|
|
||||||
settings_path: PathBuf,
|
|
||||||
) -> Router {
|
|
||||||
let state = ApiState {
|
let state = ApiState {
|
||||||
engine,
|
engine,
|
||||||
settings,
|
settings,
|
||||||
settings_path: Arc::new(settings_path),
|
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()
|
Router::new()
|
||||||
.route("/api/logs", get(get_logs))
|
.route("/api/logs", get(get_logs))
|
||||||
@@ -125,7 +120,39 @@ pub fn router(
|
|||||||
get(get_dashboard).put(save_dashboard).delete(delete_dashboard),
|
get(get_dashboard).put(save_dashboard).delete(delete_dashboard),
|
||||||
)
|
)
|
||||||
.with_state(state)
|
.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> {
|
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
|
||||||
@@ -725,10 +752,10 @@ mod tests {
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
/// Spins up a fresh engine + router pair, backed by a temp settings file
|
/// 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
|
/// for one test. Returns the router, the engine handle (so a test can
|
||||||
/// handle (so a test can ingest lines directly without an HTTP route for
|
/// ingest lines directly without an HTTP route for it), and the
|
||||||
/// it), and the `TempDir` guard (kept alive for the test's duration).
|
/// `TempDir` guard (kept alive for the test's duration).
|
||||||
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
||||||
let (engine_tx, engine_rx) = mpsc::channel(128);
|
let (engine_tx, engine_rx) = mpsc::channel(128);
|
||||||
tokio::spawn(engine::run(engine_rx));
|
tokio::spawn(engine::run(engine_rx));
|
||||||
@@ -736,15 +763,8 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let settings_path = dir.path().join("settings.json");
|
let settings_path = dir.path().join("settings.json");
|
||||||
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
|
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(
|
let app = router(engine_tx.clone(), settings.clone(), settings_path);
|
||||||
engine_tx.clone(),
|
|
||||||
static_dir.to_str().unwrap(),
|
|
||||||
settings.clone(),
|
|
||||||
settings_path,
|
|
||||||
);
|
|
||||||
(app, engine_tx, settings, dir)
|
(app, engine_tx, settings, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -30,10 +30,6 @@ struct Args {
|
|||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
port: u16,
|
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
|
/// Path to the settings file. Defaults to a per-OS config location
|
||||||
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
|
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
|
||||||
#[arg(long)]
|
#[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))
|
let listener = TcpListener::bind(("0.0.0.0", args.port))
|
||||||
.await
|
.await
|
||||||
.expect("failed to bind listener");
|
.expect("failed to bind listener");
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user