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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 13:29:58 +08:00
co-authored by Claude Opus 4.6
parent 8381b3068a
commit c0a653fa3f
4 changed files with 60 additions and 4 deletions
+40
View File
@@ -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