164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
|
|
"""
|
||
|
|
This file is part of the SplendidBear Websites' projects.
|
||
|
|
|
||
|
|
Copyright (c) 2026 @ www.splendidbear.org
|
||
|
|
|
||
|
|
For the full copyright and license information, please view the LICENSE
|
||
|
|
file that was distributed with this source code.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from collections.abc import Callable
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import requests
|
||
|
|
from gi.repository import GLib
|
||
|
|
|
||
|
|
from mineseeker import config
|
||
|
|
from mineseeker.api import client
|
||
|
|
from mineseeker.constants import SSE_RECONNECT_INITIAL, SSE_RECONNECT_MAX
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class SseListener:
|
||
|
|
"""
|
||
|
|
Opens a Mercure SSE connection in a daemon thread and dispatches
|
||
|
|
parsed JSON messages back to the GTK main thread via GLib.idle_add().
|
||
|
|
|
||
|
|
Message routing mirrors useServerCommunication.jsx handleMercureMessage():
|
||
|
|
|
||
|
|
payload.type == "challenge" → on_challenge(payload)
|
||
|
|
payload.type == "challenge-response" → on_challenge_response(payload)
|
||
|
|
payload.type == "heartbeat" → on_heartbeat(payload)
|
||
|
|
"data" key present → on_topic(payload)
|
||
|
|
"msg" key present → on_unsubscribe(payload)
|
||
|
|
(none of the above) → on_subscribe(payload)
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
game_assoc: str,
|
||
|
|
mercure_jwt: str,
|
||
|
|
*,
|
||
|
|
on_subscribe: Callable[[dict], Any] | None = None,
|
||
|
|
on_unsubscribe: Callable[[dict], Any] | None = None,
|
||
|
|
on_topic: Callable[[dict], Any] | None = None,
|
||
|
|
on_challenge: Callable[[dict], Any] | None = None,
|
||
|
|
on_challenge_response: Callable[[dict], Any] | None = None,
|
||
|
|
on_heartbeat: Callable[[dict], Any] | None = None,
|
||
|
|
) -> None:
|
||
|
|
self._game_assoc = game_assoc
|
||
|
|
self._mercure_jwt = mercure_jwt
|
||
|
|
self._handlers = {
|
||
|
|
"subscribe": on_subscribe,
|
||
|
|
"unsubscribe": on_unsubscribe,
|
||
|
|
"topic": on_topic,
|
||
|
|
"challenge": on_challenge,
|
||
|
|
"challenge-response": on_challenge_response,
|
||
|
|
"heartbeat": on_heartbeat,
|
||
|
|
}
|
||
|
|
self._stop_event = threading.Event()
|
||
|
|
self._thread: threading.Thread | None = None
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Public control
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
def start(self) -> None:
|
||
|
|
"""Start the background SSE listener thread."""
|
||
|
|
self._stop_event.clear()
|
||
|
|
self._thread = threading.Thread(
|
||
|
|
target=self._run, daemon=True, name="sse-listener"
|
||
|
|
)
|
||
|
|
self._thread.start()
|
||
|
|
|
||
|
|
def stop(self) -> None:
|
||
|
|
"""Signal the background thread to stop."""
|
||
|
|
self._stop_event.set()
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Background thread
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _build_url(self) -> str:
|
||
|
|
topic = f"mineseeker/channel/{self._game_assoc}"
|
||
|
|
return f"{config.MERCURE_URL}?topic={topic}"
|
||
|
|
|
||
|
|
def _run(self) -> None:
|
||
|
|
backoff = SSE_RECONNECT_INITIAL
|
||
|
|
while not self._stop_event.is_set():
|
||
|
|
try:
|
||
|
|
self._stream()
|
||
|
|
backoff = SSE_RECONNECT_INITIAL # reset on clean disconnect
|
||
|
|
except Exception as exc:
|
||
|
|
if self._stop_event.is_set():
|
||
|
|
break
|
||
|
|
log.warning("SSE connection lost (%s), reconnecting in %.1fs", exc, backoff)
|
||
|
|
time.sleep(backoff)
|
||
|
|
backoff = min(backoff * 2, SSE_RECONNECT_MAX)
|
||
|
|
|
||
|
|
def _stream(self) -> None:
|
||
|
|
"""Open the SSE stream and process events until stopped or error."""
|
||
|
|
url = self._build_url()
|
||
|
|
headers = {
|
||
|
|
"Authorization": f"Bearer {self._mercure_jwt}",
|
||
|
|
"Accept": "text/event-stream",
|
||
|
|
"Cache-Control": "no-cache",
|
||
|
|
}
|
||
|
|
# Use the requests session from client.py so cookies are included
|
||
|
|
resp = client.get_session().get(
|
||
|
|
url, headers=headers, stream=True, timeout=(10, None)
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
|
||
|
|
# Parse the raw SSE stream manually (sseclient-py would also work
|
||
|
|
# but avoids an extra dependency on GLib-aware loops)
|
||
|
|
data_lines: list[str] = []
|
||
|
|
for raw_line in resp.iter_lines(decode_unicode=True):
|
||
|
|
if self._stop_event.is_set():
|
||
|
|
break
|
||
|
|
if raw_line.startswith("data:"):
|
||
|
|
data_lines.append(raw_line[5:].lstrip(" "))
|
||
|
|
elif raw_line == "" and data_lines:
|
||
|
|
# Empty line signals end of event — dispatch it
|
||
|
|
payload_str = "\n".join(data_lines)
|
||
|
|
data_lines = []
|
||
|
|
try:
|
||
|
|
payload = json.loads(payload_str)
|
||
|
|
GLib.idle_add(self._dispatch, payload)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
log.debug("Non-JSON SSE data ignored: %s", payload_str)
|
||
|
|
|
||
|
|
def _dispatch(self, payload: dict) -> bool:
|
||
|
|
"""Called on the GTK main thread via GLib.idle_add."""
|
||
|
|
msg_type = payload.get("type")
|
||
|
|
|
||
|
|
if msg_type == "challenge":
|
||
|
|
self._call("challenge", payload)
|
||
|
|
elif msg_type == "challenge-response":
|
||
|
|
self._call("challenge-response", payload)
|
||
|
|
elif msg_type == "heartbeat":
|
||
|
|
self._call("heartbeat", payload)
|
||
|
|
elif "data" in payload:
|
||
|
|
self._call("topic", payload)
|
||
|
|
elif "msg" in payload:
|
||
|
|
self._call("unsubscribe", payload)
|
||
|
|
else:
|
||
|
|
self._call("subscribe", payload)
|
||
|
|
|
||
|
|
return GLib.SOURCE_REMOVE # run once only
|
||
|
|
|
||
|
|
def _call(self, key: str, payload: dict) -> None:
|
||
|
|
handler = self._handlers.get(key)
|
||
|
|
if handler is not None:
|
||
|
|
try:
|
||
|
|
handler(payload)
|
||
|
|
except Exception:
|
||
|
|
log.exception("Error in SSE handler '%s'", key)
|