use livevue_rs::{ AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore, format_patch_elements, sse_handler, }; use axum::extract::{Query as AxumQuery, State}; 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) -> Router { Router::new() .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)) .with_state(state) } // --------------------------------------------------------------------------- // 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 { format!( r#" LiveVue.rs — Todo {app} "#, css = INLINE_CSS, app = app_wrapper(conn_id, initial_signals, inner_html), ) } /// The `
` 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 { format!( r#"
{inner}

    
"#, // Could __debounce.300ms on the signal patch 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; } .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 // --------------------------------------------------------------------------- async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result { 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" } } }, }; 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, 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") .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) } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- /// `GET /` — Initial page load for the todo list. async fn initial_page_load(State(state): State) -> impl IntoResponse { let conn_id = Uuid::new_v4(); 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!("")), ]); 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!("

Render error: {}

", e), )), } } /// `GET /about` — Initial page load for the about page. async fn initial_about_load(State(state): State) -> 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()), ]); 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!("

Render error: {}

", e), )), } } /// `POST /render` — Signal-triggered rerender. /// /// DataStar sends all non-`_` signals as JSON body on every signal patch. /// Returns `text/html` with the full `
` wrapper — DataStar /// morphs it into the existing DOM by matching the element id. async fn render_handler( State(state): State, Json(body): Json, ) -> impl IntoResponse { let signals = SignalStore::from_json(body); let (conn_id, tree) = extract_conn_and_tree(&signals); 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!("

Render error: {}

", 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( state: &AppState, signals: SignalStore, mutation: impl std::future::Future> + Send, ) -> StatusCode { let (conn_id, tree) = extract_conn_and_tree(&signals); if let Err(e) = mutation.await { eprintln!("Action error for {}: {}", conn_id, 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 } } } /// `POST /action/add_todo` — Insert a new todo, push rerender down SSE pipe. async fn action_add_todo( State(state): State, Json(body): Json, ) -> impl IntoResponse { let title = body .get("newTodo") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let signals = SignalStore::from_json(body); let db = state.db.clone(); action_then_push( &state, signals, async move { if !title.is_empty() { crate::queries::create_todo(&db, &title).await?; } Ok(()) }, ) .await } /// `POST /action/toggle_todo?id=` — Toggle completion, push rerender. async fn action_toggle_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let signals = SignalStore::from_json(body); let db = state.db.clone(); let id = params.id; action_then_push( &state, signals, async move { crate::queries::toggle_todo(&db, id).await }, ) .await } /// `POST /action/delete_todo?id=` — Delete a todo, push rerender. async fn action_delete_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let signals = SignalStore::from_json(body); let db = state.db.clone(); let id = params.id; action_then_push( &state, signals, async move { crate::queries::delete_todo(&db, id).await }, ) .await }