Coverage for src / mcp_server_langgraph / utils / spa_static_files.py: 89%
47 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 06:31 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 06:31 +0000
1"""
2SPA Static Files handler for React frontends.
4Provides a FastAPI-compatible StaticFiles mount that serves single-page applications
5with proper client-side routing support (fallback to index.html for non-file routes).
7Features:
8- Serves static assets (JS, CSS, images) normally
9- Falls back to index.html for client-side routes (React Router, etc.)
10- Returns 404 for missing static files (files with extensions)
11- Optional caching headers for production performance
13Usage:
14 from mcp_server_langgraph.utils.spa_static_files import SPAStaticFiles
16 app = FastAPI()
17 app.mount("/", SPAStaticFiles(directory="frontend/dist", html=True), name="spa")
19Note: Mount SPA AFTER all API routes to ensure APIs take precedence.
20"""
22from collections.abc import MutableMapping
23from pathlib import Path
24from typing import Any
26from starlette.responses import Response
27from starlette.staticfiles import StaticFiles
30class SPAStaticFiles(StaticFiles):
31 """
32 StaticFiles mount that supports single-page application routing.
34 When a file is not found, this handler checks if the request looks like
35 a client-side route (no file extension) and serves index.html instead.
36 This enables React Router, Vue Router, etc. to handle the routing.
38 For requests with file extensions (e.g., /assets/app.js), it returns 404
39 if the file doesn't exist.
41 Args:
42 directory: Path to the static files directory
43 html: Whether to enable HTML mode (required for SPA)
44 caching: Whether to add cache-control headers for static assets
46 Raises:
47 RuntimeError: If directory doesn't exist or index.html is missing
48 """
50 def __init__(
51 self,
52 *,
53 directory: str,
54 html: bool = True,
55 caching: bool = False,
56 ) -> None:
57 """Initialize SPAStaticFiles with validation."""
58 self.directory_path = Path(directory).resolve()
59 self.caching = caching
61 # Validate directory exists
62 if not self.directory_path.exists():
63 msg = f"Static files directory does not exist: {directory}"
64 raise RuntimeError(msg)
66 # Validate index.html exists
67 index_path = self.directory_path / "index.html"
68 if not index_path.exists():
69 msg = f"index.html not found in {directory}. SPA requires index.html."
70 raise RuntimeError(msg)
72 super().__init__(directory=directory, html=html)
74 async def get_response(self, path: str, scope: MutableMapping[str, Any]) -> Response:
75 """
76 Get response for the requested path.
78 If the file exists, serve it. If not, and the path looks like a
79 client-side route (no extension), serve index.html.
81 Args:
82 path: Requested path (e.g., "assets/app.js" or "dashboard")
83 scope: ASGI scope
85 Returns:
86 Response with file content or index.html fallback
87 """
88 try:
89 # Try to serve the requested file
90 response = await super().get_response(path, scope)
92 # Add caching headers if enabled
93 if self.caching:
94 response = self._add_cache_headers(path, response)
96 return response
98 except Exception as ex:
99 # Check if this is a 404-like error
100 # HTTPException has status_code, starlette raises it for missing files
101 status_code = getattr(ex, "status_code", None)
103 if status_code == 404: 103 ↛ 119line 103 didn't jump to line 119 because the condition on line 103 was always true
104 # Check if this looks like a static file request (has extension)
105 if "." in path.split("/")[-1]:
106 # Missing static file - return 404
107 raise
109 # Looks like a client-side route - serve index.html
110 response = await super().get_response("index.html", scope)
112 # Don't cache index.html (always serve fresh)
113 if self.caching: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
116 return response
118 # Re-raise other errors
119 raise
121 def _add_cache_headers(self, path: str, response: Response) -> Response:
122 """
123 Add appropriate cache headers based on file type.
125 Static assets (JS, CSS, images) get long cache times.
126 index.html gets no-cache to ensure fresh content.
128 Args:
129 path: File path
130 response: Response object
132 Returns:
133 Response with cache headers
134 """
135 # index.html should never be cached
136 if path == "" or path == "index.html": 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
138 return response
140 # Static assets with extensions can be cached
141 file_ext = Path(path).suffix.lower()
142 cacheable_extensions = {".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf"}
144 if file_ext in cacheable_extensions:
145 # Long cache time for immutable assets (Vite adds content hashes)
146 response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
148 return response
151def create_spa_static_files(
152 directory: str,
153 *,
154 caching: bool = False,
155) -> SPAStaticFiles | None:
156 """
157 Factory function to create SPAStaticFiles handler.
159 Returns None if the directory doesn't exist or is invalid,
160 allowing graceful degradation when frontend is not built.
162 Args:
163 directory: Path to static files directory
164 caching: Whether to add cache-control headers
166 Returns:
167 SPAStaticFiles instance or None if directory invalid
168 """
169 try:
170 return SPAStaticFiles(directory=directory, html=True, caching=caching)
171 except RuntimeError:
172 return None