""" WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS). Authentication: JWT token passed as query param ?token= (Same pattern as SSE — browser WebSocket API also cannot set custom headers.) Protocol: 1. Client connects: ws://host/api/ws?token= 2. Client immediately sends: { "cursor": } (0 = first connect / no history; any positive int = resume from that point) 3. Server replays all missed events (seq > cursor) as individual frames 4. Server sends { "type": "ready" } to signal end of replay / start of live stream 5. Live events arrive as: { "seq": 123, "type": "...", "data": { ... } } 6. Every 25s server sends: { "type": "ping" } 7. Client may respond: { "type": "pong" } (ignored if not sent) 8. On disconnect/error the client reconnects and repeats from step 2 """ import asyncio import json import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from starlette.websockets import WebSocketState from routers.deps import decode_token from services.ws_bus import connect, disconnect, get_events_since router = APIRouter() logger = logging.getLogger(__name__) KEEPALIVE_INTERVAL = 25 # seconds @router.websocket("/connect") async def ws_endpoint(websocket: WebSocket, token: str = Query(...)): # ── Auth ────────────────────────────────────────────────────────────────── try: payload = decode_token(token) user_id: int = int(payload["sub"]) except Exception: await websocket.close(code=4001) return await websocket.accept() # ── Wait for cursor frame ───────────────────────────────────────────────── cursor = 0 try: raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0) msg = json.loads(raw) cursor = int(msg.get("cursor", 0)) except (asyncio.TimeoutError, Exception): pass # no cursor sent — treat as fresh connect (cursor=0) # ── Register this connection ────────────────────────────────────────────── q = await connect(user_id) try: # ── Replay missed events ────────────────────────────────────────────── if cursor > 0: missed = get_events_since(cursor, user_id) for event in missed: await websocket.send_text(json.dumps(event)) # Signal end of replay / start of live stream await websocket.send_text(json.dumps({"type": "ready"})) # ── Live stream loop ────────────────────────────────────────────────── while True: try: frame = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL) if websocket.client_state == WebSocketState.CONNECTED: await websocket.send_text(frame) except asyncio.TimeoutError: # Send keepalive ping if websocket.client_state == WebSocketState.CONNECTED: await websocket.send_text(json.dumps({"type": "ping"})) else: break except WebSocketDisconnect: pass except Exception as e: logger.debug("ws_endpoint: connection closed for user %s: %s", user_id, e) finally: await disconnect(user_id, q)