Realtime: SSE and WebSockets¶
HTMX scripts¶
Earlier versions of ztml auto-injected the HTMX script into every full-page response. I replaced that with explicit imports. It was convenient but made it hard to reason about what was actually in your page, and didn't extend cleanly to HTMX extensions. Now you import what you need and drop them in your Head().
from ztml.scripts import HTMX, HTMX_SSE, HTMX_WS
Html(
Head(Title("My App"), HTMX, HTMX_SSE),
Body(...)
)
Available exports:
HTMX: the core HTMX libraryHTMX_SSE: the SSE extensionHTMX_WS: the WebSocket extensionHTMX_RESPONSE_TARGETS: response targets extensionHTMX_HEAD_SUPPORT: head element supportHTMX_PRELOAD: link preloading
These are just Script elements pointing at unpkg CDN URLs. If you'd rather pin a version or self-host, use your own Script().src(...) instead.
Server-Sent Events¶
EventStream¶
EventStream wraps an async generator into an SSE response. Each yielded item is rendered and sent as an unnamed data: event.
from ztml.server import ZTMLApp, EventStream, serve
app = ZTMLApp()
@app.route("/stream")
async def get():
async def updates():
yield Div("first update")
yield Div("second update")
return EventStream(updates()).response()
NamedEventStream¶
For multiple named event sources with independent intervals, NamedEventStream is more ergonomic. Register sources with decorators and they run on their own schedules.
from ztml.server import NamedEventStream
clock = NamedEventStream(interval=1)
@clock.source("time")
def time_event():
from datetime import datetime
return datetime.now().strftime("%H:%M:%S")
@clock.source("date", interval=60)
def date_event():
from datetime import datetime
return datetime.now().strftime("%A, %B %d, %Y")
@app.route("/clock")
async def clock_route():
return clock.response()
On the client side, use HTMX's SSE extension to connect and swap content by event name:
from ztml.scripts import HTMX, HTMX_SSE
# In Head()
Head(Title("Clock"), HTMX, HTMX_SSE)
# In Body()
Div(
Div("--:--:--").id("time").attr("sse-swap", "time"),
Div("---").id("date").attr("sse-swap", "date"),
).hx_ext("sse").attr("sse-connect", "/clock")
Sources can be sync or async, and can return elements, components, or strings.
WebSockets¶
The @app.ws() decorator registers a WebSocket endpoint. You get the raw Starlette WebSocket object.
@app.ws("/echo")
async def echo(websocket):
await websocket.accept()
data = await websocket.receive_text()
await websocket.send_text(f"echo: {data}")
await websocket.close()
Path parameters work the same as with HTTP routes:
WebSocket handlers are lower-level than SSE—you manage the connection lifecycle yourself. For most cases where you're pushing server updates to the page, SSE with HTMX is simpler.