from __future__ import annotations import logging from dataclasses import dataclass from pathlib import Path from typing import Dict, Iterable from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles @dataclass(frozen=True) class FrontendAssets: root: Path index_html: Path app_js: Path forecast_models_js: Path workspace_js: Path workspace_css: Path background_image: Path favicon_svg: Path @property def exists(self) -> bool: return self.root.exists() def versioned_assets(self) -> tuple[Path, ...]: return ( self.index_html, self.app_js, self.forecast_models_js, self.workspace_js, self.workspace_css, ) def resolve_frontend_assets(project_root: str | Path) -> FrontendAssets: root = Path(project_root) / "frontend" return FrontendAssets( root=root, index_html=root / "index.html", app_js=root / "app.js", forecast_models_js=root / "forecast-models.js", workspace_js=root / "workspace.js", workspace_css=root / "workspace.css", background_image=root / "AIBG.png", favicon_svg=root / "favicon.svg", ) def frontend_asset_headers() -> Dict[str, str]: return { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache", "Expires": "0", } def build_frontend_asset_version( *, assets: FrontendAssets, app_version: str, cache_version: str, ) -> str: version_parts: list[str] = [app_version, cache_version] for asset_path in assets.versioned_assets(): if asset_path.exists(): version_parts.append(str(int(asset_path.stat().st_mtime))) return "-".join(version_parts) def _serve_asset_file( asset_path: Path, *, media_type: str, not_found_detail: str, ) -> FileResponse: if not asset_path.exists(): raise HTTPException(status_code=404, detail=not_found_detail) return FileResponse( str(asset_path), media_type=media_type, headers=frontend_asset_headers(), ) def _register_file_routes( app: FastAPI, *, assets: FrontendAssets, ) -> None: def add_file_route( route_path: str, asset_path: Path, *, media_type: str, not_found_detail: str, route_name: str, ) -> None: async def serve_file() -> FileResponse: return _serve_asset_file( asset_path, media_type=media_type, not_found_detail=not_found_detail, ) serve_file.__name__ = route_name app.add_api_route(route_path, serve_file, include_in_schema=False, methods=["GET"]) file_specs: Iterable[tuple[str, Path, str, str, str]] = ( ( "/workspace.js", assets.workspace_js, "application/javascript", "Workspace JS asset not found", "serve_workspace_js", ), ( "/app.js", assets.app_js, "application/javascript", "App JS asset not found", "serve_app_js", ), ( "/forecast-models.js", assets.forecast_models_js, "application/javascript", "Forecast model registry asset not found", "serve_forecast_models_js", ), ( "/workspace.css", assets.workspace_css, "text/css", "Workspace CSS asset not found", "serve_workspace_css", ), ( "/AIBG.png", assets.background_image, "image/png", "AIBG asset not found", "serve_aibg", ), ( "/favicon.svg", assets.favicon_svg, "image/svg+xml", "Favicon not found", "serve_favicon_svg", ), ( "/favicon.ico", assets.favicon_svg, "image/svg+xml", "Favicon not found", "serve_favicon_ico", ), ) for route_path, asset_path, media_type, not_found_detail, route_name in file_specs: add_file_route( route_path, asset_path, media_type=media_type, not_found_detail=not_found_detail, route_name=route_name, ) def register_frontend_assets( app: FastAPI, *, project_root: str | Path, app_version: str, cache_version: str, logger: logging.Logger, ) -> None: assets = resolve_frontend_assets(project_root) if not assets.exists: logger.warning("Frontend path not found: %s", assets.root) return async def serve_frontend_index() -> HTMLResponse: if not assets.index_html.exists(): raise HTTPException(status_code=404, detail="Frontend index not found") html = assets.index_html.read_text(encoding="utf-8") html = html.replace( "__FRONTEND_ASSET_VERSION__", build_frontend_asset_version( assets=assets, app_version=app_version, cache_version=cache_version, ), ) return HTMLResponse(content=html, headers=frontend_asset_headers()) app.add_api_route("/", serve_frontend_index, include_in_schema=False, methods=["GET"]) app.add_api_route("/index.html", serve_frontend_index, include_in_schema=False, methods=["GET"]) _register_file_routes(app, assets=assets) app.mount("/", StaticFiles(directory=str(assets.root), html=True), name="frontend") logger.info("Mounted frontend: %s", assets.root)