simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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:
MCargo.lock | 13-------------
MCargo.toml | 1-
Msrc/handlers/story.rs | 141+++++++++++++++++++------------------------------------------------------------
Msrc/routes.rs | 1-
Mtemplates/story.html | 11++++++++++-
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>