# -*- coding: utf-8 -*- """ Production frontend service. Serves the built frontend static files on port 80 (configurable via FRONTEND_PORT) and proxies API / WebSocket requests to backend services. """ import asyncio import logging import os from pathlib import Path import httpx import websockets from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, Response logger = logging.getLogger(__name__) FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" AGENT_SERVICE_URL = os.getenv("AGENT_SERVICE_URL", "http://localhost:8000") RUNTIME_SERVICE_URL = os.getenv("RUNTIME_SERVICE_URL", "http://localhost:8003") GATEWAY_WS_URL = os.getenv("GATEWAY_WS_URL", "") app = FastAPI(title="EvoTraders Frontend") async def _resolve_gateway_ws_url() -> str: """Resolve the Gateway WebSocket URL dynamically from runtime API.""" if GATEWAY_WS_URL: return GATEWAY_WS_URL try: async with httpx.AsyncClient(timeout=5) as client: resp = await client.get(f"{RUNTIME_SERVICE_URL}/api/runtime/gateway/port") data = resp.json() if data.get("is_running") and data.get("port"): url = f"ws://localhost:{data['port']}" logger.info(f"[Frontend] Resolved gateway URL: {url}") return url except Exception as e: logger.warning(f"[Frontend] Failed to resolve gateway port: {e}") fallback = f"ws://localhost:{os.getenv('GATEWAY_PORT', '8765')}" logger.info(f"[Frontend] Using fallback gateway URL: {fallback}") return fallback # ── API reverse proxy ──────────────────────────────────────────────── @app.api_route( "/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], ) async def proxy_api(request: Request, path: str): """Forward /api/* requests to the appropriate backend service.""" target = RUNTIME_SERVICE_URL if path.startswith("runtime/") else AGENT_SERVICE_URL body = await request.body() async with httpx.AsyncClient(timeout=30) as client: resp = await client.request( method=request.method, url=f"{target}/api/{path}", content=body, headers={ k: v for k, v in request.headers.items() if k.lower() not in ("host", "transfer-encoding") }, ) return Response( content=resp.content, status_code=resp.status_code, headers=dict(resp.headers), ) # ── WebSocket proxy ────────────────────────────────────────────────── @app.websocket("/ws") async def proxy_ws(ws: WebSocket): """Proxy WebSocket connections to the Gateway (port resolved dynamically).""" gateway_url = await _resolve_gateway_ws_url() await ws.accept() upstream = None try: upstream = await websockets.asyncio.client.connect( gateway_url, ping_interval=20, ping_timeout=120, max_size=10 * 1024 * 1024, # 10MB ) async def client_to_upstream(): try: while True: data = await ws.receive_text() await upstream.send(data) except WebSocketDisconnect: pass async def upstream_to_client(): try: async for msg in upstream: if isinstance(msg, str): await ws.send_text(msg) else: await ws.send_bytes(msg) except websockets.exceptions.ConnectionClosed: pass await asyncio.gather(client_to_upstream(), upstream_to_client()) except Exception as e: logger.warning(f"[Frontend] WebSocket proxy error: {e}") finally: if upstream: await upstream.close() # ── Static files (SPA fallback) ───────────────────────────────────── if FRONTEND_DIST.is_dir(): @app.get("/{full_path:path}") async def serve_spa(full_path: str): """Serve static files; fall back to index.html for SPA routing.""" file_path = FRONTEND_DIST / full_path if full_path and file_path.is_file(): return FileResponse(file_path) return FileResponse(FRONTEND_DIST / "index.html")