133 lines
4.8 KiB
Python
133 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Serve the unchanged Tencent demo UI and proxy its API to the backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
from typing import Any
|
|
|
|
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
|
from dotenv import load_dotenv
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
STATIC_ROOT = PROJECT_ROOT / "realtime_websocket" / "static"
|
|
load_dotenv(PROJECT_ROOT / ".env")
|
|
FRONTEND_HOST = os.getenv("FRONTEND_HOST", "127.0.0.1")
|
|
FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "8080"))
|
|
BACKEND_BASE_URL = os.getenv(
|
|
"BACKEND_INTERNAL_URL",
|
|
f"http://127.0.0.1:{os.getenv('WEB_PORT', '8082')}",
|
|
).rstrip("/")
|
|
HTTP_SESSION = web.AppKey("http_session", ClientSession)
|
|
|
|
|
|
def backend_url(request: web.Request) -> str:
|
|
"""Keep the original path and query while routing through the backend port."""
|
|
return f"{BACKEND_BASE_URL}{request.rel_url}"
|
|
|
|
|
|
async def index_handler(_: web.Request) -> web.FileResponse:
|
|
return web.FileResponse(
|
|
STATIC_ROOT / "index.html", headers={"Cache-Control": "no-store"}
|
|
)
|
|
|
|
|
|
async def api_stop_proxy(request: web.Request) -> web.Response:
|
|
"""Forward the Tencent page's existing stop request to the WS backend."""
|
|
async with request.app[HTTP_SESSION].get(
|
|
backend_url(request), timeout=ClientTimeout(total=5)
|
|
) as response:
|
|
body = await response.read()
|
|
return web.Response(
|
|
status=response.status,
|
|
body=body,
|
|
headers={"Content-Type": response.headers.get("Content-Type", "application/json")},
|
|
)
|
|
|
|
|
|
async def websocket_proxy(request: web.Request) -> web.WebSocketResponse:
|
|
"""Relay text and binary frames without changing the Tencent browser protocol."""
|
|
browser_ws = web.WebSocketResponse(max_msg_size=64 * 1024 * 1024, heartbeat=30)
|
|
await browser_ws.prepare(request)
|
|
try:
|
|
backend_ws = await request.app[HTTP_SESSION].ws_connect(
|
|
backend_url(request),
|
|
max_msg_size=64 * 1024 * 1024,
|
|
heartbeat=30,
|
|
autoping=True,
|
|
)
|
|
except Exception as exc:
|
|
await browser_ws.send_json({"type": "error", "message": f"backend unavailable: {exc}"})
|
|
await browser_ws.close(code=1011, message=b"backend unavailable")
|
|
return browser_ws
|
|
|
|
async def relay(source: Any, destination: Any) -> None:
|
|
async for message in source:
|
|
if message.type == WSMsgType.TEXT:
|
|
await destination.send_str(message.data)
|
|
elif message.type == WSMsgType.BINARY:
|
|
await destination.send_bytes(message.data)
|
|
elif message.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}:
|
|
if not destination.closed:
|
|
code = message.data if isinstance(message.data, int) else 1000
|
|
reason = message.extra or ""
|
|
await destination.close(code=code, message=str(reason).encode("utf-8"))
|
|
return
|
|
|
|
tasks = [
|
|
asyncio.create_task(relay(browser_ws, backend_ws)),
|
|
asyncio.create_task(relay(backend_ws, browser_ws)),
|
|
]
|
|
try:
|
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
for task in pending:
|
|
task.cancel()
|
|
await asyncio.gather(*done, *pending, return_exceptions=True)
|
|
finally:
|
|
if not backend_ws.closed:
|
|
await backend_ws.close()
|
|
if not browser_ws.closed:
|
|
await browser_ws.close()
|
|
return browser_ws
|
|
|
|
|
|
async def create_app() -> web.Application:
|
|
if not STATIC_ROOT.is_dir():
|
|
raise FileNotFoundError(f"Tencent demo static directory is missing: {STATIC_ROOT}")
|
|
parsed = urlsplit(BACKEND_BASE_URL)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.path:
|
|
raise ValueError("BACKEND_INTERNAL_URL must contain only an HTTP origin")
|
|
|
|
app = web.Application()
|
|
|
|
async def lifecycle(application: web.Application):
|
|
# An unbounded total timeout allows long recordings and slow model loads.
|
|
application[HTTP_SESSION] = ClientSession(
|
|
timeout=ClientTimeout(total=None, connect=10, sock_connect=10, sock_read=None)
|
|
)
|
|
yield
|
|
await application[HTTP_SESSION].close()
|
|
|
|
app.cleanup_ctx.append(lifecycle)
|
|
app.router.add_get("/", index_handler)
|
|
app.router.add_get("/ws", websocket_proxy)
|
|
app.router.add_get("/api/stop", api_stop_proxy)
|
|
app.router.add_static("/", STATIC_ROOT, show_index=False)
|
|
return app
|
|
|
|
|
|
def main() -> None:
|
|
print(
|
|
f"Tencent demo frontend: http://{FRONTEND_HOST}:{FRONTEND_PORT}/ "
|
|
f"(backend proxy: {BACKEND_BASE_URL})",
|
|
flush=True,
|
|
)
|
|
web.run_app(create_app(), host=FRONTEND_HOST, port=FRONTEND_PORT)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|