The codebase is now split into two clear layers: Framework library (src/): - lib.rs — public API surface, re-exports all framework types - signal.rs — SignalOpts, Signal, SignalStore, mangle/global/client_only helpers - query.rs — Query trait, CacheKey, query_one!/query_all! macros (macro paths updated from $crate::framework::* to $crate::*) - context.rs — RenderContext, ComponentTree, ComponentNode - connection.rs — ConnectionManager, SubscriptionRegistry, ConnectionState - sse.rs — format_patch_elements, format_patch_signals - server.rs — AppState, SharedState, sse_handler (pure framework plumbing) Todo example (examples/todo/): - main.rs — SQLite setup, seeding, server bootstrap - app.rs — router, document_shell, resolve_and_render, render_page, action_then_render, and all HTTP handlers - queries.rs — Todo model, ListTodos/GetTodo queries, mutation helpers - todo_list.rs — TodoList page component - about.rs — About page component Run the example independently with: cargo run --example todo https://claude.ai/code/session_01JThiQbp3Bn9J1VhBpRuz4u
393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
use livevue_rs::{
|
|
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
|
format_patch_elements, format_patch_signals, sse_handler,
|
|
};
|
|
|
|
use axum::extract::{Query as AxumQuery, State};
|
|
use axum::http::header;
|
|
use axum::response::{Html, IntoResponse};
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use uuid::Uuid;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build the axum Router with all routes.
|
|
///
|
|
/// Lesson from v0.0: handlers live here next to the components they call,
|
|
/// not in a separate routes.rs with complex trait object signatures.
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Wrap rendered component HTML in a full HTML document.
|
|
///
|
|
/// Lesson from v0.0: Maud can't handle DataStar's attribute syntax
|
|
/// (e.g. `data-on-signal-change__debounce.300ms`). The document shell uses
|
|
/// format! / raw string. Maud is for component interiors only.
|
|
///
|
|
/// DataStar v1 contract (verified against https://data-star.dev/reference):
|
|
/// - `data-signals` — initialize signals (JS object literal syntax)
|
|
/// - `data-on-signal-change` — fires when any signal changes
|
|
/// - `__debounce.300ms` — modifier: debounce 300ms
|
|
/// - `@post('/render')` — send all non-_ signals as JSON body
|
|
/// - `@get('/sse?conn=...')` — open long-lived SSE pipe
|
|
/// - `data-on-load` — fires when element enters DOM
|
|
///
|
|
/// KNOWN v0 QUIRK: Nav buttons use `$tree = {...}; @post('/render')`,
|
|
/// which fires an immediate POST. The signal change handler then fires a
|
|
/// second debounced POST 300ms later. This double-fire is harmless (same
|
|
/// HTML both times) but wasteful. Phase 2 could fix this with request
|
|
/// deduplication or by switching nav to only mutate the signal and relying
|
|
/// solely on the auto-poster.
|
|
fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> 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@v1.0.0-RC.8/bundles/datastar.js"></script>
|
|
<style>
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app"
|
|
data-signals='{signals}'
|
|
data-on-signal-change__debounce.300ms="@post('/render')"
|
|
data-on-load="@get('/sse?conn={conn}')">
|
|
{inner}
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
css = INLINE_CSS,
|
|
signals = initial_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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Match a page name (from the `tree` signal) to its component function.
|
|
///
|
|
/// This is a plain match statement — no ComponentRegistry, no trait objects,
|
|
/// no async lifetime gymnastics. The right call for a v0 with two pages.
|
|
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"
|
|
}
|
|
}
|
|
},
|
|
};
|
|
Ok(markup.into_string())
|
|
}
|
|
|
|
/// Shared render pipeline used by both initial page load and POST /render.
|
|
///
|
|
/// Returns `(rendered_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))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
|
|
// Build initial signals
|
|
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)) => {
|
|
// Keys and signals_json are stored when SSE connects
|
|
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),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// `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 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),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// `POST /render` — Signal-triggered rerender.
|
|
///
|
|
/// DataStar sends all non-`_` signals as JSON body when any signal changes
|
|
/// (triggered by `data-on-signal-change` + `@post('/render')`).
|
|
///
|
|
/// Returns `Content-Type: text/event-stream` with a single
|
|
/// `datastar-patch-elements` event containing the full page HTML.
|
|
async fn render_handler(
|
|
State(state): State<SharedState>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let signals = SignalStore::from_json(body);
|
|
|
|
// Extract connection id
|
|
let conn_id: Uuid = signals
|
|
.get("connId")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or_else(Uuid::new_v4);
|
|
|
|
// Extract component tree
|
|
let tree: ComponentTree = signals
|
|
.get("tree")
|
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
.unwrap_or_default();
|
|
|
|
match render_page(&state.db, conn_id, signals, tree).await {
|
|
Ok((html, keys, signals_json)) => {
|
|
// Update connection state
|
|
state.connections.update_signals(&conn_id, signals_json);
|
|
state.subscriptions.update(conn_id, keys);
|
|
|
|
let sse_body = format_patch_elements(&html);
|
|
(
|
|
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
sse_body,
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => {
|
|
let error_html = format!(r#"<div id="app"><p>Render error: {}</p></div>"#, e);
|
|
let sse_body = format_patch_elements(&error_html);
|
|
(
|
|
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
sse_body,
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query params for action endpoints that need an entity id.
|
|
#[derive(serde::Deserialize)]
|
|
struct ActionIdParams {
|
|
id: i64,
|
|
}
|
|
|
|
/// Helper: extract common fields from signal body, run a mutation, then rerender.
|
|
///
|
|
/// Returns an SSE response with the rerendered page.
|
|
async fn action_then_render(
|
|
state: &AppState,
|
|
body: serde_json::Value,
|
|
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
|
patch_signals: Option<&str>,
|
|
) -> impl IntoResponse {
|
|
let signals = SignalStore::from_json(body);
|
|
|
|
let conn_id: Uuid = signals
|
|
.get("connId")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or_else(Uuid::new_v4);
|
|
|
|
let tree: ComponentTree = signals
|
|
.get("tree")
|
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
.unwrap_or_default();
|
|
|
|
// Run the mutation
|
|
if let Err(e) = mutation.await {
|
|
let error_html = format!(r#"<div id="app"><p>Action error: {}</p></div>"#, e);
|
|
return (
|
|
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
format_patch_elements(&error_html),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Rerender
|
|
match render_page(&state.db, conn_id, signals, tree).await {
|
|
Ok((html, keys, signals_json)) => {
|
|
state.connections.update_signals(&conn_id, signals_json);
|
|
state.subscriptions.update(conn_id, keys);
|
|
|
|
let mut sse_body = String::new();
|
|
// Optionally patch signals (e.g. clear the input)
|
|
if let Some(ps) = patch_signals {
|
|
sse_body.push_str(&format_patch_signals(ps));
|
|
}
|
|
sse_body.push_str(&format_patch_elements(&html));
|
|
|
|
(
|
|
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
sse_body,
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => {
|
|
let error_html = format!(r#"<div id="app"><p>Render error: {}</p></div>"#, e);
|
|
(
|
|
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
format_patch_elements(&error_html),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `POST /action/add_todo` — Create a new todo, then rerender.
|
|
///
|
|
/// Reads the `newTodo` signal from the POST body, inserts it, and sends
|
|
/// back a `datastar-patch-signals` event to clear the input followed by
|
|
/// a `datastar-patch-elements` event with the rerendered page.
|
|
async fn action_add_todo(
|
|
State(state): State<SharedState>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let title = body
|
|
.get("newTodo")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_string();
|
|
|
|
let db = state.db.clone();
|
|
action_then_render(
|
|
&state,
|
|
body,
|
|
async move {
|
|
if !title.is_empty() {
|
|
crate::queries::create_todo(&db, &title).await?;
|
|
}
|
|
Ok(())
|
|
},
|
|
Some("{newTodo: ''}"), // Clear the input after adding
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// `POST /action/toggle_todo?id=<i64>` — Toggle a todo's completed status.
|
|
async fn action_toggle_todo(
|
|
State(state): State<SharedState>,
|
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let db = state.db.clone();
|
|
let id = params.id;
|
|
action_then_render(
|
|
&state,
|
|
body,
|
|
async move { crate::queries::toggle_todo(&db, id).await },
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// `POST /action/delete_todo?id=<i64>` — Delete a todo.
|
|
async fn action_delete_todo(
|
|
State(state): State<SharedState>,
|
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let db = state.db.clone();
|
|
let id = params.id;
|
|
action_then_render(
|
|
&state,
|
|
body,
|
|
async move { crate::queries::delete_todo(&db, id).await },
|
|
None,
|
|
)
|
|
.await
|
|
}
|