Key changes: - **src/config.rs** – `LiveVueConfig` (server host/port, brotli quality + window size), loaded from `livevue.toml`. A notify-based file watcher reloads the config on any file change; a SIGHUP handler does the same on demand. Falls back to compiled-in defaults when the file is absent. - **src/brotli_layer.rs** – `BrotliBody`, a custom `http_body::Body` wrapper that streams brotli-compressed data through a single persistent `brotli::CompressorWriter`. The encoder survives across SSE event flushes, so its sliding window accumulates context from all prior events — progressively better compression as the stream grows. Both quality (0–11) and window size (lgwin 10–24, i.e. 1 KB – 16 MB) are taken from config. `brotli_compression` is an axum `from_fn_with_state` middleware that activates only when the client sends `Accept-Encoding: br`. - **src/server.rs** – `AppState` gains a `config: SharedConfig` field. `AppState::new` takes the config; `AppState::new_default` is a zero-config convenience constructor. - **livevue.toml** – documented example config with inline comments explaining each field and the brotli window-size tradeoff table. - **examples/todo** – loads config via `load_and_watch_config`, derives the bind address from `config.server`, and applies `brotli_compression` as a router layer. https://claude.ai/code/session_01UnQQkkwts64FPUsSzFfdQb
219 lines
7.8 KiB
Rust
219 lines
7.8 KiB
Rust
use livevue_rs::{
|
|
ComponentTree, RenderContext, SharedConfig, SharedState, SignalStore,
|
|
brotli_compression, ingest_signals, sse_handler,
|
|
};
|
|
|
|
use axum::extract::{Query as AxumQuery, State};
|
|
use axum::middleware;
|
|
use axum::response::{Html, IntoResponse};
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use axum::http::StatusCode;
|
|
use uuid::Uuid;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn router(state: SharedState, config: SharedConfig) -> Router {
|
|
Router::new()
|
|
.route("/", get(initial_page_load))
|
|
.route("/about", get(initial_about_load))
|
|
.route("/sse", get(sse_handler))
|
|
.route("/action/add_todo", post(action_add_todo))
|
|
.route("/action/toggle_todo", post(action_toggle_todo))
|
|
.route("/action/delete_todo", post(action_delete_todo))
|
|
.layer(middleware::from_fn_with_state(
|
|
config,
|
|
brotli_compression,
|
|
))
|
|
.with_state(state)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Document shell
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn document_shell(conn_id: Uuid) -> String {
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>LiveVue.rs — Todo</title>
|
|
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js"></script>
|
|
<style>
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body
|
|
data-init="@get('/sse?conn={conn}')">
|
|
<div id="app">
|
|
<div/>
|
|
</body>
|
|
</html>"#,
|
|
css = INLINE_CSS,
|
|
conn = conn_id,
|
|
)
|
|
}
|
|
|
|
/// Used by the fanout task to wrap rendered inner HTML for morphing.
|
|
pub fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String {
|
|
format!(
|
|
r#"<div id="app"
|
|
data-signals='{signals}'>
|
|
{inner}
|
|
<pre data-json-signals></pre>
|
|
</div>"#,
|
|
signals = signals,
|
|
inner = inner_html,
|
|
)
|
|
}
|
|
|
|
const INLINE_CSS: &str = r#"
|
|
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fafafa; color: #333; }
|
|
h1 { color: #2563eb; }
|
|
nav { margin-bottom: 1.5rem; }
|
|
.nav-link { background: none; border: none; color: #2563eb; cursor: pointer; text-decoration: underline; font-size: inherit; padding: 0; }
|
|
.nav-current { font-weight: bold; }
|
|
.clock { font-variant-numeric: tabular-nums; color: #555; font-size: 0.95rem; margin-bottom: 1rem; }
|
|
.add-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
.add-form input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
|
.add-form button { padding: 0.5rem 1rem; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
.add-form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.stats { color: #666; font-size: 0.9rem; }
|
|
.todo-list { list-style: none; padding: 0; }
|
|
.todo-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #eee; }
|
|
.todo-title { flex: 1; }
|
|
.todo-title.completed { text-decoration: line-through; color: #999; }
|
|
.delete-btn { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 1.2rem; padding: 0 0.25rem; }
|
|
.empty { color: #999; font-style: italic; }
|
|
.about-content p { line-height: 1.6; }
|
|
"#;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page resolution + render pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
|
let markup = match cx.tree.page.as_str() {
|
|
"TodoList" => crate::todo_list::todo_list(cx).await?,
|
|
"About" => crate::about::about(cx).await?,
|
|
other => crate::todo_list::todo_list(cx).await?
|
|
};
|
|
Ok(markup.into_string())
|
|
}
|
|
|
|
fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) {
|
|
let conn_id = signals
|
|
.get("connId")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or_else(Uuid::new_v4);
|
|
|
|
let tree = signals
|
|
.get("tree")
|
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
.unwrap_or_default();
|
|
|
|
(conn_id, tree)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initial page load handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn initial_page_load(State(state): State<SharedState>) -> impl IntoResponse {
|
|
let conn_id = Uuid::new_v4();
|
|
|
|
// Seed the initial signal state so the SSE handler can trigger the first
|
|
// render as soon as the pipe opens, without a client round-trip.
|
|
let tree = ComponentTree { page: "TodoList".into(), ..Default::default() };
|
|
let signals = SignalStore::from_pairs(vec![
|
|
("connId".into(), serde_json::json!(conn_id.to_string())),
|
|
("tree".into(), serde_json::to_value(&tree).unwrap()),
|
|
("newTodo".into(), serde_json::json!("")),
|
|
]);
|
|
state.connections.update_signals(&conn_id, signals.to_json_string());
|
|
|
|
Html(document_shell(conn_id))
|
|
}
|
|
|
|
async fn initial_about_load(State(state): State<SharedState>) -> impl IntoResponse {
|
|
let conn_id = Uuid::new_v4();
|
|
let tree = ComponentTree { page: "About".into(), ..Default::default() };
|
|
let signals = SignalStore::from_pairs(vec![
|
|
("connId".into(), serde_json::json!(conn_id.to_string())),
|
|
("tree".into(), serde_json::to_value(&tree).unwrap()),
|
|
]);
|
|
state.connections.update_signals(&conn_id, signals.to_json_string());
|
|
Html(document_shell(conn_id))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Action handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct ActionIdParams {
|
|
id: i64,
|
|
}
|
|
|
|
async fn action_add_todo(
|
|
State(state): State<SharedState>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
|
ingest_signals(&state, conn_id, body.clone());
|
|
|
|
let title = body
|
|
.get("newTodo")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_string();
|
|
|
|
if title.is_empty() {
|
|
return StatusCode::ACCEPTED;
|
|
}
|
|
|
|
if let Err(e) = state.store.exec(crate::queries::CreateTodo { title }).await {
|
|
eprintln!("action_add_todo error: {e}");
|
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
|
}
|
|
|
|
StatusCode::ACCEPTED
|
|
}
|
|
|
|
async fn action_toggle_todo(
|
|
State(state): State<SharedState>,
|
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
|
ingest_signals(&state, conn_id, body);
|
|
|
|
if let Err(e) = state.store.exec(crate::queries::ToggleTodo { id: params.id }).await {
|
|
eprintln!("action_toggle_todo error: {e}");
|
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
|
}
|
|
|
|
StatusCode::ACCEPTED
|
|
}
|
|
|
|
async fn action_delete_todo(
|
|
State(state): State<SharedState>,
|
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
|
ingest_signals(&state, conn_id, body);
|
|
|
|
if let Err(e) = state.store.exec(crate::queries::DeleteTodo { id: params.id }).await {
|
|
eprintln!("action_delete_todo error: {e}");
|
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
|
}
|
|
|
|
StatusCode::ACCEPTED
|
|
} |