add rama support to datastar rust sdk (#847)

* add rama support to datastar rust sdk

Closes #845

* bump rama alpha version
This commit is contained in:
Glen De Cauwsemaecker 2025-04-17 05:05:33 +02:00 committed by GitHub
parent 6b2d488ae6
commit 10b29846ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 334 additions and 16 deletions

View File

@ -152,6 +152,7 @@ func writeOutConsts(version string) error {
"examples/ruby/hello-world/hello-world.html": helloWorldExample,
"examples/rust/axum/hello-world/hello-world.html": helloWorldExample,
"examples/rust/rocket/hello-world/hello-world.html": helloWorldExample,
"examples/rust/rama/hello-world/hello-world.html": helloWorldExample,
}
for path, tmplFn := range templates {

View File

@ -1,7 +1,8 @@
[package]
edition = "2021"
edition = "2024"
name = "hello_world"
version = "0.1.0"
rust-version = "1.85.0"
[dependencies]
async-stream = "0.3.6"

View File

@ -0,0 +1,2 @@
target
Cargo.lock

View File

@ -0,0 +1,16 @@
[package]
edition = "2024"
name = "hello_world"
version = "0.1.0"
rust-version = "1.85.0"
[dependencies]
async-stream = "0.3.6"
rama = { version = "0.2.0-alpha.11", features = ["http-full"] }
datastar = { path = "../../../../sdk/rust", features = ["rama"] }
futures = "0.3.31"
serde = { version = "1.0.217", features = ["derive"] }
tokio = { version = "1.43.0", features = ["full"] }
tokio-stream = "0.1.17"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@ -0,0 +1,35 @@
<!-- This is auto-generated by Datastar. DO NOT EDIT. -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Datastar SDK Demo</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.11/bundles/datastar.js"></script>
</head>
<body class="bg-white dark:bg-gray-900 text-lg max-w-xl mx-auto my-16">
<div data-signals-delay="400" class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg px-6 py-8 ring shadow-xl ring-gray-900/5 space-y-2">
<div class="flex justify-between items-center">
<h1 class="text-gray-900 dark:text-white text-3xl font-semibold">
Datastar SDK Demo
</h1>
<img src="https://data-star.dev/static/images/rocket.png" alt="Rocket" width="64" height="64"/>
</div>
<p class="mt-2">
SSE events will be streamed from the backend to the frontend.
</p>
<div class="space-x-2">
<label for="delay">
Delay in milliseconds
</label>
<input data-bind-delay id="delay" type="number" step="100" min="0" class="w-36 rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-sky-500 focus:outline focus:outline-sky-500 dark:disabled:border-gray-700 dark:disabled:bg-gray-800/20" />
</div>
<button data-on-click="@get(&#39;/hello-world&#39;)" class="rounded-md bg-sky-500 px-5 py-2.5 leading-5 font-semibold text-white hover:bg-sky-700 hover:text-gray-100 cursor-pointer">
Start
</button>
</div>
<div class="my-16 text-8xl font-bold text-transparent" style="background: linear-gradient(to right in oklch, red, orange, yellow, green, blue, blue, violet); background-clip: text">
<div id="message">Hello, world!</div>
</div>
</body>
</html>

View File

@ -0,0 +1,57 @@
use {
async_stream::stream,
core::time::Duration,
datastar::{
Sse,
prelude::{MergeFragments, ReadSignals},
},
rama::{
error::BoxError,
http::{IntoResponse, response::Html, server::HttpServer, service::web::Router},
rt::Executor,
},
serde::Deserialize,
tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt},
};
#[tokio::main]
async fn main() -> Result<(), BoxError> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let app = Router::new()
.get("/", index)
.get("/hello-world", hello_world);
tracing::debug!("listening on 127.0.0.1:3000");
HttpServer::auto(Executor::default())
.listen("127.0.0.1:3000", app)
.await?;
Ok(())
}
async fn index() -> Html<&'static str> {
Html(include_str!("../hello-world.html"))
}
const MESSAGE: &str = "Hello, world!";
#[derive(Deserialize)]
pub struct Signals {
pub delay: u64,
}
async fn hello_world(ReadSignals(signals): ReadSignals<Signals>) -> impl IntoResponse {
Sse(stream! {
for i in 0..MESSAGE.len() {
yield MergeFragments::new(format!("<div id='message'>{}</div>", &MESSAGE[0..i + 1])).into();
tokio::time::sleep(Duration::from_millis(signals.delay)).await;
}
})
}

