Server-Sent Events
Server-Sent Events (SSE) let a server push messages to the browser over a long-lived HTTP connection. WebComPy exposes them through the use_event_source composable, which returns a connection handle that is itself an async iterator of SSEvent occurrences.
Unlike state (Signal) which has cell semantics, SSE messages have occurrence semantics: every arrival matters, duplicates included, and nothing is suppressed. use_event_source is the network-side counterpart of the signal-stream utilities — it yields the raw occurrence stream, and you decide whether to bridge the values into cells.
Basic usage
from webcompy.realtime import use_event_source
@define_component("sse-alerts")
def Alerts(context):
alerts = use_event_source("/api/alerts")
@on_mounted
async def consume():
async for event in alerts:
print(event) # SSEvent(event="message", data=..., last_event_id=...)
Each yielded item is a frozen SSEvent dataclass:
| Field | Meaning |
|---|---|
event |
The SSE event type ("message" by default) |
data |
The event payload as text |
last_event_id |
The id: field sent by the server, if any |
The events parameter selects which named SSE event types are delivered; anything else is filtered out:
es = use_event_source("/events", events=("status", "progress"))
By default only ("message",) events are delivered.
events must be a non-empty tuple of non-empty strings — a bare string (events="message"), an empty tuple, or a non-string element is rejected with a TypeError/ValueError before any connection is opened.
POST requests
use_event_source opens through the browser-native EventSource API by default, which cannot send a request body. Pass method (any non-empty string other than "GET"), body, and headers to open a fetch-based SSE connection that sends the request body and headers:
es = use_event_source(
"/query",
method="POST",
body='{"q":"x"}',
headers={"Content-Type": "application/json"},
events=("result",),
)
Fetch-based connections send the given URL unchanged (relative URLs resolve against the document) and parse the text/event-stream response incrementally. body/headers together with method="GET" raise a ValueError before any connection is opened; a method that is not a non-empty string raises a TypeError. Non-GET connections require a running event loop (the browser or an asyncio context); calling them in a synchronous server context raises a RuntimeError.
Fetch-based reconnection
Fetch-based connections follow EventSource-style auto-reconnect: if the request fails to open, the response status is not successful, the body is not text/event-stream, or the body stream ends, the connection transitions to RECONNECTING and retries with an exponential backoff (1s base, 30s cap, with jitter) until explicitly closed. Reconnect requests carry the Last-Event-ID header once any id: has been received, so a server that honors it can resume from where the client left off. Only .close() (or the equivalent detachment/component-destroy path) terminates the loop.
Sharing and filtering for fetch-based connections
Fetch-based connections are keyed by (url, method, body, normalized headers), so identical requests (same body and headers, with header names compared case-insensitively) share a single underlying connection while different bodies or headers never do. The full event stream is read once and each subscriber's requested events set is filtered per subscriber — a new subscriber asking for additional event types does not reopen the underlying connection.
Non-goals
- There is no typed SSE composable (
use_typed_event_sourcedoes not exist). - Reconnection parameters are fixed (1s base / 30s cap); the server's
retry:field is not honored. - Events missed during a disconnect are not replayed beyond what
Last-Event-IDenables server-side. - There is no server-side SSE endpoint helper; mount your own endpoint (e.g. via
asgi-mount).
Connection handle
The handle exposes three surfaces:
- Iteration —
async for ev in es:yields every received event in order. .state— aSignal[ConnectionState](CONNECTING/OPEN/CLOSED) reflecting the underlying connection..close()— detaches only your own subscription; it never tears down a connection that other subscribers still use (see below).
Connection sharing
Within one app, use_event_source calls with the same URL share a single underlying native EventSource. The first subscriber opens the connection; the last subscriber's close detaches it. Each subscriber receives its own FIFO queue, so a slow consumer never blocks another, and is also free to iterate at its own pace:
# These two calls share ONE underlying connection to /events
feed_a = use_event_source("/events")
feed_b = use_event_source("/events")
Sharing matters in practice: browsers enforce a per-domain cap on HTTP/1.1 connections, so a dozen components subscribing to the same endpoint should not open a dozen sockets.
Two calls with the same URL but different events sets do not share silently: the second call reopens the shared connection with the union of both event types. Events arriving in that close-and-reopen window are lost, and the browser's Last-Event-ID resumption starts fresh on the reopened connection — use consistent events tuples for a URL to keep the connection stable.
Queues and slow consumers
Subscriber queues are unbounded by default: events arriving before your code reaches the next await accumulate until consumed. For long-lived high-frequency streams, cap a subscriber's backlog with max_queue, which keeps only the newest events (drop-oldest):
es = use_event_source("/events", max_queue=100)
Capping is per subscriber — one capped consumer does not affect others on the same connection.
max_queue must be None or an integer greater than or equal to 1; other values raise before any connection is opened.
Closing
Call .close() when the component no longer needs the stream (component-destroy hooks detach automatically for subscriptions created inside component setup; abandoned iterators are cleaned up by the runtime):
es.close() # idempotent; detaches only this caller
Reconnection and the gap/refetch recipe
The browser's native EventSource reconnects automatically and resumes via Last-Event-ID where the server supports it. During the outage the handle keeps iterating transparently; .state exposes the transitions (OPEN → CONNECTING → OPEN).
SSE does not replay the events missed while disconnected. When .state returns to OPEN, re-pull the authoritative state yourself:
from webcompy.realtime import ConnectionState
from webcompy.signal import effect
def refetch():
... # re-pull the authoritative state, e.g. a fresh HTTP request
effect(lambda: refetch() if es.state.value == ConnectionState.OPEN else None)
Treat .state as the signal that a server round-trip is eligible again; keep in-stream events as the incremental updates on top.
Bridging to signals
Because use_event_source yields an async iterable, the signal-stream utilities integrate directly. Accumulate every occurrence into a wider ReactiveList, or collapse to a single latest value with to_signal:
feed = to_reactive_list(use_event_source("/events"), maxlen=50)
# feed.items: ReactiveList[SSEvent]
This is the intended path when occurrence semantics are not what the UI needs — see Signals and Streams.
Server-side rendering
During SSR/SSG there is no browser connection: use_event_source returns an immediately-finished empty handle whose .state is CLOSED, and emits a warning. No connection state or received events are transferred into the hydration payload.