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
119 lines
4.5 KiB
Rust
119 lines
4.5 KiB
Rust
mod app;
|
|
mod queries;
|
|
mod todo_list;
|
|
mod about;
|
|
|
|
use livevue_rs::{AppState, RenderContext, Store, load_and_watch_config, spawn_fanout};
|
|
use crate::app::{app_wrapper, resolve_and_render};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// -----------------------------------------------------------------------
|
|
// Database setup
|
|
// -----------------------------------------------------------------------
|
|
|
|
let db = sqlx::SqlitePool::connect("sqlite:todo.db?mode=rwc").await?;
|
|
|
|
sqlx::query(
|
|
"CREATE TABLE IF NOT EXISTS todos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
completed BOOLEAN NOT NULL DEFAULT FALSE
|
|
)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM todos")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
if count.0 == 0 {
|
|
for title in &["Learn Rust", "Build LiveVue.rs", "Ship v0"] {
|
|
sqlx::query("INSERT INTO todos (title, completed) VALUES (?, false)")
|
|
.bind(title)
|
|
.execute(&db)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Store
|
|
// -----------------------------------------------------------------------
|
|
|
|
let store = Store::new(db);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Render function
|
|
// -----------------------------------------------------------------------
|
|
|
|
// The render_fn is the app's half of the framework contract. It receives
|
|
// a RenderContext (with signals, tree, store access) and returns the
|
|
// rendered inner HTML plus the accumulated subscription keys.
|
|
//
|
|
// The fanout task calls this for every server-initiated rerender.
|
|
let render_fn: livevue_rs::RenderFn = Arc::new(|mut cx: RenderContext| {
|
|
Box::pin(async move {
|
|
println!("Render Called");
|
|
let signals_json = cx.signals.to_json_string();
|
|
let conn_id = cx.connection_id;
|
|
let html = resolve_and_render(&mut cx).await?;
|
|
let keys = cx.take_subscription_keys();
|
|
// Wrap in app_wrapper so DataStar morphs the full #app element.
|
|
// Signals are re-emitted from the stored json so the client stays
|
|
// in sync even on server-initiated rerenders.
|
|
let wrapped = app_wrapper(conn_id, &signals_json, &html);
|
|
Ok((wrapped, keys))
|
|
})
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Configuration (livevue.toml, hot-reloaded on change or SIGHUP)
|
|
// -----------------------------------------------------------------------
|
|
|
|
let config = load_and_watch_config("livevue.toml")?;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Application state
|
|
// -----------------------------------------------------------------------
|
|
|
|
let state = AppState::new(store.clone(), render_fn, config.clone());
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Fanout task
|
|
// -----------------------------------------------------------------------
|
|
|
|
spawn_fanout(state.clone());
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Clock ticker
|
|
//
|
|
// Publishes CacheKey::Channel { key: "clock" } every second. Any
|
|
// connection whose last render called cx.kv_run("clock") will be
|
|
// scheduled for a rerender by the fanout task.
|
|
// -----------------------------------------------------------------------
|
|
|
|
let clock_store = store.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
|
loop {
|
|
interval.tick().await;
|
|
let now = chrono::Utc::now().format("%H:%M:%S UTC").to_string();
|
|
// set() writes the value and publishes the invalidation event.
|
|
clock_store.kv.set("clock", serde_json::json!(now));
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Server
|
|
// -----------------------------------------------------------------------
|
|
|
|
let bind_addr = config.read().await.server.bind_addr();
|
|
let app = app::router(state, config);
|
|
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
|
|
println!("Todo example listening on http://{bind_addr}");
|
|
axum::serve(listener, app).await?;
|
|
|
|
Ok(())
|
|
} |