mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Ship installable releases via zip/Inno Setup with optional bundled Postgres, API-hosted SPA, manual update path, and CI workflow for future tag builds. Co-authored-by: Cursor <cursoragent@cursor.com>
84 lines
2.5 KiB
Python
84 lines
2.5 KiB
Python
"""Optional SPA hosting from a Vite build (web/dist)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from .config import settings
|
|
|
|
_log = logging.getLogger("netx.ui")
|
|
|
|
# Paths that must never fall through to index.html.
|
|
_API_PREFIXES = (
|
|
"/v1/",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi.json",
|
|
"/metrics",
|
|
"/health",
|
|
)
|
|
|
|
|
|
def resolve_ui_dist() -> Path | None:
|
|
raw = str(getattr(settings, "ui_dist_dir", "") or "").strip()
|
|
if not raw:
|
|
return None
|
|
p = Path(raw)
|
|
if not p.is_absolute():
|
|
p = Path(__file__).resolve().parents[1] / p
|
|
index = p / "index.html"
|
|
if index.is_file():
|
|
return p
|
|
return None
|
|
|
|
|
|
def _is_api_or_infra(path: str) -> bool:
|
|
p = path or "/"
|
|
if p == "/health" or p.startswith("/health/"):
|
|
return True
|
|
if p in ("/metrics", "/metrics/json", "/openapi.json"):
|
|
return True
|
|
return any(p == pref.rstrip("/") or p.startswith(pref) for pref in _API_PREFIXES)
|
|
|
|
|
|
def mount_ui_if_present(app: FastAPI) -> bool:
|
|
"""Mount /assets + SPA fallback when ui_dist_dir has index.html. Returns True if mounted."""
|
|
dist = resolve_ui_dist()
|
|
if dist is None:
|
|
return False
|
|
|
|
assets = dist / "assets"
|
|
if assets.is_dir():
|
|
app.mount("/assets", StaticFiles(directory=str(assets)), name="ui_assets")
|
|
|
|
index_path = dist / "index.html"
|
|
|
|
@app.get("/")
|
|
async def spa_root() -> FileResponse:
|
|
return FileResponse(index_path)
|
|
|
|
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)
|
|
async def spa_fallback(full_path: str, request: Request) -> Response:
|
|
# Let API / infra 404s stay as API 404s (this route is last).
|
|
path = "/" + (full_path or "").lstrip("/")
|
|
if _is_api_or_infra(path):
|
|
return Response(status_code=404, content=b'{"detail":"Not Found"}', media_type="application/json")
|
|
# Prefer real files under dist (favicon, robots, etc.)
|
|
candidate = dist / full_path
|
|
try:
|
|
candidate.resolve().relative_to(dist.resolve())
|
|
except ValueError:
|
|
return FileResponse(index_path)
|
|
if candidate.is_file():
|
|
return FileResponse(candidate)
|
|
return FileResponse(index_path)
|
|
|
|
_log.info("UI mounted from %s", dist)
|
|
return True
|