Coverage for src / lexigram / ui / htmx / sse.py: 83%
60 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""SSE support for Lexigram UI.
3Server-side: ``SSEMessage``, ``SSEStream`` — format and stream SSE payloads.
4Client-side: ``SSE`` component — renders the HTMX SSE connector element with
5 typed event names and automatic exponential-backoff reconnection.
6"""
8from __future__ import annotations
10import asyncio
11from collections.abc import AsyncGenerator
12from enum import Enum
13from typing import Any
15from starlette.responses import StreamingResponse
17from lexigram.serialization import dumps_str
18from lexigram.ui.core.base import Component, el, raw
20__all__ = [
21 "SSEEventType",
22 "SSEMessage",
23 "SSEStream",
24 "SSE",
25]
28# ---------------------------------------------------------------------------
29# Typed event names
30# ---------------------------------------------------------------------------
33class SSEEventType(str, Enum):
34 """Well-known SSE event type names.
36 Use these instead of bare strings when configuring the ``SSE`` component
37 or emitting ``SSEMessage`` objects so the names stay in sync across the
38 client and server.
39 """
41 MESSAGE = "message"
42 UPDATE = "update"
43 ERROR = "error"
44 PING = "ping"
45 CLOSE = "close"
46 CONNECT = "connect"
47 DISCONNECT = "disconnect"
50# ---------------------------------------------------------------------------
51# Server-side helpers
52# ---------------------------------------------------------------------------
55class SSEMessage:
56 """Helper to format SSE messages."""
58 def __init__(
59 self,
60 data: Any,
61 event: str | None = None,
62 event_id: str | None = None,
63 retry: int | None = None,
64 ):
65 self.data = data
66 self.event = event
67 self.id = event_id
68 self.retry = retry
70 def __str__(self) -> str:
71 lines = []
72 if self.id:
73 lines.append(f"id: {self.id}")
74 if self.event:
75 lines.append(f"event: {self.event}")
76 if self.retry:
77 lines.append(f"retry: {self.retry}")
79 data = self.data
80 if not isinstance(data, str):
81 data = dumps_str(data)
83 for line in data.split("\n"):
84 lines.append(f"data: {line}")
86 return "\n".join(lines) + "\n\n"
89class SSEStream(StreamingResponse):
90 """Streaming response for Server-Sent Events."""
92 def __init__(self, generator: AsyncGenerator[SSEMessage, None], **kwargs: Any):
93 async def event_generator() -> Any:
94 try:
95 async for message in generator:
96 yield str(message)
97 except asyncio.CancelledError:
98 pass
100 super().__init__(event_generator(), media_type="text/event-stream", **kwargs)
101 self.headers["Cache-Control"] = "no-cache"
102 self.headers["Connection"] = "keep-alive"
103 self.headers["X-Accel-Buffering"] = "no"
106# ---------------------------------------------------------------------------
107# Client-side HTMX SSE connector component
108# ---------------------------------------------------------------------------
111class SSE(Component):
112 """Client-side HTMX SSE connector with typed event support and auto-reconnect.
114 Renders a ``<div>`` wired up with the ``hx-ext="sse"`` extension plus an
115 inline ``<script>`` that listens for ``htmx:sseError`` and schedules a
116 reconnect with exponential back-off, capped at 30 s.
118 Args:
119 url: SSE endpoint URL (``sse-connect`` attribute).
120 target: CSS selector or ``"this"`` for the HTMX swap target.
121 event_type: SSE event name to swap on (``sse-swap`` attribute).
122 Accepts a plain string or an ``SSEEventType`` member.
123 retry_ms: Initial reconnect delay in milliseconds (default 3000).
124 Doubles on each failed attempt, capped at 30 000 ms.
125 """
127 def __init__(
128 self,
129 url: str,
130 target: str = "this",
131 event_type: str = SSEEventType.MESSAGE,
132 retry_ms: int = 3000,
133 **props: Any,
134 ) -> None:
135 super().__init__(
136 url=url, target=target, event_type=event_type, retry_ms=retry_ms, **props
137 )
138 self.url = url
139 self.target = target
140 # Normalise SSEEventType members to their string value
141 self.event_type = (
142 event_type.value if isinstance(event_type, SSEEventType) else event_type
143 )
144 self.retry_ms = max(100, int(retry_ms))
146 def render(self) -> Any:
147 # Use the Python object id to produce a unique DOM id per render.
148 component_id = f"sse-{id(self)}"
150 reconnect_script = raw(
151 f"<script>"
152 f"(function(){{"
153 f"var _el=document.getElementById('{component_id}');"
154 f"var _retry={self.retry_ms};"
155 f"var _max=30000;"
156 f"var _n=0;"
157 f"document.addEventListener('htmx:sseError',function(e){{"
158 f"if(!_el||!e.detail)return;"
159 f"var delay=Math.min(_retry*Math.pow(2,_n),_max);"
160 f"_n++;"
161 f"setTimeout(function(){{if(_el&&typeof htmx!=='undefined')htmx.process(_el);}},delay);"
162 f"}});"
163 f"document.addEventListener('htmx:sseOpen',function(){{{{"
164 f"_n=0;"
165 f"}}}});"
166 f"}})();"
167 f"</script>"
168 )
170 return el(
171 "div",
172 {
173 "id": component_id,
174 "hx-ext": "sse",
175 "sse-connect": self.url,
176 "sse-swap": self.event_type,
177 "hx-target": self.target,
178 },
179 *self.children,
180 reconnect_script,
181 )