Refactor: untangle framework library from todo example

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
This commit is contained in:
Claude
2026-03-06 16:51:53 +00:00
parent 2569596ca5
commit 8535d39012
13 changed files with 122 additions and 111 deletions
-47
View File
@@ -1,47 +0,0 @@
use crate::framework::context::RenderContext;
use maud::{html, Markup};
/// Second page component: a simple "About" page.
///
/// This exists specifically to exercise:
/// - The `tree` signal page routing (resolve_root match arm)
/// - SPA-like navigation via tree signal mutation
/// - Round-trip: TodoList → About → TodoList without full page reload
///
/// In a real app this might show user info, settings, etc.
pub async fn about(_cx: &mut RenderContext) -> anyhow::Result<Markup> {
Ok(html! {
div.about-page {
h1 { "About LiveVue.rs" }
nav {
button.nav-link
data-on-click="$tree = {page: 'TodoList'}; @post('/render')" {
"Todos"
}
" | "
span.nav-current { "About" }
}
div.about-content {
p {
"LiveVue.rs is a reactive server-side rendering framework for Rust. "
"The server renders full-page HTML on every state change. "
"DataStar morphs it into the DOM."
}
p {
"The architectural bet: "
strong { "full rerender is cheap" }
", so skip diffing, skip partial updates, skip client-side state management."
}
h2 { "v0 Status" }
ul {
li { "Single process, single node" }
li { "SQLite for storage" }
li { "Subscription keys collected (Phase 2 seam)" }
li { "No cache, no auth, no WAL consumer" }
}
}
}
})
}
-470
View File
@@ -1,470 +0,0 @@
pub mod about;
pub mod queries;
pub mod todo_list;
use crate::framework::connection::{ConnectionManager, SubscriptionRegistry};
use crate::framework::context::{ComponentTree, RenderContext};
use crate::framework::signal::SignalStore;
use crate::framework::sse::{format_patch_elements, format_patch_signals};
use axum::extract::{Query as AxumQuery, State};
use axum::http::header;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use axum::{Json, Router};
use std::convert::Infallible;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Shared application state
// ---------------------------------------------------------------------------
/// All shared state, wrapped in Arc for axum's State extractor.
pub struct AppState {
pub db: sqlx::SqlitePool,
pub connections: ConnectionManager,
pub subscriptions: SubscriptionRegistry,
}
pub type SharedState = Arc<AppState>;
// ---------------------------------------------------------------------------
// 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 Demo</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 demo app. In a real app this would be a separate file.
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" => todo_list::todo_list(cx).await?,
"About" => 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<crate::framework::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),
)),
}
}
/// Query params for the SSE endpoint.
#[derive(serde::Deserialize)]
struct SseParams {
conn: Uuid,
}
/// `GET /sse?conn=<uuid>` — Long-lived SSE pipe for server-initiated push.
///
/// In v0, nothing actively pushes events through this pipe (no invalidation
/// events yet). It exists to:
/// 1. Register the connection in ConnectionManager.
/// 2. Keep the SSE connection alive (DataStar expects it).
/// 3. Provide the Phase 2 seam for server-initiated rerenders.
///
/// On drop, the connection is cleaned up from both the ConnectionManager
/// and the SubscriptionRegistry.
async fn sse_handler(
State(state): State<SharedState>,
AxumQuery(params): AxumQuery<SseParams>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let conn_id = params.conn;
// Create a channel for sending events to this connection
let (tx, rx) = mpsc::channel::<String>(32);
// Clone sender for the cleanup watcher. Sender::closed() resolves when
// the receiver is dropped (i.e. client disconnects and the SSE stream
// drops). This is the proper cleanup mechanism — no arbitrary sleep.
let tx_watch = tx.clone();
// Register the connection
state.connections.insert(conn_id, tx);
// Spawn cleanup task: waits for the receiver to drop (client disconnect),
// then removes the connection from all registries.
let state_cleanup = state.clone();
tokio::spawn(async move {
tx_watch.closed().await;
state_cleanup.connections.remove(&conn_id);
state_cleanup.subscriptions.remove_connection(&conn_id);
});
// Convert the receiver into an SSE stream.
// DESIGN NOTE: For v0, the SSE pipe is mostly idle — POST /render
// returns SSE directly in its response, not through this pipe. This
// channel exists for Phase 2 server-initiated pushes (data invalidation).
// Messages sent via the channel should be plain content strings (not
// pre-formatted SSE); axum's Sse wrapper handles the SSE framing.
let stream = ReceiverStream::new(rx);
let event_stream = tokio_stream::StreamExt::map(stream, |msg| {
Ok(Event::default().data(msg))
});
Sse::new(event_stream).keep_alive(KeepAlive::default())
}
/// `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() {
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 { 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 { queries::delete_todo(&db, id).await },
None,
)
.await
}
-75
View File
@@ -1,75 +0,0 @@
use crate::framework::CacheKey;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------
/// A single todo item. Maps to the `todos` SQLite table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
pub id: i64,
pub title: String,
pub completed: bool,
}
// ---------------------------------------------------------------------------
// Read queries (go through cx.run / Query trait)
// ---------------------------------------------------------------------------
/// Fetch all todos, ordered by id.
pub struct ListTodos;
crate::query_all!(ListTodos {
sql: "SELECT id, title, completed FROM todos ORDER BY id",
params: [],
output: Todo,
cache: |_s: &ListTodos| CacheKey::Table { table: "todos" },
});
/// Fetch a single todo by id.
pub struct GetTodo {
pub id: i64,
}
crate::query_one!(GetTodo {
sql: "SELECT id, title, completed FROM todos WHERE id = ?",
params: [|s: &GetTodo| s.id],
output: Todo,
cache: |s: &GetTodo| CacheKey::PrimaryKey { table: "todos", id: s.id },
});
// ---------------------------------------------------------------------------
// Write operations (plain sqlx, not through Query trait)
// ---------------------------------------------------------------------------
//
// Mutations don't go through cx.run() because they're not reads and don't
// produce subscription keys. They're called directly by action handlers,
// which then trigger a full rerender.
/// Insert a new todo. Returns the new row's id.
pub async fn create_todo(db: &sqlx::SqlitePool, title: &str) -> anyhow::Result<i64> {
let result = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, false)")
.bind(title)
.execute(db)
.await?;
Ok(result.last_insert_rowid())
}
/// Toggle a todo's completed status.
pub async fn toggle_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
sqlx::query("UPDATE todos SET completed = NOT completed WHERE id = ?")
.bind(id)
.execute(db)
.await?;
Ok(())
}
/// Delete a todo by id.
pub async fn delete_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
sqlx::query("DELETE FROM todos WHERE id = ?")
.bind(id)
.execute(db)
.await?;
Ok(())
}
-122
View File
@@ -1,122 +0,0 @@
use crate::app::queries::{ListTodos, Todo};
use crate::framework::context::RenderContext;
use crate::framework::signal::{global, mangle};
use maud::{html, Markup};
/// Root page component: renders the full todo list with add form.
///
/// Reads signals:
/// - `newTodo` (global, server-relevant): the text input value
///
/// Exercises:
/// - `cx.run()` for data fetching
/// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions
/// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item)
/// - DataStar `data-bind`, `data-on-click`, `data-attr` attributes
/// - `@post()` actions for mutations
pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
// Fetch all todos
let todos = cx.run(ListTodos).await?;
// Global signal for the "new todo" input. This call does two things:
// 1. Records "newTodo" as a touched signal (for demangling tracking)
// 2. Returns a Signal struct whose .val is "$newTodo" for expressions
let new_todo = cx.signal("newTodo", global());
let todo_count = todos.len();
let done_count = todos.iter().filter(|t| t.completed).count();
Ok(html! {
div.todo-app {
h1 { "LiveVue.rs — Todo List" }
// Navigation link to About page (exercises tree signal + page routing)
nav {
span.nav-current { "Todos" }
" | "
button.nav-link
data-on-click="$tree = {page: 'About'}; @post('/render')" {
"About"
}
}
// Add todo form.
// data-bind-newTodo binds the input to the $newTodo signal.
// The button uses new_todo.val ("$newTodo") in a DataStar expression.
div.add-form {
input
type="text"
placeholder="What needs to be done?"
data-bind-newTodo=""
;
button
data-on-click="@post('/action/add_todo')"
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) {
"Add"
}
}
// Stats
p.stats {
(format!("{} items, {} completed", todo_count, done_count))
}
// Todo items
ul.todo-list {
@for todo in &todos {
(todo_item(cx, todo))
}
}
@if todos.is_empty() {
p.empty { "No todos yet. Add one above!" }
}
}
})
}
/// Renders a single todo item.
///
/// This is a static child component — called directly from `todo_list`,
/// not resolved through the component registry.
///
/// Demonstrates per-instance, client-only signal via `mangle().client_only()`.
fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
// Per-instance, client-only signal for edit mode.
// Not used in v0 rendering, but demonstrates the mangled signal API
// and ensures it's tracked as touched. Produces: "_editing__<id>"
let _editing = cx.signal("editing", mangle(todo.id).client_only());
// Build class string server-side. Since we do a full rerender on every
// state change, there's no need for data-class here — the server knows
// whether the todo is completed.
let title_class = if todo.completed {
"todo-title completed"
} else {
"todo-title"
};
html! {
li.todo-item id=(format!("todo-{}", todo.id)) {
// Checkbox to toggle completion.
// `checked[expr]` is maud's syntax for conditional boolean attributes.
input
type="checkbox"
checked[todo.completed]
data-on-click=(format!("@post('/action/toggle_todo?id={}')", todo.id))
;
// Title with strikethrough when completed (class set server-side)
span class=(title_class) {
(todo.title)
}
// Delete button
button.delete-btn
data-on-click=(format!("@post('/action/delete_todo?id={}')", todo.id))
{
"×"
}
}
}
}
@@ -1,4 +1,4 @@
use crate::framework::query::CacheKey;
use crate::query::CacheKey;
use dashmap::DashMap;
use std::collections::HashSet;
use tokio::sync::mpsc;
+3 -3
View File
@@ -1,5 +1,5 @@
use crate::framework::signal::{Signal, SignalOpts, SignalStore};
use crate::framework::query::{CacheKey, Query};
use crate::signal::{Signal, SignalOpts, SignalStore};
use crate::query::{CacheKey, Query};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
@@ -34,7 +34,7 @@ pub struct ComponentNode {
impl Default for ComponentTree {
fn default() -> Self {
Self {
page: "TodoList".into(),
page: String::new(),
children: HashMap::new(),
}
}
+2
View File
@@ -3,9 +3,11 @@ pub mod query;
pub mod context;
pub mod connection;
pub mod sse;
pub mod server;
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
pub use query::{CacheKey, Query};
pub use context::{RenderContext, ComponentTree, ComponentNode};
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry};
pub use sse::{format_patch_elements, format_patch_signals};
pub use server::{AppState, SharedState, sse_handler};
-63
View File
@@ -1,63 +0,0 @@
mod framework;
mod app;
use app::AppState;
use framework::connection::{ConnectionManager, SubscriptionRegistry};
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// -----------------------------------------------------------------------
// Database setup
// -----------------------------------------------------------------------
// SQLite with create-if-missing. In-memory `:memory:` also works for dev.
let db = sqlx::SqlitePool::connect("sqlite:livevue.db?mode=rwc").await?;
// Run migrations inline (no migration files needed for v0).
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?;
// Seed a few todos if the table is empty (nice for first run)
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?;
}
}
// -----------------------------------------------------------------------
// Application state
// -----------------------------------------------------------------------
let state = Arc::new(AppState {
db,
connections: ConnectionManager::new(),
subscriptions: SubscriptionRegistry::new(),
});
// -----------------------------------------------------------------------
// Server
// -----------------------------------------------------------------------
let app = app::router(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("LiveVue.rs v0.1 listening on http://localhost:3000");
axum::serve(listener, app).await?;
Ok(())
}
+5 -5
View File
@@ -79,10 +79,10 @@ macro_rules! query_one {
output: $output:ty,
cache: $cache:expr $(,)?
}) => {
impl $crate::framework::Query for $name {
impl $crate::Query for $name {
type Output = $output;
fn cache_key(&self) -> $crate::framework::CacheKey {
fn cache_key(&self) -> $crate::CacheKey {
($cache)(self)
}
@@ -134,10 +134,10 @@ macro_rules! query_all {
output: $output:ty,
cache: $cache:expr $(,)?
}) => {
impl $crate::framework::Query for $name {
impl $crate::Query for $name {
type Output = Vec<$output>;
fn cache_key(&self) -> $crate::framework::CacheKey {
fn cache_key(&self) -> $crate::CacheKey {
($cache)(self)
}
@@ -158,4 +158,4 @@ macro_rules! query_all {
}
}
};
}
}
+85
View File
@@ -0,0 +1,85 @@
use crate::connection::{ConnectionManager, SubscriptionRegistry};
use axum::extract::{Query as AxumQuery, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use std::convert::Infallible;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Shared application state
// ---------------------------------------------------------------------------
/// All shared state, wrapped in Arc for axum's State extractor.
///
/// Applications can use this directly or define their own state type.
/// The framework's `sse_handler` is tied to this type.
pub struct AppState {
pub db: sqlx::SqlitePool,
pub connections: ConnectionManager,
pub subscriptions: SubscriptionRegistry,
}
pub type SharedState = Arc<AppState>;
// ---------------------------------------------------------------------------
// SSE handler
// ---------------------------------------------------------------------------
/// Query params for the SSE endpoint.
#[derive(serde::Deserialize)]
pub struct SseParams {
conn: Uuid,
}
/// `GET /sse?conn=<uuid>` — Long-lived SSE pipe for server-initiated push.
///
/// In v0, nothing actively pushes events through this pipe (no invalidation
/// events yet). It exists to:
/// 1. Register the connection in ConnectionManager.
/// 2. Keep the SSE connection alive (DataStar expects it).
/// 3. Provide the Phase 2 seam for server-initiated rerenders.
///
/// On drop, the connection is cleaned up from both the ConnectionManager
/// and the SubscriptionRegistry.
pub async fn sse_handler(
State(state): State<SharedState>,
AxumQuery(params): AxumQuery<SseParams>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let conn_id = params.conn;
// Create a channel for sending events to this connection
let (tx, rx) = mpsc::channel::<String>(32);
// Clone sender for the cleanup watcher. Sender::closed() resolves when
// the receiver is dropped (i.e. client disconnects and the SSE stream
// drops). This is the proper cleanup mechanism — no arbitrary sleep.
let tx_watch = tx.clone();
// Register the connection
state.connections.insert(conn_id, tx);
// Spawn cleanup task: waits for the receiver to drop (client disconnect),
// then removes the connection from all registries.
let state_cleanup = state.clone();
tokio::spawn(async move {
tx_watch.closed().await;
state_cleanup.connections.remove(&conn_id);
state_cleanup.subscriptions.remove_connection(&conn_id);
});
// Convert the receiver into an SSE stream.
// DESIGN NOTE: For v0, the SSE pipe is mostly idle — POST /render
// returns SSE directly in its response, not through this pipe. This
// channel exists for Phase 2 server-initiated pushes (data invalidation).
// Messages sent via the channel should be plain content strings (not
// pre-formatted SSE); axum's Sse wrapper handles the SSE framing.
let stream = ReceiverStream::new(rx);
let event_stream = tokio_stream::StreamExt::map(stream, |msg| {
Ok(Event::default().data(msg))
});
Sse::new(event_stream).keep_alive(KeepAlive::default())
}
View File