From c0a653fa3f8f32170eb2ac7aaa4a8ee66a236843 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 14 Jul 2026 13:29:58 +0800 Subject: [PATCH] feat(host-agent): serve built console via SPA-aware static mount Add an optional single-process mode where the backend serves the built console bundle itself, so operators don't need a separate `npm run dev` for edge/dev setups. When RUNTIME_CONSOLE_STATIC_DIR points at the console dist directory, the app mounts a SpaStaticFiles handler at /ui/ (with 404 fallback to index.html for client-side routing) and redirects / to /ui/. The console build uses an empty VITE_API_BASE_URL for relative API paths (same-origin, no CORS), and Vite's base is set to /ui/ so assets resolve under the mount. /console/* JSON API is unchanged and is shared by both serve modes. api.ts now treats an explicitly-empty VITE_API_BASE_URL as "use relative paths" instead of falling back to the dev default, which previously forced absolute URLs even in same-origin builds. Co-Authored-By: Claude Opus 4.6 --- api/rest.py | 40 ++++++++++++++++++++++++++++++++++++++++ console/README.md | 16 ++++++++++++++++ console/src/api.ts | 7 +++---- console/vite.config.ts | 1 + 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/api/rest.py b/api/rest.py index a0afc4d..03dcb30 100644 --- a/api/rest.py +++ b/api/rest.py @@ -1,3 +1,5 @@ +import os +from pathlib import Path from typing import Any from api.console import create_console_router @@ -25,7 +27,27 @@ def create_app( ) -> Any: from fastapi import BackgroundTasks, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import RedirectResponse + from fastapi.staticfiles import StaticFiles from pydantic import BaseModel + from starlette.exceptions import HTTPException as StarletteHTTPException + from starlette.types import Scope + + class SpaStaticFiles(StaticFiles): + """``StaticFiles`` variant that falls back to ``index.html`` for SPA routes. + + Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown + path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the + SPA's client-side router handle it. + """ + + async def get_response(self, path: str, scope: Scope) -> Any: + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code == 404 and path != "index.html": + return await super().get_response("index.html", scope) + raise device_manager = manager or DEFAULT_MANAGER store = metadata_store or TaskMetadataStore() @@ -127,6 +149,24 @@ def create_app( ) ) + console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR") + if console_static_dir: + dist_dir = Path(console_static_dir) + if not dist_dir.is_dir(): + raise ValueError( + f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}" + ) + + @app.get("/", include_in_schema=False) + async def _redirect_to_console() -> RedirectResponse: + return RedirectResponse(url="/ui/") + + app.mount( + "/ui", + SpaStaticFiles(directory=str(dist_dir), html=True), + name="console", + ) + return app diff --git a/console/README.md b/console/README.md index 3c4f334..53b7246 100644 --- a/console/README.md +++ b/console/README.md @@ -28,3 +28,19 @@ for local frontend development. ```bash npm run build ``` + +## Same-Origin, Single-Process Mode + +For an edge/dev setup where running a separate `npm run dev` process is too heavy, +the backend can serve the built console directly from the same process: + +```bash +VITE_API_BASE_URL= npm run build +RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000 +``` + +`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works +same-origin without CORS. The console is then served at `/ui/` (with `/` +redirecting there); `/console/*` remains the JSON API used by both this mode +and local `npm run dev`. Rebuild (`npm run build`) after frontend changes — +this mode does not hot-reload. diff --git a/console/src/api.ts b/console/src/api.ts index 1eea035..d8a5d8a 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -7,10 +7,9 @@ import type { } from "./types"; const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined; -export const API_BASE_URL = (configuredBaseUrl || "http://127.0.0.1:8000").replace( - /\/$/, - "", -); +export const API_BASE_URL = ( + configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000" +).replace(/\/$/, ""); async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { diff --git a/console/vite.config.ts b/console/vite.config.ts index 6ea8547..bf8e9c1 100644 --- a/console/vite.config.ts +++ b/console/vite.config.ts @@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue"; export default defineConfig({ plugins: [vue()], + base: "/ui/", });