commit 2d4964582bb427d63881a6f6cd5b3b09f35264a9
parent c4e901da35b7a269e129daa060171f4ee0131356
Author: Silas Brack <silasbrack@gmail.com>
Date: Thu, 11 Jun 2026 12:36:12 +0200
feat: replace vanilla JS comment updates with Datastar SSE stream
Replaces the manual EventSource + fetch + innerHTML approach with
a proper Datastar SSE integration. A new /story/{id}/comments-stream
endpoint bridges TrailBase SSE subscriptions to datastar-patch-elements
events, letting Datastar morph the comments section instead of
destroying DOM state with innerHTML.
Removes the now-unused ?fragment=comments endpoint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 123 insertions(+), 39 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2080,6 +2080,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
+ "tokio-stream",
"tracing",
"trailbase-client",
"url",
@@ -2343,6 +2344,18 @@ dependencies = [
]
[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -9,6 +9,7 @@ axum-extra = { version = "0.10", features = ["cookie"] }
askama = "0.12"
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
+tokio-stream = { version = "0.1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
diff --git a/src/handlers/story.rs b/src/handlers/story.rs
@@ -1,11 +1,14 @@
+use std::convert::Infallible;
+
use askama::Template;
use axum::{
- extract::{Path, Query, State},
- response::{Html, Redirect},
+ extract::{Path, State},
+ response::{Html, Redirect, sse::{Event, Sse}},
Form,
};
use axum_extra::extract::cookie::CookieJar;
use serde::Deserialize;
+use tokio_stream::StreamExt;
use crate::db::{CommentWithMeta, StoryWithMeta};
use crate::error::AppError;
@@ -68,22 +71,10 @@ async fn fetch_enriched_comments(
Ok(enriched)
}
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum StoryFragment {
- Comments,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct StoryQuery {
- pub fragment: Option<StoryFragment>,
-}
-
pub async fn show_story(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
- Query(query): Query<StoryQuery>,
) -> Result<Html<String>, AppError> {
let tokens = get_tokens_from_cookies(&jar);
let client = match &tokens {
@@ -91,17 +82,6 @@ pub async fn show_story(
None => trailbase::new_client(&state.trailbase_url)?,
};
- // Return just the comments fragment if requested
- if matches!(query.fragment, Some(StoryFragment::Comments)) {
- let enriched_comments = fetch_enriched_comments(&state, &tokens, &client, id).await?;
- let is_logged_in = tokens.is_some();
- let template = CommentsTemplate {
- comments: enriched_comments,
- is_logged_in,
- };
- return Ok(Html(template.render()?));
- }
-
// Fetch the story
let story = trailbase::get_story(&client, id)
.await?
@@ -407,3 +387,104 @@ fn render_edit_error(story_id: i64, form: &EditForm, all_tags: &[trailbase::Cate
};
Html(app.render().unwrap_or_default())
}
+
+/// SSE endpoint that bridges TrailBase comment subscriptions to Datastar
+/// patch events. Datastar connects here via `@get` and receives morphable
+/// HTML fragments whenever comments change.
+pub async fn comments_stream(
+ State(state): State<AppState>,
+ jar: CookieJar,
+ Path(story_id): Path<i64>,
+) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
+ let tokens = get_tokens_from_cookies(&jar);
+ let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(10);
+
+ tokio::spawn(async move {
+ let subscribe_url = format!(
+ "{}/api/records/v1/comment/subscribe/*?filter[story_id]={}",
+ state.trailbase_url.trim_end_matches('/'),
+ story_id
+ );
+
+ let response = match state.http_client.get(&subscribe_url).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::error!("Failed to subscribe to comments for story {}: {}", story_id, e);
+ return;
+ }
+ };
+
+ let mut byte_stream = response.bytes_stream();
+ let mut buffer = Vec::new();
+
+ while let Some(chunk) = byte_stream.next().await {
+ let chunk = match chunk {
+ Ok(c) => c,
+ Err(_) => break,
+ };
+ buffer.extend_from_slice(&chunk);
+
+ // Detect SSE event boundary (double newline)
+ let has_event = buffer.windows(2).any(|w| w == b"\n\n");
+ if !has_event {
+ continue;
+ }
+ buffer.clear();
+
+ // Fetch fresh comments
+ let client = match &tokens {
+ Some(t) => match trailbase::client_with_tokens(&state.trailbase_url, t) {
+ Ok(c) => c,
+ Err(_) => continue,
+ },
+ None => match trailbase::new_client(&state.trailbase_url) {
+ Ok(c) => c,
+ Err(_) => continue,
+ },
+ };
+
+ let comments = match fetch_enriched_comments(&state, &tokens, &client, story_id).await {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!("Failed to fetch comments for SSE: {}", e);
+ continue;
+ }
+ };
+
+ let is_logged_in = tokens.is_some();
+ let template = CommentsTemplate {
+ comments,
+ is_logged_in,
+ };
+ let inner_html = match template.render() {
+ Ok(h) => h,
+ Err(_) => continue,
+ };
+
+ // Build data lines for Datastar patch event
+ let html = format!(
+ "<section class=\"comments\" id=\"comments-section\">{}</section>",
+ inner_html
+ );
+ let data: String = html
+ .lines()
+ .map(|line| format!("elements {}", line))
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ let event = Event::default()
+ .event("datastar-patch-elements")
+ .data(data);
+
+ if tx.send(Ok(event)).await.is_err() {
+ break; // Client disconnected
+ }
+ }
+ });
+
+ let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
+ Sse::new(stream).keep_alive(
+ axum::response::sse::KeepAlive::new()
+ .interval(std::time::Duration::from_secs(30)),
+ )
+}
diff --git a/src/routes.rs b/src/routes.rs
@@ -73,6 +73,7 @@ pub fn create_router(state: AppState) -> Router {
.route("/story/{id}/edit", post(handlers::edit_story))
// Comments
+ .route("/story/{id}/comments-stream", get(handlers::comments_stream))
.route("/story/{id}/comment", post(handlers::create_comment))
.route("/comment/{id}/reply", get(handlers::show_reply_form))
.route("/comment/{id}/reply", post(handlers::submit_reply))
diff --git a/templates/story.html b/templates/story.html
@@ -80,20 +80,8 @@
<p class="login-prompt"><a href="/login">Log in</a> to comment.</p>
{% endif %}
- <section class="comments" id="comments-section">
+ <section class="comments" id="comments-section"
+ data-on:load="@get('/story/{{ story.id }}/comments-stream')">
{% include "comments.html" %}
</section>
-
- <script>
- (function() {
- var evtSource = new EventSource('/api/records/v1/comment/subscribe/*?filter[story_id]={{ story.id }}');
- evtSource.onmessage = function(event) {
- fetch('/story/{{ story.id }}?fragment=comments')
- .then(function(r) { return r.text(); })
- .then(function(html) {
- document.getElementById('comments-section').innerHTML = html;
- });
- };
- })();
- </script>
</div>