View File

@ -1,7 +1,8 @@
[package]
edition = "2021"
edition = "2024"
name = "hello_world"
version = "0.1.0"
rust-version = "1.85.0"
[dependencies]
datastar = { path = "../../../../sdk/rust", features = ["rocket"] }

View File

@ -1,8 +1,11 @@
[package]
authors = ["Johnathan Stevers <jmstevers@gmail.com>"]
authors = [
"Johnathan Stevers <jmstevers@gmail.com>",
"Glen Henri J. De Cauwsemaecker <glen@plabayo.tech>",
]
categories = ["web-programming"]
description = "Datastar is the Rust implementation of the [Datastar](https://data-star.dev) SDK."
edition = "2021"
edition = "2024"
homepage = "https://data-star.dev"
keywords = ["datastar", "web", "backend"]
license = "MIT OR Apache-2.0"
@ -10,34 +13,39 @@ name = "datastar"
readme = "README.md"
repository = "https://github.com/starfederation/datastar-rs"
version = "0.1.0"
rust-version = "1.85.0"
[dev-dependencies]
async-stream = { version = "0.3.6", default-features = false }
serde = { version = "1.0.217", default-features = false, features = ["derive"] }
serde_json = { version = "1.0.138", default-features = false, features = [
"std",
] }
serde = { version = "1", default-features = false, features = ["derive"] }
serde_json = { version = "1", default-features = false, features = ["std"] }
tokio = { version = "1.43.0", features = ["full"] }
axum = { version = "0.8.1" }
rocket = { version = "0.5.1", features = ["json"] }
rama = { version = "0.2.0-alpha.10", features = ["http-full"] }
[dependencies]
matchit = "0.8.4"
axum = { version = "0.8.1", default-features = false, optional = true, features = [
"query",
"tokio",
] }
futures-util = { version = "0.3.31", default-features = false }
http-body = { version = "1.0.1", default-features = false, optional = true }
pin-project-lite = { version = "0.2.16", default-features = false, optional = true }
futures-util = { version = "0.3", default-features = false }
http-body = { version = "1.0", default-features = false, optional = true }
pin-project-lite = { version = "0.2", default-features = false, optional = true }
rocket = { version = "0.5.1", default-features = false, optional = true }
serde = { version = "1.0.217", default-features = false, optional = true, features = [
rama = { version = "0.2.0-alpha.11", default-features = false, optional = true, features = [
"http",
] }
serde = { version = "1", default-features = false, optional = true, features = [
"derive",
] }
serde_json = { version = "1.0.138", default-features = false, optional = true, features = [
serde_json = { version = "1", default-features = false, optional = true, features = [
"std",
] }
sync_wrapper = { version = "1.0.2", default-features = false, optional = true }
sync_wrapper = { version = "1", default-features = false, optional = true }
bytes = { version = "1", default-features = false, optional = true }
[features]
@ -51,6 +59,14 @@ axum = [
]
http2 = []
rocket = ["dep:rocket"]
rama = [
"dep:rama",
"dep:serde",
"dep:serde_json",
"dep:pin-project-lite",
"dep:bytes",
"dep:sync_wrapper",
]
[profile.dev]
opt-level = 1

View File

@ -1,6 +1,6 @@
# Datastar Rust SDK
An implementation of the Datastar SDK in Rust with framework integration for Axum and Rocket.
An implementation of the Datastar SDK in Rust with framework integration for Axum, Rocket and Rama.
# Usage

View File

@ -5,6 +5,8 @@
#[cfg(feature = "axum")]
pub mod axum;
#[cfg(feature = "rama")]
pub mod rama;
#[cfg(feature = "rocket")]
pub mod rocket;
@ -23,6 +25,8 @@ mod consts;
pub mod prelude {
#[cfg(feature = "axum")]
pub use crate::axum::ReadSignals;
#[cfg(all(feature = "rama", not(feature = "axum")))]
pub use crate::rama::ReadSignals;
pub use crate::{
consts::FragmentMergeMode, execute_script::ExecuteScript, merge_fragments::MergeFragments,
merge_signals::MergeSignals, remove_fragments::RemoveFragments,

185
sdk/rust/src/rama.rs Normal file
View File

@ -0,0 +1,185 @@
//! Rama integration for Datastar.
//!
//! Learn more about rama at
//! <https://github.com/plabayo/rama>.
use {
crate::{Sse, TrySse, prelude::DatastarEvent},
bytes::Bytes,
futures_util::{Stream, StreamExt},
pin_project_lite::pin_project,
rama::http::{
Body, BodyExtractExt, IntoResponse, Method, Request, Response, StatusCode,
dep::http_body::{Body as HttpBody, Frame},
header,
service::web::extract::{FromRequest, Query},
},
serde::{Deserialize, de::DeserializeOwned},
std::{
convert::Infallible,
pin::Pin,
task::{Context, Poll},
},
sync_wrapper::SyncWrapper,
};
pin_project! {
struct SseBody<S> {
#[pin]
stream: SyncWrapper<S>,
}
}
impl<S> IntoResponse for Sse<S>
where
S: Stream<Item = DatastarEvent> + Send + 'static,
{
fn into_response(self) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream"),
(header::CACHE_CONTROL, "no-cache"),
(header::CONNECTION, "keep-alive"),
],
Body::new(SseBody {
stream: SyncWrapper::new(self.0.map(Ok::<_, Infallible>)),
}),
)
.into_response()
}
}
impl<S, E> IntoResponse for TrySse<S>
where
S: Stream<Item = Result<DatastarEvent, E>> + Send + 'static,
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
fn into_response(self) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream"),
(header::CACHE_CONTROL, "no-cache"),
(header::CONNECTION, "keep-alive"),
],
Body::new(SseBody {
stream: SyncWrapper::new(self.0),
}),
)
.into_response()
}
}
impl<S, E> HttpBody for SseBody<S>
where
S: Stream<Item = Result<DatastarEvent, E>>,
{
type Data = Bytes;
type Error = E;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let this = self.project();
match this.stream.get_pin_mut().poll_next(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Ready(Some(Ok(event))) => {
Poll::Ready(Some(Ok(Frame::data(event.to_string().into()))))
}
}
}
}
#[derive(Deserialize)]
struct DatastarParam {
datastar: serde_json::Value,
}
/// [`ReadSignals`] is a request extractor that reads datastar signals from the request.
///
/// # Examples
///
/// ```
/// use datastar::rama::ReadSignals;
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct Signals {
/// foo: String,
/// bar: i32,
/// }
///
/// async fn handler(ReadSignals(signals): ReadSignals<Signals>) {
/// println!("foo: {}", signals.foo);
/// println!("bar: {}", signals.bar);
/// }
///
/// ```
#[derive(Debug)]
pub struct ReadSignals<T: DeserializeOwned>(pub T);
impl<T> FromRequest for ReadSignals<T>
where
T: DeserializeOwned + Send + Sync + 'static,
{
type Rejection = Response;
async fn from_request(req: Request) -> Result<Self, Self::Rejection> {
let json = match *req.method() {
Method::GET => {
let query =
Query::<DatastarParam>::parse_query_str(req.uri().query().unwrap_or(""))
.map_err(IntoResponse::into_response)?;
let signals = query.0.datastar.as_str().ok_or_else(|| {
(StatusCode::BAD_REQUEST, "Failed to parse JSON").into_response()
})?;
serde_json::from_str(signals)
.map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()).into_response())?
}
_ => req
.into_body()
.try_into_json()
.await
.map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()).into_response())?,
};
Ok(Self(json))
}
}
#[cfg(test)]
mod tests {
use {
super::Sse,
crate::{
prelude::ReadSignals,
testing::{self, Signals},
},
rama::{
error::BoxError,
http::{IntoResponse, server::HttpServer, service::web::Router},
rt::Executor,
},
};
async fn test(ReadSignals(signals): ReadSignals<Signals>) -> impl IntoResponse {
Sse(testing::test(signals.events))
}
#[tokio::test]
async fn sdk_test() -> Result<(), BoxError> {
HttpServer::auto(Executor::default())
.listen(
"127.0.0.1:3000",
Router::new().get("/test", test).post("/test", test),
)
.await?;
Ok(())
}
}