- ConnectionManager: Add redis_factory constructor parameter - GameService: Add engine_factory constructor parameter - AuthHandler: New class replacing standalone functions with token_verifier and conn_manager injection - Update all tests to use constructor DI instead of patch() - Update CLAUDE.md with factory injection patterns - Update services README with new patterns - Add socketio README documenting AuthHandler and events Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
236 lines
7.4 KiB
Python
236 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 auth_handler, require_auth
|
|
|
|
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 auth_handler.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 auth_handler.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 auth_handler.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",
|
|
)
|