WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: deep-dive on real-time architecture (firehose → SSE → HTMX)

Detailed architectural research on connecting the AT Proto Jetstream
firehose to the browser via Server-Sent Events and HTMX's declarative
SSE extension. Covers all three layers:

1. Jetstream consumer (@skyware/jetstream with space.atbb.* filter)
2. In-process EventEmitter → Hono SSE streaming endpoints
3. HTMX sse-swap attributes for zero-JS live DOM updates

Includes concrete code sketches, scaling considerations (EventEmitter
→ Redis Pub/Sub → PostgreSQL LISTEN/NOTIFY), implementation roadmap,
and comparison showing why this is atBB's strongest architectural
differentiator over phpBB/Discourse/Flarum/NodeBB.

https://claude.ai/code/session_012nLGUeocmDKttJz1VYFPPA

Claude cfcad949 6bb7ada9

+616
+616
docs/research/realtime-architecture.md
···
··· 1 + # Real-Time Architecture: Firehose → SSE → HTMX 2 + 3 + *Deep-dive research conducted 2026-02-07* 4 + 5 + --- 6 + 7 + ## The Opportunity 8 + 9 + Every traditional forum (phpBB, Discourse, Flarum) works on a request-response model. You load a topic page. It's a snapshot. If someone replies while you're reading, you don't see it until you manually refresh. Discourse has real-time features via WebSockets, but it's a bolt-on — a separate infrastructure layer they built on top of their standard Rails app. 10 + 11 + atBB is different. The AT Protocol *already has* a real-time event stream (the firehose/Jetstream). The AppView *already needs* to subscribe to it for indexing. The web UI *already uses* HTMX, which has declarative SSE support. The entire pipeline exists — it just needs to be connected. 12 + 13 + **The data flow:** 14 + ``` 15 + User writes post AT Proto Jetstream atBB Browser 16 + to their PDS ───▶ Relay ───▶ (JSON WS) ───▶ AppView ───▶ (HTMX SSE) 17 + indexes + 18 + broadcasts 19 + ``` 20 + 21 + End-to-end latency: ~1–2 seconds from PDS write to browser update. That's fast enough to feel "live" without feeling like chat. 22 + 23 + --- 24 + 25 + ## Architecture Overview 26 + 27 + ### Three Layers 28 + 29 + ``` 30 + ┌─────────────────────────────────────────────────────┐ 31 + │ Browser (HTMX) │ 32 + │ │ 33 + │ ┌────────────┐ ┌──────────┐ ┌─────────────────┐ │ 34 + │ │ Topic View │ │ Category │ │ Notification │ │ 35 + │ │ sse-swap= │ │ View │ │ Badge │ │ 36 + │ │ "newReply" │ │ sse-swap=│ │ sse-swap= │ │ 37 + │ │ │ │ "newTopic│ │ "notification" │ │ 38 + │ └─────┬──────┘ └────┬─────┘ └───────┬─────────┘ │ 39 + │ │ │ │ │ 40 + │ └──────────────┴────────────────┘ │ 41 + │ │ SSE │ 42 + └───────────────────────┼──────────────────────────────┘ 43 + 44 + ┌───────────────────────┼──────────────────────────────┐ 45 + │ @atbb/web (Hono) │ 46 + │ │ │ 47 + │ GET /sse/thread/:id ──────┐ │ 48 + │ GET /sse/category/:id ────┤ SSE │ 49 + │ GET /sse/global ──────────┤ Endpoints │ 50 + │ │ │ 51 + │ ┌─────────────────────────┘ │ 52 + │ │ Subscribe to AppView event bus │ 53 + │ │ Render HTML fragments │ 54 + │ │ Stream as SSE events │ 55 + └────────────┼─────────────────────────────────────────┘ 56 + 57 + ┌────────────┼─────────────────────────────────────────┐ 58 + │ │ @atbb/appview (Hono) │ 59 + │ │ │ 60 + │ ┌─────────▼──────────┐ ┌────────────────────────┐ │ 61 + │ │ Event Bus │ │ Jetstream Consumer │ │ 62 + │ │ (in-process) │◀──│ │ │ 63 + │ │ │ │ space.atbb.* │ │ 64 + │ │ topic:abc → │ │ filter + index │ │ 65 + │ │ [subscriber1] │ │ │ │ 66 + │ │ [subscriber2] │ │ Cursor persistence │ │ 67 + │ │ category:xyz → │ │ Reconnection logic │ │ 68 + │ │ [subscriber3] │ └────────────┬───────────┘ │ 69 + │ └────────────────────┘ │ │ 70 + │ │ │ 71 + │ ┌─────────────────────────────────────▼───────────┐ │ 72 + │ │ PostgreSQL │ │ 73 + │ │ posts | categories | users | memberships │ │ 74 + │ └─────────────────────────────────────────────────┘ │ 75 + └──────────────────────────────────────────────────────┘ 76 + 77 + │ WebSocket (JSON) 78 + 79 + ┌──────────────────────────────────────────────────────┐ 80 + │ Jetstream │ 81 + │ wss://jetstream2.us-east.bsky.network/subscribe │ 82 + │ ?wantedCollections=space.atbb.* │ 83 + └──────────────────────────────────────────────────────┘ 84 + ``` 85 + 86 + ### Why SSE Over WebSocket for the Browser Connection 87 + 88 + | Factor | SSE | WebSocket | 89 + |--------|-----|-----------| 90 + | Direction | Server → Client only | Bidirectional | 91 + | Forum fit | Perfect — users read far more than they write | Overkill | 92 + | User writes | Standard HTMX POST/PUT (already works) | Would need `ws-send` | 93 + | Infrastructure | Works through all proxies, CDNs, load balancers | Needs sticky sessions, special proxy config | 94 + | Reconnection | Browser `EventSource` auto-reconnects natively | Extension handles it | 95 + | Graceful degradation | If SSE breaks, forum still works as normal HTTP | Same | 96 + | HTMX integration | `sse-swap` maps events to DOM targets declaratively | OOB swap by ID only | 97 + | HTTP/2 concern | Uses one connection per stream (H/2 multiplexes) | Separate TCP connection | 98 + 99 + **Forum interactions are fundamentally asymmetric.** Users spend 95% of their time reading. SSE handles the high-volume server→client push (new replies, presence, typing indicators). Standard HTMX POST handles the low-volume client→server actions (submitting replies, reacting). WebSocket's bidirectionality is wasted here. 100 + 101 + --- 102 + 103 + ## Layer 1: Jetstream Consumer (AppView) 104 + 105 + ### Connection Setup 106 + 107 + Use `@skyware/jetstream` for MVP — it provides typed JSON events with cursor management. The full `@atproto/sync` firehose (CBOR + signatures) is available for post-MVP hardening. 108 + 109 + ```typescript 110 + // packages/appview/src/firehose/consumer.ts 111 + 112 + import { Jetstream } from "@skyware/jetstream"; 113 + import type { EventEmitter } from "node:events"; 114 + 115 + interface FirehoseConsumer { 116 + start(): void; 117 + stop(): void; 118 + events: EventEmitter; // broadcast bus 119 + } 120 + 121 + function createFirehoseConsumer(db: Database): FirehoseConsumer { 122 + const events = new EventEmitter(); 123 + 124 + const jetstream = new Jetstream({ 125 + endpoint: "wss://jetstream2.us-east.bsky.network/subscribe", 126 + wantedCollections: ["space.atbb.*"], 127 + cursor: loadCursorFromDb(db), // microsecond timestamp 128 + }); 129 + 130 + // Index new posts and broadcast for SSE 131 + jetstream.onCreate("space.atbb.post", async (event) => { 132 + const { did, commit, time_us } = event; 133 + const post = await indexPost(db, did, commit.rkey, commit.cid, commit.record); 134 + saveCursor(db, time_us); 135 + 136 + // Determine broadcast channel 137 + if (post.rootPostId) { 138 + // It's a reply — broadcast to the thread channel 139 + events.emit(`topic:${post.rootPostId}`, { type: "newReply", post }); 140 + } else { 141 + // It's a new topic — broadcast to the category channel 142 + events.emit(`category:${post.categoryId}`, { type: "newTopic", post }); 143 + } 144 + 145 + // Always broadcast to global (for notification badges, etc.) 146 + events.emit("global", { type: "newPost", post }); 147 + }); 148 + 149 + jetstream.onDelete("space.atbb.post", async (event) => { 150 + const { did, commit, time_us } = event; 151 + const post = await softDeletePost(db, did, commit.rkey); 152 + saveCursor(db, time_us); 153 + 154 + if (post) { 155 + events.emit(`topic:${post.rootPostId}`, { type: "postDeleted", post }); 156 + } 157 + }); 158 + 159 + // Index other record types similarly... 160 + jetstream.onCreate("space.atbb.forum.category", async (event) => { 161 + await indexCategory(db, event.did, event.commit.rkey, event.commit.record); 162 + saveCursor(db, event.time_us); 163 + events.emit("global", { type: "categoryUpdate" }); 164 + }); 165 + 166 + jetstream.onCreate("space.atbb.reaction", async (event) => { 167 + const reaction = await indexReaction(db, event.did, event.commit); 168 + saveCursor(db, event.time_us); 169 + events.emit(`topic:${reaction.topicId}`, { type: "newReaction", reaction }); 170 + }); 171 + 172 + return { 173 + start: () => jetstream.start(), 174 + stop: () => jetstream.close(), 175 + events, 176 + }; 177 + } 178 + ``` 179 + 180 + ### Jetstream Filtering 181 + 182 + Jetstream supports NSID prefix wildcards: 183 + 184 + ``` 185 + ?wantedCollections=space.atbb.* 186 + ``` 187 + 188 + This catches `space.atbb.post`, `space.atbb.forum.forum`, `space.atbb.forum.category`, `space.atbb.membership`, `space.atbb.reaction`, `space.atbb.modAction` — everything in the atBB namespace. 189 + 190 + ### Cursor Management 191 + 192 + Jetstream events have a `time_us` field (Unix microseconds). Persist this as a cursor: 193 + 194 + ```typescript 195 + // Save every 100 events (not every event — that would hammer the DB) 196 + let eventsSinceSave = 0; 197 + function saveCursor(db: Database, cursor: number) { 198 + eventsSinceSave++; 199 + if (eventsSinceSave >= 100) { 200 + db.execute("UPDATE firehose_state SET cursor = $1", [cursor]); 201 + eventsSinceSave = 0; 202 + } 203 + } 204 + 205 + // On reconnect, rewind 5 seconds for safety (process events idempotently) 206 + function loadCursorFromDb(db: Database): number | undefined { 207 + const row = db.queryOne("SELECT cursor FROM firehose_state"); 208 + if (!row?.cursor) return undefined; 209 + return row.cursor - 5_000_000; // 5 seconds in microseconds 210 + } 211 + ``` 212 + 213 + **Backfill window:** Jetstream retains ~24 hours of events. If the AppView is offline longer, fall back to `com.atproto.sync.getRepo` for known DIDs. 214 + 215 + --- 216 + 217 + ## Layer 2: Event Bus → SSE Endpoints (Web Package) 218 + 219 + The web package subscribes to the AppView's event bus and renders HTML fragments streamed to the browser. 220 + 221 + ### Option A: Internal Event Bus (Single-Process) 222 + 223 + If appview and web run in the same process (or web calls an appview SSE endpoint): 224 + 225 + ```typescript 226 + // packages/appview/src/routes/events.ts 227 + 228 + import { Hono } from "hono"; 229 + import { streamSSE } from "hono/streaming"; 230 + 231 + const app = new Hono(); 232 + 233 + // SSE endpoint for a specific thread 234 + app.get("/api/events/topic/:id", async (c) => { 235 + const topicId = c.req.param("id"); 236 + 237 + return streamSSE(c, async (stream) => { 238 + // Send initial connection confirmation 239 + await stream.writeSSE({ event: "connected", data: "ok" }); 240 + 241 + // Heartbeat to prevent proxy timeouts 242 + const heartbeat = setInterval(async () => { 243 + await stream.writeSSE({ event: "heartbeat", data: "" }); 244 + }, 30_000); 245 + 246 + // Subscribe to topic events 247 + const handler = async (event: ForumEvent) => { 248 + const html = renderEventHtml(event); // JSX → HTML string 249 + await stream.writeSSE({ 250 + event: event.type, // "newReply", "newReaction", "postDeleted" 251 + data: html, 252 + }); 253 + }; 254 + 255 + firehoseConsumer.events.on(`topic:${topicId}`, handler); 256 + 257 + // Cleanup on disconnect 258 + stream.onAbort(() => { 259 + clearInterval(heartbeat); 260 + firehoseConsumer.events.off(`topic:${topicId}`, handler); 261 + }); 262 + }); 263 + }); 264 + 265 + // SSE endpoint for a category (new topics) 266 + app.get("/api/events/category/:id", async (c) => { 267 + const categoryId = c.req.param("id"); 268 + 269 + return streamSSE(c, async (stream) => { 270 + const heartbeat = setInterval(async () => { 271 + await stream.writeSSE({ event: "heartbeat", data: "" }); 272 + }, 30_000); 273 + 274 + const handler = async (event: ForumEvent) => { 275 + const html = renderEventHtml(event); 276 + await stream.writeSSE({ event: event.type, data: html }); 277 + }; 278 + 279 + firehoseConsumer.events.on(`category:${categoryId}`, handler); 280 + 281 + stream.onAbort(() => { 282 + clearInterval(heartbeat); 283 + firehoseConsumer.events.off(`category:${categoryId}`, handler); 284 + }); 285 + }); 286 + }); 287 + ``` 288 + 289 + ### Option B: AppView Exposes SSE, Web Proxies It 290 + 291 + If appview and web are separate processes, the web package can either: 292 + 1. Proxy the SSE stream from appview directly 293 + 2. Consume appview SSE internally and re-emit with HTML rendering 294 + 295 + Option 1 is simpler — the appview SSE endpoint returns HTML fragments, and the web package's JSX templates include `sse-connect` pointing at the appview. 296 + 297 + ### HTML Fragment Rendering 298 + 299 + The key insight: SSE events carry **pre-rendered HTML fragments**, not JSON. This is what makes HTMX SSE zero-JS on the client. 300 + 301 + ```typescript 302 + // packages/web/src/components/ReplyCard.tsx 303 + 304 + import type { FC } from "hono/jsx"; 305 + 306 + interface ReplyCardProps { 307 + author: string; 308 + authorDid: string; 309 + text: string; 310 + createdAt: string; 311 + replyCount?: number; 312 + } 313 + 314 + export const ReplyCard: FC<ReplyCardProps> = (props) => ( 315 + <article class="reply" id={`reply-${props.authorDid}-${props.createdAt}`}> 316 + <header class="reply-meta"> 317 + <a href={`/user/${props.authorDid}`} class="reply-author"> 318 + {props.author} 319 + </a> 320 + <time datetime={props.createdAt}> 321 + {new Date(props.createdAt).toLocaleString()} 322 + </time> 323 + </header> 324 + <div class="reply-body"> 325 + {props.text} 326 + </div> 327 + </article> 328 + ); 329 + 330 + // Render to string for SSE 331 + function renderReplyHtml(post: IndexedPost): string { 332 + // Hono JSX can render to string server-side 333 + return renderToString(<ReplyCard 334 + author={post.authorHandle} 335 + authorDid={post.authorDid} 336 + text={post.text} 337 + createdAt={post.createdAt} 338 + />); 339 + } 340 + ``` 341 + 342 + --- 343 + 344 + ## Layer 3: HTMX SSE in the Browser 345 + 346 + ### Topic View (Thread Page) 347 + 348 + ```tsx 349 + // packages/web/src/routes/topic.tsx 350 + 351 + export const TopicView: FC<TopicViewProps> = ({ topic, replies }) => ( 352 + <BaseLayout title={topic.title}> 353 + {/* SSE connection scoped to this thread */} 354 + <div hx-ext="sse" sse-connect={`/api/events/topic/${topic.id}`}> 355 + 356 + {/* Thread header */} 357 + <article class="topic-op"> 358 + <h1>{topic.title}</h1> 359 + <div class="post-body">{topic.text}</div> 360 + <div class="post-meta"> 361 + by <a href={`/user/${topic.authorDid}`}>{topic.author}</a> 362 + {" · "} 363 + <time datetime={topic.createdAt}>{topic.createdAt}</time> 364 + </div> 365 + </article> 366 + 367 + {/* Reply list — new replies streamed in at the bottom */} 368 + <section id="replies"> 369 + {replies.map((reply) => ( 370 + <ReplyCard {...reply} /> 371 + ))} 372 + 373 + {/* This target receives new replies via SSE */} 374 + <div sse-swap="newReply" hx-swap="beforebegin"></div> 375 + </section> 376 + 377 + {/* Reaction updates swap into specific post elements via OOB */} 378 + {/* (The SSE "newReaction" event sends OOB-targeted HTML) */} 379 + 380 + {/* Reply count badge — updated in real-time */} 381 + <span id="reply-count" sse-swap="replyCount"> 382 + {replies.length} replies 383 + </span> 384 + 385 + {/* Typing indicator */} 386 + <div id="typing-indicator" sse-swap="typing"></div> 387 + 388 + </div> 389 + 390 + {/* Reply form — standard HTMX POST (not SSE) */} 391 + <form hx-post={`/api/topics/${topic.id}/reply`} 392 + hx-swap="none" 393 + hx-on::after-request="this.reset()"> 394 + <textarea name="text" placeholder="Write a reply..." 395 + required minlength="1" maxlength="3000"></textarea> 396 + <button type="submit">Reply</button> 397 + </form> 398 + </BaseLayout> 399 + ); 400 + ``` 401 + 402 + ### Category View (Topic List) 403 + 404 + ```tsx 405 + export const CategoryView: FC<CategoryViewProps> = ({ category, topics }) => ( 406 + <BaseLayout title={category.name}> 407 + <div hx-ext="sse" sse-connect={`/api/events/category/${category.id}`}> 408 + 409 + <h1>{category.name}</h1> 410 + <p>{category.description}</p> 411 + 412 + <table class="topic-list"> 413 + <thead> 414 + <tr> 415 + <th>Topic</th> 416 + <th>Author</th> 417 + <th>Replies</th> 418 + <th>Last Post</th> 419 + </tr> 420 + </thead> 421 + <tbody id="topic-list-body"> 422 + {topics.map((topic) => <TopicRow {...topic} />)} 423 + 424 + {/* New topics appear at the top */} 425 + <tr sse-swap="newTopic" hx-swap="afterbegin" hx-target="#topic-list-body"> 426 + </tr> 427 + </tbody> 428 + </table> 429 + 430 + </div> 431 + </BaseLayout> 432 + ); 433 + ``` 434 + 435 + ### Global Notification Badge 436 + 437 + This could be placed in the base layout so it works on every page: 438 + 439 + ```tsx 440 + // packages/web/src/layouts/base.tsx 441 + 442 + export const BaseLayout: FC<BaseLayoutProps> = (props) => ( 443 + <html> 444 + <head> 445 + <script src="https://unpkg.com/htmx.org@2.0.4" /> 446 + <script src="https://unpkg.com/htmx-ext-sse@2.2.3/sse.js" /> 447 + </head> 448 + <body hx-boost="true"> 449 + <header hx-ext="sse" sse-connect="/api/events/global"> 450 + <nav> 451 + <a href="/">atBB Forum</a> 452 + {/* Notification badge — updated live */} 453 + <span id="notification-badge" sse-swap="notification"></span> 454 + </nav> 455 + </header> 456 + <main> 457 + {props.children} 458 + </main> 459 + </body> 460 + </html> 461 + ); 462 + ``` 463 + 464 + --- 465 + 466 + ## What This Enables (Concrete Features) 467 + 468 + ### Tier 1: Easy Wins (MVP-compatible) 469 + 470 + | Feature | SSE Event | Behavior | 471 + |---------|-----------|----------| 472 + | **Live replies** | `newReply` | New reply appears at bottom of thread without refresh | 473 + | **Live new topics** | `newTopic` | New topic appears at top of category view | 474 + | **Reply count** | `replyCount` | Reply count badge updates in real-time | 475 + | **Post deletion** | `postDeleted` | Deleted post fades out or shows "[deleted]" | 476 + | **Mod actions** | `modAction` | Locked/pinned status updates live | 477 + 478 + ### Tier 2: Enhanced UX (Post-MVP) 479 + 480 + | Feature | SSE Event | Behavior | 481 + |---------|-----------|----------| 482 + | **Typing indicator** | `typing` | "User X is typing..." shown below reply list | 483 + | **Online presence** | `presence` | "12 users viewing this topic" | 484 + | **Reaction animations** | `newReaction` | Like/upvote count increments with animation | 485 + | **Unread badges** | `notification` | Global nav shows unread count for subscribed topics | 486 + | **Topic bumping** | `topicBumped` | Topic list reorders when a topic gets a new reply | 487 + 488 + ### Tier 3: Differentiators (Future) 489 + 490 + | Feature | Description | 491 + |---------|-------------| 492 + | **Cross-forum activity feed** | Since AT Proto identities span forums, show activity from all forums a user participates in | 493 + | **Live moderation dashboard** | Stream mod queue events in real-time | 494 + | **"Someone replied to your post" toasts** | Non-intrusive notification popups | 495 + 496 + --- 497 + 498 + ## Scaling Considerations 499 + 500 + ### Single Instance (MVP) 501 + 502 + For MVP, an in-process `EventEmitter` is sufficient: 503 + 504 + ``` 505 + Jetstream → AppView process (index + EventEmitter) → SSE streams to browsers 506 + ``` 507 + 508 + No external infrastructure needed. The EventEmitter holds subscriber lists in memory. 509 + 510 + **Capacity:** A single Node.js process can comfortably hold 1,000+ SSE connections. For a self-hosted forum, this is more than enough. 511 + 512 + ### Multi-Instance (Production) 513 + 514 + If atBB needs horizontal scaling: 515 + 516 + ``` 517 + Jetstream → AppView Instance 1 ──┐ 518 + ├── Redis Pub/Sub ──┬── Web Instance 1 → SSE 519 + Jetstream → AppView Instance 2 ──┘ └── Web Instance 2 → SSE 520 + ``` 521 + 522 + - Only one AppView instance should consume Jetstream (use leader election or a single indexer process) 523 + - That instance publishes events to Redis Pub/Sub 524 + - All web instances subscribe to Redis and stream to their connected clients 525 + - **Alternative:** Use PostgreSQL `LISTEN/NOTIFY` instead of Redis — one fewer dependency 526 + 527 + ### HTTP/2 Requirement 528 + 529 + SSE connections hold an HTTP connection open. Under HTTP/1.1, browsers limit to 6 connections per domain — a user with multiple tabs could exhaust this. **HTTP/2 multiplexes all streams over a single TCP connection**, eliminating this issue. 530 + 531 + **Action:** Ensure the deployment setup (Docker Compose with nginx/Caddy) uses HTTP/2. Caddy enables HTTP/2 by default. 532 + 533 + ### Proxy Configuration 534 + 535 + SSE requires long-lived connections. Configure reverse proxy timeouts: 536 + 537 + ```nginx 538 + # nginx — for SSE routes only 539 + location /api/events/ { 540 + proxy_pass http://appview:3000; 541 + proxy_http_version 1.1; 542 + proxy_set_header Connection ""; 543 + proxy_buffering off; 544 + proxy_cache off; 545 + proxy_read_timeout 86400s; # 24 hours 546 + } 547 + ``` 548 + 549 + Or with Caddy (no special config needed — it handles streaming correctly by default). 550 + 551 + --- 552 + 553 + ## Implementation Roadmap 554 + 555 + ### Phase 1 (Part of AppView Core milestone) 556 + 1. **Add Jetstream consumer** — `@skyware/jetstream` with `space.atbb.*` filter 557 + 2. **Add in-process EventEmitter** — broadcast indexed events by channel 558 + 3. **Add SSE endpoint** — `/api/events/topic/:id` using Hono's `streamSSE` 559 + 4. **Persist cursor** — store `time_us` in PostgreSQL, reload on restart 560 + 561 + ### Phase 2 (Part of Web UI milestone) 562 + 5. **Add `htmx-ext-sse`** — include SSE extension alongside HTMX in base layout 563 + 6. **Add `sse-connect` to topic view** — connect to thread-specific SSE stream 564 + 7. **Render reply HTML fragments** — reuse existing JSX components for SSE payloads 565 + 8. **Add `sse-connect` to category view** — connect to category-specific SSE stream 566 + 567 + ### Phase 3 (Post-MVP polish) 568 + 9. **Typing indicators** — debounced POST from textarea keyup, broadcast via SSE 569 + 10. **Presence tracking** — track SSE connections per topic, broadcast count 570 + 11. **Notification badges** — global SSE stream for subscribed topic updates 571 + 12. **Graceful degradation** — ensure forum works perfectly without SSE (progressive enhancement) 572 + 573 + ### Dependencies 574 + 575 + | Step | Depends On | New Packages | 576 + |------|-----------|--------------| 577 + | Jetstream consumer | Phase 1 AppView Core | `@skyware/jetstream`, `ws` | 578 + | EventEmitter | Jetstream consumer | None (Node.js built-in) | 579 + | SSE endpoint | EventEmitter + Hono | None (`hono/streaming` built-in) | 580 + | HTMX SSE extension | Web UI + SSE endpoint | `htmx-ext-sse` (CDN) | 581 + 582 + --- 583 + 584 + ## Why This Matters 585 + 586 + No other forum software has this architecture. Here's the comparison: 587 + 588 + | Forum | Real-Time Approach | 589 + |-------|-------------------| 590 + | **phpBB** | None. Pure request-response. Must refresh to see new replies. | 591 + | **Discourse** | Custom WebSocket implementation via MessageBus gem. Bolt-on architecture, requires Redis, sticky sessions. | 592 + | **Flarum** | Pusher.com integration (third-party SaaS). Adds external dependency and cost. | 593 + | **NodeBB** | Socket.io (WebSocket). Heavy client-side JS framework. | 594 + | **atBB** | Protocol-native firehose → in-process event bus → SSE → HTMX declarative swap. Zero client-side JS. The real-time stream is architecturally intrinsic, not bolted on. | 595 + 596 + The AT Protocol firehose means atBB doesn't *add* real-time — it *is* real-time. The firehose consumer that indexes posts is the same component that powers live updates. There's no separate infrastructure, no Redis, no WebSocket server, no client-side framework. Just HTML attributes. 597 + 598 + This is atBB's strongest architectural differentiator and should be a first-class feature from day one. 599 + 600 + --- 601 + 602 + ## Sources 603 + 604 + - [HTMX SSE Extension Docs](https://htmx.org/extensions/sse/) 605 + - [htmx-ext-sse npm package](https://www.npmjs.com/package/htmx-ext-sse) 606 + - [HTMX WebSocket vs SSE comparison](https://htmx.org/extensions/ws/) 607 + - [Hono SSE Streaming API](https://hono.dev/docs/helpers/streaming) 608 + - [Bluesky Jetstream GitHub](https://github.com/bluesky-social/jetstream) 609 + - [Jetstream: Shrinking the Firehose by >99%](https://jazco.dev/2024/09/24/jetstream/) 610 + - [@skyware/jetstream](https://www.npmjs.com/package/@skyware/jetstream) 611 + - [@atproto/sync](https://www.npmjs.com/package/@atproto/sync) 612 + - [@atproto/tap (backfill tool)](https://docs.bsky.app/blog/introducing-tap) 613 + - [Bluesky Firehose Guide](https://docs.bsky.app/docs/advanced-guides/firehose) 614 + - [benc-uk/htmx-go-chat (SSE chat example)](https://github.com/benc-uk/htmx-go-chat) 615 + - [Live Website Updates with Go, SSE, and HTMX](https://threedots.tech/post/live-website-updates-go-sse-htmx/) 616 + - [SSE vs WebSockets](https://www.smashingmagazine.com/2018/02/sse-websockets-data-flow-http2/)