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
+

+
+
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}
"
+ )
+}