feat(cloud-api): bake cloud-console SPA into the image and serve at /console
Multi-stage Dockerfile: stage 1 (node:20-bookworm-slim) builds cloud-console with vite base "/console/"; stage 2 (uv) copies dist/ to /app/console-static. Cloud API mounts the SPA at /console via SpaStaticFiles (StaticFiles subclass that falls back to index.html for deep-link refreshes) when the new CLOUD_CONSOLE_STATIC_DIR env is set, and 307-redirects / to /console/. Static files bypass bearer auth (the SPA shell is public; tokens are still required for /v1/*). Compose enables the mount by default; local dev still uses npm run dev + CLOUD_CONSOLE_CORS_ORIGINS. Jenkinsfile passes mirror overrides (NODE_IMAGE, NPM_REGISTRY, UV_IMAGE, APT_MIRROR, UV_INDEX_URL) as --build-arg, defaulting to CN mirrors (registry.jerryyan.net, registry.npmmirror.com, registry-ghcr.jerryyan.top, mirrors.aliyun.com) so CN builds don't time out; Dockerfile ARGs default to official upstreams so `docker build .` still works anywhere. Backend suite: 443 passed (-m "not integration"); cloud-console typecheck and production build succeed with the new base path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,15 @@ import logging
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.types import Scope
|
||||
|
||||
from cloud.auth import (
|
||||
ChainedAuthProvider,
|
||||
@@ -19,6 +23,7 @@ from cloud.auth import (
|
||||
)
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.control_config import (
|
||||
CloudConfigurationError,
|
||||
CloudControlConfig,
|
||||
load_control_config,
|
||||
validate_control_config,
|
||||
@@ -69,6 +74,25 @@ class CloudApplicationServices:
|
||||
auth_provider: Any
|
||||
|
||||
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
|
||||
|
||||
``StaticFiles(html=True)`` only serves ``index.html`` for the mount root and
|
||||
for directory roots; an unknown path like ``/console/tasks/abc`` raises a
|
||||
plain 404, which breaks deep-link refreshes in a browser-running SPA. This
|
||||
subclass intercepts 404s for non-asset paths and re-serves ``index.html``
|
||||
so the SPA router can take over.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
config: CloudControlConfig | None = None,
|
||||
@@ -237,6 +261,24 @@ def create_app(
|
||||
lease_duration_seconds=control_config.lease_duration_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
if control_config.console_static_dir:
|
||||
dist_dir = Path(control_config.console_static_dir)
|
||||
if not dist_dir.is_dir():
|
||||
raise CloudConfigurationError(
|
||||
f"CLOUD_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def _redirect_to_console() -> RedirectResponse:
|
||||
return RedirectResponse(url="/console/")
|
||||
|
||||
app.mount(
|
||||
"/console",
|
||||
SpaStaticFiles(directory=str(dist_dir), html=True),
|
||||
name="cloud-console",
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user