""" Frontend Serving Module Handles React frontend serving and static file mounting with cache headers. """ import os from pathlib import Path from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, Response from starlette.middleware.base import BaseHTTPMiddleware from loguru import logger from typing import Dict, Any class CacheHeadersMiddleware(BaseHTTPMiddleware): """ Middleware to add cache headers to static files. This improves performance by allowing browsers to cache static assets (JS, CSS, images) for 1 year, reducing repeat visit load times. """ async def dispatch(self, request: Request, call_next): response = await call_next(request) # Only add cache headers to static files if request.url.path.startswith("/static/"): path = request.url.path.lower() # Check if file has a hash in its name (React build pattern: filename.hash.ext) # Examples: bundle.abc123.js, main.def456.chunk.js, vendors.789abc.js import re # Pattern matches: filename.hexhash.ext or filename.hexhash.chunk.ext hash_pattern = r'\.[a-f0-9]{8,}\.' has_hash = bool(re.search(hash_pattern, path)) # File extensions that should be cached cacheable_extensions = ['.js', '.css', '.woff', '.woff2', '.ttf', '.otf', '.png', '.jpg', '.jpeg', '.webp', '.svg', '.ico', '.gif'] is_cacheable_file = any(path.endswith(ext) for ext in cacheable_extensions) if is_cacheable_file: if has_hash: # Immutable files (with hash) - cache for 1 year # These files never change (new hash = new file) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" # Expires header calculated dynamically to match max-age # Modern browsers prefer Cache-Control, but Expires provides compatibility from datetime import datetime, timedelta expires_date = datetime.utcnow() + timedelta(seconds=31536000) response.headers["Expires"] = expires_date.strftime("%a, %d %b %Y %H:%M:%S GMT") else: # Non-hashed files - shorter cache (1 hour) # These might be updated, so cache for shorter time response.headers["Cache-Control"] = "public, max-age=3600" # Never cache HTML files (index.html) elif request.url.path == "/" or request.url.path.endswith(".html"): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response class FrontendServing: """Manages React frontend serving and static file mounting with cache headers.""" def __init__(self, app: FastAPI): self.app = app self.frontend_build_path = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "build") self.static_path = os.path.join(self.frontend_build_path, "static") def setup_frontend_serving(self) -> bool: """ Set up React frontend serving and static file mounting with cache headers. This method: 1. Adds cache headers middleware for static files 2. Mounts static files directory 3. Configures proper caching for performance """ try: logger.info("Setting up frontend serving with cache headers...") # Add cache headers middleware BEFORE mounting static files self.app.add_middleware(CacheHeadersMiddleware) logger.info("Cache headers middleware added") # Mount static files for React app (only if directory exists) if os.path.exists(self.static_path): self.app.mount("/static", StaticFiles(directory=self.static_path), name="static") logger.info("Frontend static files mounted successfully with cache headers") logger.info("Static files will be cached for 1 year (immutable files) or 1 hour (others)") return True else: logger.info("Frontend build directory not found. Static files not mounted.") return False except Exception as e: logger.error(f"Could not mount static files: {e}") return False def serve_frontend(self) -> FileResponse | Dict[str, Any]: """ Serve the React frontend index.html. Note: index.html is never cached to ensure users always get the latest version. Static assets (JS/CSS) are cached separately via middleware. """ try: # Check if frontend build exists index_html = os.path.join(self.frontend_build_path, "index.html") if os.path.exists(index_html): # Return FileResponse with no-cache headers for HTML response = FileResponse(index_html) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response else: return { "message": "Frontend not built. Please run 'npm run build' in the frontend directory.", "api_docs": "/api/docs" } except Exception as e: logger.error(f"Error serving frontend: {e}") return { "message": "Error serving frontend", "error": str(e), "api_docs": "/api/docs" } def get_frontend_status(self) -> Dict[str, Any]: """Get the status of frontend build and serving.""" try: index_html = os.path.join(self.frontend_build_path, "index.html") static_exists = os.path.exists(self.static_path) return { "frontend_build_path": self.frontend_build_path, "static_path": self.static_path, "index_html_exists": os.path.exists(index_html), "static_files_exist": static_exists, "frontend_ready": os.path.exists(index_html) and static_exists } except Exception as e: logger.error(f"Error checking frontend status: {e}") return { "error": str(e), "frontend_ready": False }