Files
chat/chat/web/sse.py
T

89 lines
3.5 KiB
Python

"""Server-Sent Events endpoint for per-chat live updates.
Each browser tab on ``/chats/<id>`` opens an SSE connection here. On connect:
1. We verify the chat exists (404 otherwise).
2. We subscribe to the chat's pub/sub channel.
3. We emit a ``snapshot`` event with the current state. T16 only provides a
stub payload (``{"chat_id": <id>, "ready": true}``) so the client can
confirm the channel is live; T19+ will populate it with real state.
4. We loop, awaiting events from the queue and yielding them as SSE frames.
A 15-second keepalive comment is emitted on idle to defeat intermediary
timeouts.
5. When the client disconnects we unsubscribe so the registry doesn't leak.
"""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from chat.state.world import get_chat
from chat.web.bots import get_conn
from chat.web.pubsub import subscribe, unsubscribe
router = APIRouter()
# Heartbeat cadence. Long enough to avoid chattiness; short enough that most
# HTTP intermediaries won't close an idle connection.
_KEEPALIVE_SECONDS = 15.0
def _format_sse(event: str, data: dict | str) -> bytes:
"""Format a single SSE frame: ``event: <name>\\ndata: <body>\\n\\n``.
``data`` may be a dict (JSON-serialized) or a raw string. The string
form is used for HTMX SSE swaps where the payload is an HTML
fragment that the client splices into the DOM verbatim.
"""
if isinstance(data, str):
payload = data
else:
payload = json.dumps(data)
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
@router.get("/chats/{chat_id}/events")
async def chat_events(chat_id: str, request: Request, conn=Depends(get_conn)):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail="chat not found")
async def stream():
queue = await subscribe(chat_id)
try:
# Initial snapshot — T19 will fill in real state.
yield _format_sse("snapshot", {"chat_id": chat_id, "ready": True})
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(
queue.get(), timeout=_KEEPALIVE_SECONDS
)
except asyncio.TimeoutError:
# SSE comment line (per spec, lines starting with ":" are
# ignored by the client) — keeps the connection warm.
yield b": keepalive\n\n"
continue
# Allow publishers to set the SSE event name via "event" key;
# default to "message" if omitted. When the remaining payload
# is a single ``data`` string, send it verbatim — that lets
# turn-flow publishers ship pre-rendered HTML fragments that
# HTMX's SSE extension can swap into the DOM directly.
event = dict(event) # don't mutate the published dict
kind = event.pop("event", "message")
if set(event.keys()) == {"data"} and isinstance(
event["data"], str
):
yield _format_sse(kind, event["data"])
else:
yield _format_sse(kind, event)
finally:
await unsubscribe(chat_id, queue)
return StreamingResponse(stream(), media_type="text/event-stream")