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:
+40
@@ -1,3 +1,5 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.console import create_console_router
|
from api.console import create_console_router
|
||||||
@@ -25,7 +27,27 @@ def create_app(
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
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
|
device_manager = manager or DEFAULT_MANAGER
|
||||||
store = metadata_store or TaskMetadataStore()
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,3 +28,19 @@ for local frontend development.
|
|||||||
```bash
|
```bash
|
||||||
npm run build
|
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
@@ -7,10 +7,9 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
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> {
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
base: "/ui/",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user