refactor: serve.py 改用 starlette + uvicorn,统一支持 HTTP/WebSocket/API 代理

This commit is contained in:
Hermes Agent
2026-05-13 13:25:16 +08:00
parent 518e19a282
commit cbde341d8f
3 changed files with 89 additions and 103 deletions
+3
View File
@@ -1 +1,4 @@
starlette>=0.27.0
uvicorn>=0.20.0
httpx>=0.24.0
websockets>=10.0 websockets>=10.0
+84 -99
View File
@@ -1,130 +1,115 @@
"""Simple static file server with API and WebSocket proxy""" """KVM Frontend - Static files + API proxy + WebSocket VNC proxy"""
import asyncio
import http.server
import websockets
import threading
import urllib.request
import urllib.error
import os import os
import json import re
from starlette.applications import Starlette
from starlette.routing import Route, WebSocketRoute, Mount
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse, RedirectResponse
from starlette.websockets import WebSocket
import uvicorn
HOST = "0.0.0.0"
PORT = 8006 PORT = 8006
DIST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist") DIST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist")
API_BASE = "http://127.0.0.1:8004" API_BASE = "http://127.0.0.1:8004"
WS_BASE = "ws://127.0.0.1:8004" WS_BASE = "ws://127.0.0.1:8004"
class KVMHandler(http.server.SimpleHTTPRequestHandler): # ── SPA fallback ──────────────────────────────────────────────
def __init__(self, *args, **kwargs): async def serve_spa(request):
super().__init__(*args, directory=DIST_DIR, **kwargs) path = request.url.path
file_path = os.path.join(DIST_DIR, path.lstrip("/"))
if os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse(os.path.join(DIST_DIR, "index.html"))
def do_GET(self):
if self.path.startswith("/api/"):
self._proxy("GET")
elif self.path == "/" or self.path == "":
self.path = "/index.html"
super().do_GET()
else:
file_path = os.path.join(DIST_DIR, self.path.lstrip("/"))
if os.path.isfile(file_path):
super().do_GET()
else:
self.path = "/index.html"
super().do_GET()
def do_POST(self): # ── API proxy ─────────────────────────────────────────────────
if self.path.startswith("/api/"): async def proxy_api(request):
self._proxy("POST") from starlette.requests import Request
else: body = await request.body()
self.send_error(404) headers = {}
for key in ["content-type", "authorization"]:
val = request.headers.get(key)
if val:
headers[key.replace("_", "-")] = val
def do_PUT(self): url = f"{API_BASE}{request.url.path}"
if self.path.startswith("/api/"): if request.url.query:
self._proxy("PUT") url += f"?{request.url.query}"
else:
self.send_error(404)
def do_DELETE(self):
if self.path.startswith("/api/"):
self._proxy("DELETE")
else:
self.send_error(404)
def _proxy(self, method):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else None
url = f"{API_BASE}{self.path}"
req = urllib.request.Request(url, data=body, method=method)
for key in ["Content-Type", "Authorization"]:
if key in self.headers:
req.add_header(key, self.headers[key])
async with __import__("httpx").AsyncClient(timeout=30) as client:
try: try:
with urllib.request.urlopen(req, timeout=30) as resp: resp = await client.request(
resp_body = resp.read() method=request.method,
self.send_response(resp.status) url=url,
self.send_header("Content-Type", "application/json") content=body,
self.send_header("Access-Control-Allow-Origin", "*") headers=headers,
self.end_headers() )
self.wfile.write(resp_body) return Response(
except urllib.error.HTTPError as e: resp.content,
resp_body = e.read() status_code=resp.status_code,
self.send_response(e.code) headers={"access-control-allow-origin": "*"},
self.send_header("Content-Type", "application/json") )
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(resp_body)
except Exception as e: except Exception as e:
self.send_response(502) from starlette.responses import JSONResponse
self.send_header("Content-Type", "application/json") return JSONResponse({"error": str(e)}, status_code=502)
self.end_headers() from starlette.responses import Response
self.wfile.write(json.dumps({"error": str(e)}).encode()) return Response("", status_code=500)
def log_message(self, format, *args):
pass
async def websocket_proxy(websocket, path): # ── WebSocket VNC proxy ────────────────────────────────────────
"""Proxy WebSocket connections to backend""" async def vnc_websocket(websocket: WebSocket):
ws_url = f"{WS_BASE}{path}" await websocket.accept()
# Build backend WebSocket URL
path = websocket.url.path # e.g. /ws/vnc/vm-name?host_id=local
backend_url = f"{WS_BASE}{path}"
try: try:
async with websockets.connect(ws_url) as backend_ws: async with __import__("websockets").connect(backend_url) as backend:
async def forward_to_backend(): async def forward_to_backend():
async for msg in websocket: async for msg in websocket.iter_text():
await backend_ws.send(msg) await backend.send(msg)
async for msg in websocket.iter_bytes():
await backend.send(msg)
async def forward_to_frontend(): async def forward_to_frontend():
async for msg in backend_ws: async for msg in backend:
await websocket.send(msg) if isinstance(msg, bytes):
await websocket.send_bytes(msg)
else:
await websocket.send_text(msg)
await asyncio.gather( await __import__("asyncio").gather(
forward_to_backend(), forward_to_backend(),
forward_to_frontend() forward_to_frontend(),
) )
except Exception as e: except Exception as e:
print(f"WebSocket proxy error: {e}") print(f"WebSocket proxy error: {e}")
finally:
try:
await websocket.close()
except Exception:
pass
async def ws_handler(websocket, path): from starlette.responses import Response
await websocket_proxy(websocket, path)
routes = [
WebSocketRoute("/ws/{path:path}", vnc_websocket, name="vnc"),
Route("/api/{path:path}", proxy_api),
Route("/api", proxy_api),
Mount("/", StaticFiles(directory=DIST_DIR, html=True), name="static"),
]
def run_websocket_server(): app = Starlette(routes=routes)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(websockets.serve(ws_handler, "0.0.0.0", PORT))
loop.run_forever()
if __name__ == "__main__": if __name__ == "__main__":
# Start WebSocket server in background thread uvicorn.run(
ws_thread = threading.Thread(target=run_websocket_server, daemon=True) app,
ws_thread.start() host=HOST,
print(f"WebSocket proxy listening on ws://0.0.0.0:{PORT}") port=PORT,
log_level="info",
# Start HTTP server )
server = http.server.HTTPServer(("0.0.0.0", PORT), KVMHandler)
print(f"Static file server serving on http://0.0.0.0:{PORT}")
server.serve_forever()
+2 -4
View File
@@ -100,10 +100,8 @@ cd "$FRONTEND_DIR"
npm install npm install
npm run build npm run build
# 安装前端 Python 依赖 (serve.py 需要 websockets) # 安装前端 Python 依赖 (starlette + uvicorn)
if ! pip show websockets &>/dev/null; then pip install -r requirements.txt
pip install -r requirements.txt
fi
echo -e "${YELLOW}[5/5] 验证前端...${NC}" echo -e "${YELLOW}[5/5] 验证前端...${NC}"
if [[ -d "dist" ]]; then if [[ -d "dist" ]]; then