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
+16
View File
@@ -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.
+3 -4
View File
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
+1
View File
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "/ui/",
});