simple-web-app

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

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