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:
@@ -3,6 +3,8 @@ name = "livevue-rs"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
# Run the todo example with: cargo run --example todo
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::framework::context::RenderContext;
|
use livevue_rs::RenderContext;
|
||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
/// Second page component: a simple "About" page.
|
/// Second page component: a simple "About" page.
|
||||||
@@ -1,37 +1,15 @@
|
|||||||
pub mod about;
|
use livevue_rs::{
|
||||||
pub mod queries;
|
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
||||||
pub mod todo_list;
|
format_patch_elements, format_patch_signals, sse_handler,
|
||||||
|
};
|
||||||
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::extract::{Query as AxumQuery, State};
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use std::convert::Infallible;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
|
||||||
use uuid::Uuid;
|
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
|
// Router
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -83,7 +61,7 @@ fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> Str
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>LiveVue.rs Demo</title>
|
<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@v1.0.0-RC.8/bundles/datastar.js"></script>
|
||||||
<style>
|
<style>
|
||||||
{css}
|
{css}
|
||||||
@@ -105,7 +83,7 @@ fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> Str
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal CSS for the demo app. In a real app this would be a separate file.
|
/// Minimal CSS for the todo app.
|
||||||
const INLINE_CSS: &str = r#"
|
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; }
|
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fafafa; color: #333; }
|
||||||
h1 { color: #2563eb; }
|
h1 { color: #2563eb; }
|
||||||
@@ -136,8 +114,8 @@ const INLINE_CSS: &str = r#"
|
|||||||
/// no async lifetime gymnastics. The right call for a v0 with two pages.
|
/// no async lifetime gymnastics. The right call for a v0 with two pages.
|
||||||
async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
||||||
let markup = match cx.tree.page.as_str() {
|
let markup = match cx.tree.page.as_str() {
|
||||||
"TodoList" => todo_list::todo_list(cx).await?,
|
"TodoList" => crate::todo_list::todo_list(cx).await?,
|
||||||
"About" => about::about(cx).await?,
|
"About" => crate::about::about(cx).await?,
|
||||||
other => maud::html! {
|
other => maud::html! {
|
||||||
div {
|
div {
|
||||||
h1 { "404 — Unknown page" }
|
h1 { "404 — Unknown page" }
|
||||||
@@ -160,7 +138,7 @@ async fn render_page(
|
|||||||
conn_id: Uuid,
|
conn_id: Uuid,
|
||||||
signals: SignalStore,
|
signals: SignalStore,
|
||||||
tree: ComponentTree,
|
tree: ComponentTree,
|
||||||
) -> anyhow::Result<(String, Vec<crate::framework::CacheKey>, String)> {
|
) -> anyhow::Result<(String, Vec<CacheKey>, String)> {
|
||||||
let signals_json = signals.to_json_string();
|
let signals_json = signals.to_json_string();
|
||||||
let mut cx = RenderContext::new(conn_id, signals, tree, db.clone());
|
let mut cx = RenderContext::new(conn_id, signals, tree, db.clone());
|
||||||
let html = resolve_and_render(&mut cx).await?;
|
let html = resolve_and_render(&mut cx).await?;
|
||||||
@@ -229,62 +207,6 @@ async fn initial_about_load(State(state): State<SharedState>) -> impl IntoRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// `POST /render` — Signal-triggered rerender.
|
||||||
///
|
///
|
||||||
/// DataStar sends all non-`_` signals as JSON body when any signal changes
|
/// DataStar sends all non-`_` signals as JSON body when any signal changes
|
||||||
@@ -426,7 +348,7 @@ async fn action_add_todo(
|
|||||||
body,
|
body,
|
||||||
async move {
|
async move {
|
||||||
if !title.is_empty() {
|
if !title.is_empty() {
|
||||||
queries::create_todo(&db, &title).await?;
|
crate::queries::create_todo(&db, &title).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
@@ -446,7 +368,7 @@ async fn action_toggle_todo(
|
|||||||
action_then_render(
|
action_then_render(
|
||||||
&state,
|
&state,
|
||||||
body,
|
body,
|
||||||
async move { queries::toggle_todo(&db, id).await },
|
async move { crate::queries::toggle_todo(&db, id).await },
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -463,7 +385,7 @@ async fn action_delete_todo(
|
|||||||
action_then_render(
|
action_then_render(
|
||||||
&state,
|
&state,
|
||||||
body,
|
body,
|
||||||
async move { queries::delete_todo(&db, id).await },
|
async move { crate::queries::delete_todo(&db, id).await },
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
mod framework;
|
|
||||||
mod app;
|
mod app;
|
||||||
|
mod queries;
|
||||||
|
mod todo_list;
|
||||||
|
mod about;
|
||||||
|
|
||||||
use app::AppState;
|
use livevue_rs::{AppState, ConnectionManager, SubscriptionRegistry};
|
||||||
use framework::connection::{ConnectionManager, SubscriptionRegistry};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -12,7 +13,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// SQLite with create-if-missing. In-memory `:memory:` also works for dev.
|
// SQLite with create-if-missing. In-memory `:memory:` also works for dev.
|
||||||
let db = sqlx::SqlitePool::connect("sqlite:livevue.db?mode=rwc").await?;
|
let db = sqlx::SqlitePool::connect("sqlite:todo.db?mode=rwc").await?;
|
||||||
|
|
||||||
// Run migrations inline (no migration files needed for v0).
|
// Run migrations inline (no migration files needed for v0).
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -55,7 +56,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let app = app::router(state);
|
let app = app::router(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
println!("LiveVue.rs v0.1 listening on http://localhost:3000");
|
println!("Todo example listening on http://localhost:3000");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::framework::CacheKey;
|
use livevue_rs::CacheKey;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -20,7 +20,7 @@ pub struct Todo {
|
|||||||
/// Fetch all todos, ordered by id.
|
/// Fetch all todos, ordered by id.
|
||||||
pub struct ListTodos;
|
pub struct ListTodos;
|
||||||
|
|
||||||
crate::query_all!(ListTodos {
|
livevue_rs::query_all!(ListTodos {
|
||||||
sql: "SELECT id, title, completed FROM todos ORDER BY id",
|
sql: "SELECT id, title, completed FROM todos ORDER BY id",
|
||||||
params: [],
|
params: [],
|
||||||
output: Todo,
|
output: Todo,
|
||||||
@@ -32,7 +32,7 @@ pub struct GetTodo {
|
|||||||
pub id: i64,
|
pub id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::query_one!(GetTodo {
|
livevue_rs::query_one!(GetTodo {
|
||||||
sql: "SELECT id, title, completed FROM todos WHERE id = ?",
|
sql: "SELECT id, title, completed FROM todos WHERE id = ?",
|
||||||
params: [|s: &GetTodo| s.id],
|
params: [|s: &GetTodo| s.id],
|
||||||
output: Todo,
|
output: Todo,
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::app::queries::{ListTodos, Todo};
|
use crate::queries::{ListTodos, Todo};
|
||||||
use crate::framework::context::RenderContext;
|
use livevue_rs::{RenderContext, global, mangle};
|
||||||
use crate::framework::signal::{global, mangle};
|
|
||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
/// Root page component: renders the full todo list with add form.
|
/// Root page component: renders the full todo list with add form.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::framework::query::CacheKey;
|
use crate::query::CacheKey;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::framework::signal::{Signal, SignalOpts, SignalStore};
|
use crate::signal::{Signal, SignalOpts, SignalStore};
|
||||||
use crate::framework::query::{CacheKey, Query};
|
use crate::query::{CacheKey, Query};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -34,7 +34,7 @@ pub struct ComponentNode {
|
|||||||
impl Default for ComponentTree {
|
impl Default for ComponentTree {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
page: "TodoList".into(),
|
page: String::new(),
|
||||||
children: HashMap::new(),
|
children: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,11 @@ pub mod query;
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
|
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
|
||||||
pub use query::{CacheKey, Query};
|
pub use query::{CacheKey, Query};
|
||||||
pub use context::{RenderContext, ComponentTree, ComponentNode};
|
pub use context::{RenderContext, ComponentTree, ComponentNode};
|
||||||
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry};
|
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry};
|
||||||
pub use sse::{format_patch_elements, format_patch_signals};
|
pub use sse::{format_patch_elements, format_patch_signals};
|
||||||
|
pub use server::{AppState, SharedState, sse_handler};
|
||||||
@@ -79,10 +79,10 @@ macro_rules! query_one {
|
|||||||
output: $output:ty,
|
output: $output:ty,
|
||||||
cache: $cache:expr $(,)?
|
cache: $cache:expr $(,)?
|
||||||
}) => {
|
}) => {
|
||||||
impl $crate::framework::Query for $name {
|
impl $crate::Query for $name {
|
||||||
type Output = $output;
|
type Output = $output;
|
||||||
|
|
||||||
fn cache_key(&self) -> $crate::framework::CacheKey {
|
fn cache_key(&self) -> $crate::CacheKey {
|
||||||
($cache)(self)
|
($cache)(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +134,10 @@ macro_rules! query_all {
|
|||||||
output: $output:ty,
|
output: $output:ty,
|
||||||
cache: $cache:expr $(,)?
|
cache: $cache:expr $(,)?
|
||||||
}) => {
|
}) => {
|
||||||
impl $crate::framework::Query for $name {
|
impl $crate::Query for $name {
|
||||||
type Output = Vec<$output>;
|
type Output = Vec<$output>;
|
||||||
|
|
||||||
fn cache_key(&self) -> $crate::framework::CacheKey {
|
fn cache_key(&self) -> $crate::CacheKey {
|
||||||
($cache)(self)
|
($cache)(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,4 +158,4 @@ macro_rules! query_all {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user