diff --git a/Cargo.lock b/Cargo.lock index ac7262a..1f9a137 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -206,6 +221,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "cookie" version = "0.18.1" @@ -239,6 +266,7 @@ version = "0.3.0" dependencies = [ "async-stream", "axum", + "chrono", "indexmap", "reqwest", "rocket", @@ -731,6 +759,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1063,6 +1115,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -2351,6 +2412,41 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index 760e7ae..06db406 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ axum = { version = "0.8.4", default-features = false, optional = true, features "tokio", "json", ] } +chrono = { version = "0.4.41", default-features = false, features = ["clock"] } rocket = { version = "0.5.1", default-features = false, optional = true } serde = { version = "1", default-features = false, optional = true, features = [ "derive", diff --git a/examples/activity-feed.html b/examples/activity-feed.html new file mode 100644 index 0000000..87069a3 --- /dev/null +++ b/examples/activity-feed.html @@ -0,0 +1,102 @@ + + + + Datastar SDK Activity Feed Demo + + + + +
+
+

Datastar SDK Activity Feed Demo

+ Rocket +
+

SSE events will be streamed from the backend to the frontend.

+
+ + +
+
+ + +
+ +  |  + + + + +
+
+
+ Click Generate to create + 200 events, 10 milliseconds + apart.
+ Total: 0 + | + Done: 0 + | + Warn: 0 + | + Fail: 0 + | + Info: 0 +
+ ------------------------------------------------------------- +
+
+ + diff --git a/examples/axum-activity-feed.rs b/examples/axum-activity-feed.rs new file mode 100644 index 0000000..e3ecc52 --- /dev/null +++ b/examples/axum-activity-feed.rs @@ -0,0 +1,155 @@ +use { + async_stream::stream, + axum::{ + Router, + extract::Path, + response::{Html, IntoResponse, Sse}, + routing::{get, post}, + }, + chrono, + core::{convert::Infallible, error::Error, time::Duration}, + datastar::{ + axum::ReadSignals, + prelude::{ElementPatchMode, PatchElements, PatchSignals}, + }, + serde::{Deserialize, Serialize}, + tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}, +}; + +/// All `data-signals-*` defined in activity-feed.html +#[derive(Serialize, Deserialize)] +pub struct Signals { + // Form inputs + pub interval: u64, + pub events: u64, + // Activity flags + pub generating: bool, + // Output counters + pub total: u64, + pub done: u64, + pub warn: u64, + pub fail: u64, + pub info: u64, +} + +/// All valid event statuses. +// Normalizing variants to lowercase allows parsing routes from `/event/{status}` +// with a `Path` extractor. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Done, + Fail, + Info, + Warn, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + format!("{}=debug,tower_http=debug", env!("CARGO_CRATE_NAME")).into() + }), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let app = Router::new() + .route("/", get(index)) + .route("/event/generate", post(generate)) + .route("/event/{status}", post(event)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?; + + tracing::debug!("listening on {}", listener.local_addr()?); + + axum::serve(listener, app).await?; + + Ok(()) +} + +/// Simple handler returning a static HTML page +async fn index() -> Html<&'static str> { + Html(include_str!("activity-feed.html")) +} + +/// Generates a number of "done" events with a specified interval. +async fn generate(ReadSignals(signals): ReadSignals) -> impl IntoResponse { + // Values we will update in a loop + let mut total = signals.total; + let mut done = signals.done; + + // Start the SSE stream + Sse::new(stream! { + // Signal event generation start + let patch = PatchSignals::new(format!(r#"{{"generating": true}}"#)); + let sse_event = patch.write_as_axum_sse_event(); + yield Ok::<_, Infallible>(sse_event); + + // Yield the events elements and signals to the stream + for _ in 1..=signals.events { + total += 1; + done += 1; + // Append a new entry to the activity feed + let elements = event_entry(&Status::Done, total, "Auto"); + let patch = PatchElements::new(elements).selector("#feed").mode(ElementPatchMode::After); + let sse_event = patch.write_as_axum_sse_event(); + yield Ok::<_, Infallible>(sse_event); + + // Update the event counts + let patch = PatchSignals::new(format!(r#"{{"total": {total}, "done": {done}}}"#)); + let sse_event = patch.write_as_axum_sse_event(); + yield Ok::<_, Infallible>(sse_event); + tokio::time::sleep(Duration::from_millis(signals.interval)).await; + } + + // Signal event generation end + let patch = PatchSignals::new(format!(r#"{{"generating": false}}"#)); + let sse_event = patch.write_as_axum_sse_event(); + yield Ok::<_, Infallible>(sse_event); + }) +} + +/// Creates one event with a given status +async fn event( + Path(status): Path, + ReadSignals(signals): ReadSignals, +) -> impl IntoResponse { + // Create the event stream, since we're patching both an element and a signal. + Sse::new(stream! { + // Signal the updated event counts + let total = signals.total + 1; + let signals = match status { + Status::Done => format!(r#"{{"total": {total}, "done": {}}}"#, signals.done + 1), + Status::Warn => format!(r#"{{"total": {total}, "warn": {}}}"#, signals.warn + 1), + Status::Fail => format!(r#"{{"total": {total}, "fail": {}}}"#, signals.fail + 1), + Status::Info => format!(r#"{{"total": {total}, "info": {}}}"#, signals.info + 1), + }; + let patch = PatchSignals::new(signals); + let sse_signal = patch.write_as_axum_sse_event(); + yield Ok::<_, Infallible>(sse_signal); + + // Patch an element and append it to the feed + let elements = event_entry(&status, total, "Manual"); + let patch = PatchElements::new(elements).selector("#feed").mode(ElementPatchMode::After); + let sse_event = patch.write_as_axum_sse_event(); + yield Ok::<_, Infallible>(sse_event); + }) +} + +/// Returns an HTML string for the entry +fn event_entry(status: &Status, index: u64, source: &str) -> String { + let timestamp = chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S%.3f") + .to_string(); + let (color, indicator) = match status { + Status::Done => ("green", "✅ Done"), + Status::Warn => ("yellow", "⚠️ Warn"), + Status::Fail => ("red", "❌ Fail"), + Status::Info => ("blue", "ℹ️ Info"), + }; + format!( + "
{timestamp} [ {indicator} ] {source} event {index}
" + ) +}