"""Push hooks for relay-scene updates in the Gradio Space. The Space should not poll Hugging Face control-plane APIs or repeatedly poll the relay bucket. The local publisher calls the protected hook after it writes a new manifest; browser clients keep one SSE connection open and refresh once per hook event. """ from __future__ import annotations import asyncio import hmac import json import os from collections.abc import AsyncIterator from typing import Annotated, Any from fastapi import Body, FastAPI, Header, HTTPException, Request from fastapi.responses import StreamingResponse RELAY_HOOK_TOKEN_ENV = "SMALL_CUTS_RELAY_HOOK_TOKEN" RELAY_HOOK_PATH = "/small-cuts/hooks/relay-scene" RELAY_EVENTS_PATH = "/small-cuts/events" SSE_HEARTBEAT_S = 15.0 class RelayEventHub: def __init__(self) -> None: self._next_id = 0 self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set() def subscribe(self) -> asyncio.Queue[dict[str, Any]]: queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=8) self._subscribers.add(queue) return queue def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None: self._subscribers.discard(queue) async def publish(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: self._next_id += 1 event = {"id": self._next_id, "payload": payload or {}} stale: list[asyncio.Queue[dict[str, Any]]] = [] for queue in list(self._subscribers): try: queue.put_nowait(event) except asyncio.QueueFull: stale.append(queue) for queue in stale: self.unsubscribe(queue) return event def install_relay_hooks(app: FastAPI, *, hub: RelayEventHub | None = None) -> RelayEventHub: if getattr(app.state, "small_cuts_relay_hooks_installed", False): return app.state.small_cuts_relay_event_hub event_hub = hub or RelayEventHub() app.state.small_cuts_relay_hooks_installed = True app.state.small_cuts_relay_event_hub = event_hub @app.post(RELAY_HOOK_PATH, status_code=202) async def relay_scene_hook( payload: Annotated[dict[str, Any] | None, Body()] = None, authorization: Annotated[str | None, Header()] = None, ) -> dict[str, Any]: _require_hook_authorization(authorization) event = await event_hub.publish(payload or {}) return {"status": "accepted", "event_id": event["id"]} @app.get(RELAY_EVENTS_PATH) async def relay_events(request: Request) -> StreamingResponse: return StreamingResponse( relay_event_stream(event_hub, request, heartbeat_s=SSE_HEARTBEAT_S), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) return event_hub async def relay_event_stream( event_hub: RelayEventHub, request: Request, *, heartbeat_s: float = SSE_HEARTBEAT_S, ) -> AsyncIterator[str]: queue = event_hub.subscribe() try: yield _sse("ready", {"status": "connected"}) while True: if await request.is_disconnected(): break try: event = await asyncio.wait_for(queue.get(), timeout=heartbeat_s) except asyncio.TimeoutError: yield ": ping\n\n" continue yield _sse("relay-scene", event) finally: event_hub.unsubscribe(queue) def _require_hook_authorization(authorization: str | None) -> None: expected = os.environ.get(RELAY_HOOK_TOKEN_ENV, "").strip() if not expected: raise HTTPException(status_code=503, detail="relay hook is not configured") if not authorization or not hmac.compare_digest(authorization, f"Bearer {expected}"): raise HTTPException(status_code=401, detail="unauthorized") def _sse(event: str, data: dict[str, Any]) -> str: return f"event: {event}\ndata: {json.dumps(data, separators=(',', ':'))}\n\n"