e833b923c8
接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下); 含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
148 lines
4.6 KiB
Python
148 lines
4.6 KiB
Python
"""Browser Worker — Playwright Chromium + WebSocket stream."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
|
|
from pydantic import BaseModel, Field
|
|
|
|
from config import settings
|
|
from session_manager import manager
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger("browser_worker")
|
|
|
|
|
|
def _check_key(header: str | None) -> None:
|
|
if not settings.WORKER_SECRET:
|
|
raise HTTPException(status_code=503, detail="Worker secret not configured")
|
|
if not header or header != settings.WORKER_SECRET:
|
|
raise HTTPException(status_code=401, detail="Invalid worker key")
|
|
|
|
|
|
class CreateSessionBody(BaseModel):
|
|
session_id: str = Field(min_length=8, max_length=64)
|
|
viewport_w: int = Field(default=1280, ge=320, le=1920)
|
|
viewport_h: int = Field(default=720, ge=240, le=1080)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
await manager.start()
|
|
logger.info("Playwright Chromium ready")
|
|
yield
|
|
await manager.stop()
|
|
|
|
|
|
app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {
|
|
"status": "ok",
|
|
"sessions": manager.session_count(),
|
|
"max_sessions": settings.WORKER_MAX_SESSIONS,
|
|
}
|
|
|
|
|
|
@app.post("/v1/sessions")
|
|
async def create_session(
|
|
body: CreateSessionBody,
|
|
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
|
):
|
|
_check_key(x_nexus_worker_key)
|
|
try:
|
|
await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h)
|
|
except ValueError as exc:
|
|
code = str(exc)
|
|
if code == "max_sessions":
|
|
raise HTTPException(status_code=503, detail="Max sessions reached") from exc
|
|
if code == "session_exists":
|
|
raise HTTPException(status_code=409, detail="Session already exists") from exc
|
|
raise HTTPException(status_code=400, detail=code) from exc
|
|
return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h}
|
|
|
|
|
|
@app.delete("/v1/sessions/{session_id}")
|
|
async def delete_session(
|
|
session_id: str,
|
|
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
|
):
|
|
_check_key(x_nexus_worker_key)
|
|
if not await manager.destroy_session(session_id):
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.websocket("/v1/sessions/{session_id}/stream")
|
|
async def session_stream(
|
|
websocket: WebSocket,
|
|
session_id: str,
|
|
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
|
):
|
|
key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key")
|
|
if not settings.WORKER_SECRET or key != settings.WORKER_SECRET:
|
|
await websocket.close(code=4401, reason="Invalid worker key")
|
|
return
|
|
|
|
if not manager.get_session(session_id):
|
|
await websocket.close(code=4404, reason="Session not found")
|
|
return
|
|
|
|
await websocket.accept()
|
|
|
|
outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
|
|
async def send(msg: dict[str, Any]) -> None:
|
|
await outbound.put(msg)
|
|
|
|
try:
|
|
await manager.attach_stream(session_id, send)
|
|
except ValueError:
|
|
await websocket.close(code=4404, reason="Session not found")
|
|
return
|
|
except RuntimeError:
|
|
await manager.destroy_session(session_id)
|
|
await websocket.close(code=4503, reason="Browser not ready")
|
|
return
|
|
|
|
async def pump_out() -> None:
|
|
while True:
|
|
msg = await outbound.get()
|
|
await websocket.send_json(msg)
|
|
|
|
pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}")
|
|
try:
|
|
while True:
|
|
raw = await websocket.receive_text()
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"})
|
|
continue
|
|
if not isinstance(msg, dict):
|
|
continue
|
|
await manager.handle_message(session_id, msg)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
pump_task.cancel()
|
|
try:
|
|
await pump_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await manager.destroy_session(session_id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
host, _, port = settings.WORKER_BIND.partition(":")
|
|
uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info")
|