CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
206 lines
6.6 KiB
Python
206 lines
6.6 KiB
Python
# Copyright 2026 Logan Fick
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Server-Sent Event connection tracking for the polls module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import itertools
|
|
from dataclasses import dataclass, field
|
|
from enum import StrEnum
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import orjson
|
|
|
|
if TYPE_CHECKING:
|
|
from logging import Logger
|
|
|
|
from aiohttp import web
|
|
|
|
from owlbot.api import User
|
|
|
|
|
|
class SSEEvent(StrEnum):
|
|
"""Server-Sent Event types for the polls module."""
|
|
|
|
KEEPALIVE = "keepalive"
|
|
POLL_CANCELLED = "poll_cancelled"
|
|
POLL_ENDED = "poll_ended"
|
|
TALLY = "tally"
|
|
|
|
|
|
def sse_payload(event_type: SSEEvent, data: dict[str, Any]) -> bytes:
|
|
"""Serialize an SSE event into wire format.
|
|
|
|
:param event_type: The event type name.
|
|
:param data: The JSON-serializable event data.
|
|
:return: Encoded SSE frame ready for ``StreamResponse.write()``.
|
|
"""
|
|
return b"event: " + event_type.encode() + b"\ndata: " + orjson.dumps(data) + b"\n\n"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PollSSEConnection:
|
|
"""Runtime metadata for one connected poll event stream.
|
|
|
|
:param id: Internal connection ID.
|
|
:param user_id: Owncast user ID associated with the stream.
|
|
:param is_moderator: Whether the connected viewer has moderator privileges.
|
|
:param response: Prepared response used for SSE writes.
|
|
:param write_lock: Per-connection lock that serializes writes.
|
|
"""
|
|
|
|
id: int
|
|
user_id: str
|
|
is_moderator: bool
|
|
response: web.StreamResponse
|
|
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
|
|
|
|
class PollSSERegistry:
|
|
"""Maintain live poll SSE connections and serialize writes per client."""
|
|
|
|
def __init__(self, logger: Logger) -> None:
|
|
"""Initialize an empty SSE connection registry.
|
|
|
|
:param logger: Logger for connection lifecycle diagnostics.
|
|
"""
|
|
self._logger = logger
|
|
self._connection_ids = itertools.count()
|
|
self._connections: dict[int, PollSSEConnection] = {}
|
|
|
|
def __len__(self) -> int:
|
|
"""Return the number of currently tracked connections.
|
|
|
|
:return: Active SSE connection count.
|
|
"""
|
|
return len(self._connections)
|
|
|
|
def register(
|
|
self,
|
|
user: User,
|
|
*,
|
|
response: web.StreamResponse,
|
|
) -> PollSSEConnection:
|
|
"""Register an SSE client and return its connection.
|
|
|
|
:param user: Viewer associated with the event stream.
|
|
:param response: Prepared streaming response for the client.
|
|
:return: Registered connection metadata.
|
|
"""
|
|
connection = PollSSEConnection(
|
|
id=next(self._connection_ids),
|
|
user_id=user.id,
|
|
is_moderator=user.is_moderator,
|
|
response=response,
|
|
)
|
|
self._connections[connection.id] = connection
|
|
return connection
|
|
|
|
def unregister(self, connection: PollSSEConnection) -> None:
|
|
"""Remove one SSE connection.
|
|
|
|
:param connection: Connection to remove from the registry.
|
|
"""
|
|
removed = self._connections.pop(connection.id, None)
|
|
if removed is not None:
|
|
self._logger.debug(
|
|
"SSE client disconnected: %s (%d remaining).",
|
|
removed.user_id,
|
|
len(self._connections),
|
|
)
|
|
|
|
async def send(
|
|
self,
|
|
connection: PollSSEConnection,
|
|
event_type: SSEEvent,
|
|
data: dict[str, Any],
|
|
) -> bool:
|
|
"""Send one event to one connection, removing it if the write fails.
|
|
|
|
:param connection: Target SSE connection.
|
|
:param event_type: Event type to send.
|
|
:param data: JSON-serializable event payload.
|
|
:return: True if the event was written successfully.
|
|
"""
|
|
if self._connections.get(connection.id) is not connection:
|
|
return False
|
|
|
|
try:
|
|
await self._write_connection(connection, sse_payload(event_type, data))
|
|
except Exception: # noqa: BLE001 # transport failures vary by server/client state
|
|
self._connections.pop(connection.id, None)
|
|
self._logger.debug(
|
|
"Removing disconnected SSE client: %s", connection.user_id
|
|
)
|
|
return False
|
|
return True
|
|
|
|
async def broadcast(
|
|
self,
|
|
event_type: SSEEvent,
|
|
data: dict[str, Any],
|
|
*,
|
|
moderators_only: bool = False,
|
|
) -> None:
|
|
"""Broadcast one event to currently tracked connections.
|
|
|
|
:param event_type: Event type to send.
|
|
:param data: JSON-serializable event payload.
|
|
:param moderators_only: Whether to limit delivery to moderator clients.
|
|
"""
|
|
targets = [
|
|
connection
|
|
for connection in self._connections.values()
|
|
if not moderators_only or connection.is_moderator
|
|
]
|
|
if not targets:
|
|
return
|
|
|
|
payload = sse_payload(event_type, data)
|
|
results = await asyncio.gather(
|
|
*(self._write_connection(connection, payload) for connection in targets),
|
|
return_exceptions=True,
|
|
)
|
|
for connection, result in zip(targets, results, strict=True):
|
|
if isinstance(result, BaseException):
|
|
self._logger.debug(
|
|
"Removing disconnected SSE client: %s", connection.user_id
|
|
)
|
|
self._connections.pop(connection.id, None)
|
|
|
|
async def close_all(self) -> None:
|
|
"""Close and forget every tracked SSE response."""
|
|
connections = list(self._connections.values())
|
|
for connection in connections:
|
|
with contextlib.suppress(Exception):
|
|
async with connection.write_lock:
|
|
await connection.response.write_eof()
|
|
self._connections.clear()
|
|
|
|
async def _write_connection(
|
|
self,
|
|
connection: PollSSEConnection,
|
|
payload: bytes,
|
|
) -> None:
|
|
"""Write one encoded SSE frame while holding the connection lock.
|
|
|
|
:param connection: Target SSE connection.
|
|
:param payload: Encoded SSE frame.
|
|
"""
|
|
async with connection.write_lock:
|
|
await connection.response.write(payload)
|