mantimon-tcg/backend/app/socketio/server.py
Cal Corum 0c810e5b30 Add Phase 4 WebSocket infrastructure (WS-001 through GS-001)
WebSocket Message Schemas (WS-002):
- Add Pydantic models for all client/server WebSocket messages
- Implement discriminated unions for message type parsing
- Include JoinGame, Action, Resign, Heartbeat client messages
- Include GameState, ActionResult, Error, TurnStart server messages

Connection Manager (WS-003):
- Add Redis-backed WebSocket connection tracking
- Implement user-to-sid mapping with TTL management
- Support game room association and opponent lookup
- Add heartbeat tracking for connection health

Socket.IO Authentication (WS-004):
- Add JWT-based authentication middleware
- Support token extraction from multiple formats
- Implement session setup with ConnectionManager integration
- Add require_auth helper for event handlers

Socket.IO Server Setup (WS-001):
- Configure AsyncServer with ASGI mode
- Register /game namespace with event handlers
- Integrate with FastAPI via ASGIApp wrapper
- Configure CORS from application settings

Game Service (GS-001):
- Add stateless GameService for game lifecycle orchestration
- Create engine per-operation using rules from GameState
- Implement action-based RNG seeding for deterministic replay
- Add rng_seed field to GameState for replay support

Architecture verified:
- Core module independence (no forbidden imports)
- Config from request pattern (rules in GameState)
- Dependency injection (constructor deps, method config)
- All 1090 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:21:20 -06:00

241 lines
7.4 KiB
Python

