128 lines
4.5 KiB
Python
128 lines
4.5 KiB
Python
# -*- 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")
|
|
|