commit f82ffb0051398af639e7cacc8753de2576687242
parent a5c79d2e7b71b6f896cb054cfaf89f8f1c874ee8
Author: Silas Brack <silasbrack@gmail.com>
Date: Thu, 11 Jun 2026 13:43:36 +0200
fix: replace broken SSE bridge with EventSource + Datastar @get
The server-side SSE bridge for comments was returning the full
story page instead of just comments. Replaced with a simpler
approach: EventSource subscribes to TrailBase SSE for notifications,
then dispatches a custom event that triggers Datastar's @get to
fetch a properly-formatted datastar-patch-elements comments fragment.
This gives us Datastar morphing without the complexity of bridging
two SSE systems server-side.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 43 insertions(+), 124 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2080,7 +2080,6 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
- "tokio-stream",
"tracing",
"trailbase-client",
"url",
@@ -2344,18 +2343,6 @@ 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,7 +9,6 @@ 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,18 +1,16 @@
-use std::convert::Infallible;
-
use askama::Template;
use axum::{
- extract::{Path, State},
- response::{Html, Redirect, sse::{Event, Sse}},
+ extract::{Path, Query, State},
+ response::{Html, IntoResponse, Redirect, Response},
Form,
};
use axum_extra::extract::cookie::CookieJar;
use serde::Deserialize;
-use tokio_stream::StreamExt;
use crate::db::{CommentWithMeta, StoryWithMeta};
use crate::error::AppError;
use crate::handlers::auth::get_tokens_from_cookies;
+use crate::sse;
use crate::state::AppState;
use crate::templates::{ApplicationTemplate, CommentsTemplate, EditTemplate, StoryTemplate, SubmitTemplate};
use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY};
@@ -71,17 +69,45 @@ 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>,
-) -> Result<Html<String>, AppError> {
+ Query(query): Query<StoryQuery>,
+) -> Result<Response, AppError> {
let tokens = get_tokens_from_cookies(&jar);
let client = match &tokens {
Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?,
None => trailbase::new_client(&state.trailbase_url)?,
};
+ // Return comments as a Datastar SSE 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,
+ };
+ let inner_html = template.render()?;
+ let html = format!(
+ "<section class=\"comments\" id=\"comments-section\">{}</section>",
+ inner_html
+ );
+ return Ok(sse::patch_elements(&html));
+ }
+
// Fetch the story
let story = trailbase::get_story(&client, id)
.await?
@@ -135,7 +161,7 @@ pub async fn show_story(
let app = ApplicationTemplate {
content: story_template.render()?,
};
- Ok(Html(app.render()?))
+ Ok(Html(app.render()?).into_response())
}
pub async fn show_submit(
@@ -387,104 +413,3 @@ 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,7 +73,6 @@ 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
@@ -81,7 +81,16 @@
{% endif %}
<section class="comments" id="comments-section"
- data-on:load="@get('/story/{{ story.id }}/comments-stream')">
+ data-on:comments-updated="@get('/story/{{ story.id }}?fragment=comments')">
{% include "comments.html" %}
</section>
+
+ <script>
+ (function() {
+ var evtSource = new EventSource('/api/records/v1/comment/subscribe/*?filter[story_id]={{ story.id }}');
+ evtSource.onmessage = function() {
+ document.getElementById('comments-section').dispatchEvent(new CustomEvent('comments-updated'));
+ };
+ })();
+ </script>
</div>