"""Socket.IO server configuration and ASGI app creation.
This module sets up the python-socketio AsyncServer and creates the combined
ASGI application that mounts Socket.IO alongside FastAPI.
Architecture:
- AsyncServer handles WebSocket connections with async_mode='asgi'
- Socket.IO app is mounted at /socket.io path
- CORS settings match FastAPI configuration
- JWT authentication on connect via auth.py
- Namespaces are registered for different communication domains
Namespaces:
/game - Active game communication (actions, state updates)
/lobby - Pre-game lobby (matchmaking, invites) - Phase 6
Authentication:
Clients must provide a JWT access token in the auth parameter:
socket.connect({ auth: { token: "JWT_ACCESS_TOKEN" } })
Example:
from app.socketio import create_socketio_app
from fastapi import FastAPI
fastapi_app = FastAPI()
combined_app = create_socketio_app(fastapi_app)
# Run with: uvicorn app.main:app
"""
import logging
from typing import TYPE_CHECKING
import socketio
from app.config import settings
from app.socketio.auth import (
authenticate_connection,
cleanup_authenticated_session,
require_auth,
setup_authenticated_session,
)
if TYPE_CHECKING:
from fastapi import FastAPI
logger = logging.getLogger(__name__)
# Create the AsyncServer instance
# - async_mode='asgi' for ASGI compatibility with uvicorn
# - cors_allowed_origins matches FastAPI CORS settings
# - logger enables Socket.IO internal logging in debug mode
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins=settings.cors_origins if settings.cors_origins else [],
logger=settings.debug,
engineio_logger=settings.debug,
)
# =============================================================================
# /game Namespace - Active Game Communication
# =============================================================================
# These are skeleton handlers that will be fully implemented in WS-005.
# For now, they provide basic connection lifecycle handling.
@sio.event(namespace="/game")
async def connect(
sid: str, environ: dict[str, object], auth: dict[str, object] | None = None
) -> bool | None:
"""Handle client connection to /game namespace.
Authenticates the connection using JWT from auth data.
Rejects connections without valid authentication.
Args:
sid: Socket session ID assigned by Socket.IO.
environ: WSGI/ASGI environ dict with request info.
auth: Authentication data sent by client (JWT token).
Expected format: { token: "JWT_ACCESS_TOKEN" }
Returns:
True to accept connection, False to reject.
None is treated as True (accept).
"""
# Authenticate the connection
auth_result = await authenticate_connection(sid, auth)
if not auth_result.success:
logger.warning(
f"Connection rejected for {sid}: {auth_result.error_code} - {auth_result.error_message}"
)
# Emit error before rejecting (client may receive this)
await sio.emit(
"auth_error",
{
"code": auth_result.error_code,
"message": auth_result.error_message,
},
to=sid,
namespace="/game",
)
return False
# Set up authenticated session and register connection
await setup_authenticated_session(sio, sid, auth_result.user_id, namespace="/game")
logger.info(f"Client authenticated to /game: sid={sid}, user_id={auth_result.user_id}")
return True
@sio.event(namespace="/game")
async def disconnect(sid: str) -> None:
"""Handle client disconnection from /game namespace.
Cleans up connection state and notifies other game participants.
Args:
sid: Socket session ID of disconnecting client.
"""
# Clean up session and get user info
user_id = await cleanup_authenticated_session(sid, namespace="/game")
if user_id:
logger.info(f"Client disconnected from /game: sid={sid}, user_id={user_id}")
# TODO (WS-005): Notify opponent of disconnection if in game
else:
logger.debug(f"Unauthenticated client disconnected: {sid}")
@sio.on("game:join", namespace="/game")
async def on_game_join(sid: str, data: dict[str, object]) -> dict[str, object]:
"""Handle request to join/rejoin a game session.
Args:
sid: Socket session ID.
data: Message containing game_id and optional last_event_id for resume.
Returns:
Response with game state or error.
"""
logger.debug(f"game:join from {sid}: {data}")
# TODO (WS-005): Implement with GameService
return {"error": "Not implemented yet"}
@sio.on("game:action", namespace="/game")
async def on_game_action(sid: str, data: dict[str, object]) -> dict[str, object]:
"""Handle game action from player.
Args:
sid: Socket session ID.
data: Action message with type and parameters.
Returns:
Action result or error.
"""
logger.debug(f"game:action from {sid}: {data}")
# TODO (WS-005): Implement with GameService
return {"error": "Not implemented yet"}
@sio.on("game:resign", namespace="/game")
async def on_game_resign(sid: str, data: dict[str, object]) -> dict[str, object]:
"""Handle player resignation.
Args:
sid: Socket session ID.
data: Resignation message (may be empty).
Returns:
Confirmation or error.
"""
logger.debug(f"game:resign from {sid}: {data}")
# TODO (WS-005): Implement with GameService
return {"error": "Not implemented yet"}
@sio.on("game:heartbeat", namespace="/game")
async def on_game_heartbeat(sid: str, data: dict[str, object] | None = None) -> dict[str, object]:
"""Handle heartbeat to keep connection alive.
Updates last_seen timestamp in ConnectionManager to prevent
the connection from being marked as stale.
Args:
sid: Socket session ID.
data: Optional heartbeat data (message_id for tracking).
Returns:
Heartbeat acknowledgment with server timestamp.
"""
from datetime import UTC, datetime
from app.services.connection_manager import connection_manager
# Require authentication
user_id = await require_auth(sio, sid)
if not user_id:
return {"error": "Not authenticated", "code": "unauthenticated"}
# Update last_seen in ConnectionManager
await connection_manager.update_heartbeat(sid)
# Return acknowledgment with timestamp
return {
"type": "heartbeat_ack",
"timestamp": datetime.now(UTC).isoformat(),
"message_id": data.get("message_id") if data else None,
}
# =============================================================================
# ASGI App Creation
# =============================================================================
def create_socketio_app(fastapi_app: "FastAPI") -> socketio.ASGIApp:
"""Create combined ASGI app with Socket.IO mounted alongside FastAPI.
This wraps the FastAPI app with Socket.IO, handling:
- WebSocket connections at /socket.io/
- HTTP requests passed through to FastAPI
Args:
fastapi_app: The FastAPI application instance.
Returns:
Combined ASGI application to use with uvicorn.
Example:
app = FastAPI()
combined = create_socketio_app(app)
# uvicorn will use 'combined' as the ASGI app
"""
return socketio.ASGIApp(
sio,
other_asgi_app=fastapi_app,
socketio_path="socket.io",
)