bodged refactor

This commit is contained in:
2026-03-08 20:10:15 +01:00
parent 71e63e4410
commit b10326b242
12 changed files with 801 additions and 339 deletions
+51 -159
View File
@@ -1,6 +1,6 @@
use livevue_rs::{
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
format_patch_elements, sse_handler,
ingest_signals, sse_handler,
};
use axum::extract::{Query as AxumQuery, State};
@@ -19,7 +19,6 @@ pub fn router(state: SharedState) -> Router {
.route("/", get(initial_page_load))
.route("/about", get(initial_about_load))
.route("/sse", get(sse_handler))
.route("/render", post(render_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))
@@ -30,14 +29,7 @@ pub fn router(state: SharedState) -> Router {
// Document shell
// ---------------------------------------------------------------------------
/// Full HTML document for initial page load.
///
/// DataStar v1 contract:
/// - `data-signals` — initialize signals (JS object literal syntax)
/// - `data-on-signal-patch__debounce.300ms` — fires when any signal is patched,
/// debounced 300ms, posts all non-_ signals as JSON body to /render
/// - `data-init` — fires when element enters DOM, opens long-lived SSE pipe
fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> String {
fn document_shell(conn_id: Uuid) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
@@ -45,47 +37,42 @@ fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> Str
<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@v1.0.0-RC.8/bundles/datastar.js"></script>
<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>
{app}
<body
data-init="@get('/sse?conn={conn}')">
<div id="app">
<div/>
</body>
</html>"#,
css = INLINE_CSS,
app = app_wrapper(conn_id, initial_signals, inner_html),
conn = conn_id,
)
}
/// The `<div id="app">` wrapper with DataStar bindings.
///
/// Extracted separately so `/render` can return just this element as
/// `text/html` — DataStar morphs it into the existing DOM by id.
fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String {
/// 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}'
data-on-signal-patch="@post('/render')"
data-on-init="@get('/sse?conn={conn}')">
{inner}
data-signals='{signals}'>
{inner}
<pre data-json-signals></pre>
</div>"#,
// Could __debounce.300ms on the signal patch
</div>"#,
signals = signals,
conn = conn_id,
inner = inner_html,
)
}
/// Minimal CSS for the todo app.
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; }
@@ -101,42 +88,18 @@ const INLINE_CSS: &str = r#"
"#;
// ---------------------------------------------------------------------------
// Page resolution
// Page resolution + render pipeline
// ---------------------------------------------------------------------------
async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
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 => maud::html! {
div {
h1 { "404 — Unknown page" }
p { "No component registered for: " (other) }
button.nav-link
data-on-click="$tree = {page: 'TodoList'}; @post('/render')" {
"Go to Todos"
}
}
},
other => crate::todo_list::todo_list(cx).await?
};
Ok(markup.into_string())
}
/// Shared render pipeline. Returns `(rendered_inner_html, subscription_keys, signals_json)`.
async fn render_page(
db: &sqlx::SqlitePool,
conn_id: Uuid,
signals: SignalStore,
tree: ComponentTree,
) -> anyhow::Result<(String, Vec<CacheKey>, String)> {
let signals_json = signals.to_json_string();
let mut cx = RenderContext::new(conn_id, signals, tree, db.clone());
let html = resolve_and_render(&mut cx).await?;
let keys = cx.take_subscription_keys();
Ok((html, keys, signals_json))
}
/// Extract conn_id and tree from a signal store.
fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) {
let conn_id = signals
.get("connId")
@@ -153,133 +116,65 @@ fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) {
}
// ---------------------------------------------------------------------------
// Handlers
// Initial page load handlers
// ---------------------------------------------------------------------------
/// `GET /` — Initial page load for the todo list.
async fn initial_page_load(State(state): State<SharedState>) -> impl IntoResponse {
let conn_id = Uuid::new_v4();
let tree = ComponentTree {
page: "TodoList".into(),
..Default::default()
};
// 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());
let initial_signals_attr = signals.to_data_signals_attr();
match render_page(&state.db, conn_id, signals, tree).await {
Ok((html, _keys, _signals_json)) => {
Html(document_shell(conn_id, &initial_signals_attr, &html))
}
Err(e) => Html(document_shell(
conn_id,
&initial_signals_attr,
&format!("<p>Render error: {}</p>", e),
)),
}
Html(document_shell(conn_id))
}
/// `GET /about` — Initial page load for the about page.
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 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()),
]);
let initial_signals_attr = signals.to_data_signals_attr();
match render_page(&state.db, conn_id, signals, tree).await {
Ok((html, _keys, _signals_json)) => {
Html(document_shell(conn_id, &initial_signals_attr, &html))
}
Err(e) => Html(document_shell(
conn_id,
&initial_signals_attr,
&format!("<p>Render error: {}</p>", e),
)),
}
state.connections.update_signals(&conn_id, signals.to_json_string());
Html(document_shell(conn_id))
}
/// `POST /render` — Signal-triggered rerender.
///
/// DataStar sends all non-`_` signals as JSON body on every signal patch.
/// Returns `text/html` with the full `<div id="app">` wrapper — DataStar
/// morphs it into the existing DOM by matching the element id.
async fn render_handler(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let signals = SignalStore::from_json(body);
let (conn_id, tree) = extract_conn_and_tree(&signals);
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------
match render_page(&state.db, conn_id, signals.clone(), tree).await {
Ok((html, keys, signals_json)) => {
state.connections.update_signals(&conn_id, signals_json.clone());
state.subscriptions.update(conn_id, keys);
Html(app_wrapper(conn_id, &signals_json, &html))
}
Err(e) => Html(app_wrapper(
conn_id,
&signals.to_json_string(),
&format!("<p>Render error: {}</p>", e),
)),
}
}
/// Query params for action endpoints that need an entity id.
#[derive(serde::Deserialize)]
struct ActionIdParams {
id: i64,
}
/// Helper: run a mutation, push the rerendered page down the SSE pipe, return 200.
///
/// Actions do not return a body — the result comes down the SSE pipe as a
/// `datastar-patch-elements` event.
async fn action_then_push(
/// Ingest signals, run a mutation, then publish an invalidation event so the
/// fanout task handles the rerender. Returns 202 Accepted.
async fn action_then_publish(
state: &AppState,
signals: SignalStore,
body: serde_json::Value,
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
invalidation_key: CacheKey,
) -> StatusCode {
let (conn_id, tree) = extract_conn_and_tree(&signals);
let signals = ingest_signals(state, extract_conn_and_tree(&SignalStore::from_json(body.clone())).0, body);
let _ = signals; // signals are stored; mutation drives the rerender
if let Err(e) = mutation.await {
eprintln!("Action error for {}: {}", conn_id, e);
eprintln!("Action mutation error: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR;
}
match render_page(&state.db, conn_id, signals, tree).await {
Ok((html, keys, signals_json)) => {
state.connections.update_signals(&conn_id, signals_json.clone());
state.subscriptions.update(conn_id, keys);
if let Some(sender) = state.connections.get_sender(&conn_id) {
let event = format_patch_elements(&app_wrapper(conn_id, &signals_json, &html));
// Non-blocking: if the pipe is closed the cleanup task handles it
let _ = sender.try_send(event);
}
StatusCode::OK
}
Err(e) => {
eprintln!("Render error for {}: {}", conn_id, e);
StatusCode::INTERNAL_SERVER_ERROR
}
}
state.store.publish(invalidation_key);
StatusCode::ACCEPTED
}
/// `POST /action/add_todo` — Insert a new todo, push rerender down SSE pipe.
async fn action_add_todo(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
@@ -290,55 +185,52 @@ async fn action_add_todo(
.unwrap_or("")
.trim()
.to_string();
let db = state.store.db.clone();
let signals = SignalStore::from_json(body);
let db = state.db.clone();
action_then_push(
action_then_publish(
&state,
signals,
body,
async move {
if !title.is_empty() {
crate::queries::create_todo(&db, &title).await?;
}
Ok(())
},
CacheKey::Table { table: "todos" },
)
.await
}
/// `POST /action/toggle_todo?id=<i64>` — Toggle completion, push rerender.
async fn action_toggle_todo(
State(state): State<SharedState>,
AxumQuery(params): AxumQuery<ActionIdParams>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let signals = SignalStore::from_json(body);
let db = state.db.clone();
let db = state.store.db.clone();
let id = params.id;
action_then_push(
action_then_publish(
&state,
signals,
body,
async move { crate::queries::toggle_todo(&db, id).await },
CacheKey::Table { table: "todos" },
)
.await
}
/// `POST /action/delete_todo?id=<i64>` — Delete a todo, push rerender.
async fn action_delete_todo(
State(state): State<SharedState>,
AxumQuery(params): AxumQuery<ActionIdParams>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let signals = SignalStore::from_json(body);
let db = state.db.clone();
let db = state.store.db.clone();
let id = params.id;
action_then_push(
action_then_publish(
&state,
signals,
body,
async move { crate::queries::delete_todo(&db, id).await },
CacheKey::Table { table: "todos" },
)
.await
}