refactor: serve.py 改用 starlette + uvicorn,统一支持 HTTP/WebSocket/API 代理
This commit is contained in:
@@ -1 +1,4 @@
|
|||||||
|
starlette>=0.27.0
|
||||||
|
uvicorn>=0.20.0
|
||||||
|
httpx>=0.24.0
|
||||||
websockets>=10.0
|
websockets>=10.0
|
||||||
|
|||||||
+83
-98
@@ -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("/"))
|
||||||
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):
|
if os.path.isfile(file_path):
|
||||||
super().do_GET()
|
return FileResponse(file_path)
|
||||||
else:
|
return FileResponse(os.path.join(DIST_DIR, "index.html"))
|
||||||
self.path = "/index.html"
|
|
||||||
super().do_GET()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
if self.path.startswith("/api/"):
|
|
||||||
self._proxy("POST")
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
def do_PUT(self):
|
# ── API proxy ─────────────────────────────────────────────────
|
||||||
if self.path.startswith("/api/"):
|
async def proxy_api(request):
|
||||||
self._proxy("PUT")
|
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_DELETE(self):
|
url = f"{API_BASE}{request.url.path}"
|
||||||
if self.path.startswith("/api/"):
|
if request.url.query:
|
||||||
self._proxy("DELETE")
|
url += f"?{request.url.query}"
|
||||||
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()
|
|
||||||
|
|||||||
+1
-3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user