WebSockets (live fight updates)

The client maintains a WebSocket connection so the Profile screen can refresh live/pending fights without polling when someone accepts or rejects a fight.

This is separate from push notifications (FCM/APNs) and from the in-app bet_service FastAPI socket at /ws/{user_id}/{device_id} (used elsewhere in backend tests/services). The mobile/web app connects to the API Gateway WebSocket endpoint configured by REACT_APP_WS_URL.

Client implementation

Piece Path
URL builder + hook wrapper client/src/components/websocket/WebSocketClient.js
Consumer (only current usage) client/src/components/profile/Profile.js
Dependency react-use-websocket

Profile calls buildWebSocket({ userId: profileData?.id, authToken: bearerToken }) and watches lastMessage. When a relevant event arrives, it runs refreshPendingFights() (same path as pull-to-refresh).

Connection URL

Set in production builds via REACT_APP_WS_URL (see ios_deployment / env for native):

Environment Typical URL
Production wss://ws.getfoodfight.link/
Dev wss://ws.ff-devs.click/

WebSocketClient builds the final URL as:

  • Base: serverUrl argument or process.env.REACT_APP_WS_URL
  • Query: user_id, jwt_token (Cognito ID token from auth context)

On https pages, ws: is upgraded to wss:. Local http + wss:// logs a scheme mismatch warning (see Troubleshooting).

Client behavior

From WebSocketClient.js:

  • Reconnect: up to 5 attempts, exponential backoff (max 10s)
  • Heartbeat: JSON { "type": "ping" } every 30s (idle timeout avoidance)
  • Shared connection: share: true (one socket per URL across hook instances)
  • Logging: [WebSocketClient] prefix on open / message / close / error

Message types handled on Profile

The app parses JSON from lastMessage.data and refreshes fights when:

type Who gets a refresh
fight.accepted maker_id or taker_id matches current profile user
fight.rejected or fight_rejected maker_id matches current profile user

Example server payload shape for accept (from bet_service):

{
  "type": "fight.accepted",
  "bet_id": 123,
  "maker_id": "...",
  "taker_id": "...",
  "maker_outcome": "...",
  "taker_name": "...",
  "maker_items": "...",
  "taker_items": "...",
  "maker_cost": 0,
  "taker_cost": 0
}

Other event types may be sent by the backend but are not handled on Profile today—extend Profile.js if you add new real-time UI.

Backend (high level)

sequenceDiagram
  participant App as Client Profile
  participant WS as API Gateway WebSocket
  participant Lambda as $connect / fightEvent
  participant DB as websocket_connections
  participant Bet as bet_service

  App->>WS: Connect ?user_id & jwt_token
  Lambda->>DB: Store connectionId
  Bet->>Lambda: fight accepted / rejected
  Lambda->>WS: postToConnection
  WS->>App: JSON message
  App->>App: refreshPendingFights()
Layer Location
Connect / disconnect / route events backend/projects/websocket/ (websocket_handler.py, websocket_server.py)
Persist connectionId per user libs/dbWebSocketConnection / websocket_repo.py
Push to connected clients libs/websockets/src/libs/websockets/util.py (send_websocket_message_to_user, notify_maker_and_taker)
Emit on accept bet_servicecreate_fight_accepted_message_payload in util.py, live_bet_service.py

Auth on connect: Cognito JWT in jwt_token query param (verified in websocket handler).

Local development

Client

  • Set REACT_APP_WS_URL in client/.env to your dev WebSocket URL (or rely on native build env from CI).
  • Open Profile while logged in; watch console for [WebSocketClient].

Backend

cd backend
make websocket-service    # WebSocket service + DB
make websocket-tests      # BDD component tests

Bet-service integration tests often use --profile websocket (see backend/Makefile). Local CIO/WS mocking may use Wiremock profiles in compose.yaml.

Adding a new consumer

  1. Import buildWebSocket from components/websocket/WebSocketClient.js.
  2. Pass userId and Cognito idToken (same as Profile’s bearerToken).
  3. Handle lastMessage in a useEffect—must follow Rules of Hooks; buildWebSocket wraps useWebSocket and must be called unconditionally at component top level (same pattern as Profile.js).

Prefer a single shared connection (share: true is already default) rather than opening one socket per screen.