Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
3.7 KiB
Python
88 lines
3.7 KiB
Python
"""
|
|
WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS).
|
|
|
|
Authentication: JWT token passed as query param ?token=<jwt>
|
|
(Same pattern as SSE — browser WebSocket API also cannot set custom headers.)
|
|
|
|
Protocol:
|
|
1. Client connects: ws://host/api/ws?token=<jwt>
|
|
2. Client immediately sends: { "cursor": <last_seq_id> }
|
|
(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)
|