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

1""" 

2SPA Static Files handler for React frontends. 

3 

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). 

6 

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 

12 

13Usage: 

14 from mcp_server_langgraph.utils.spa_static_files import SPAStaticFiles 

15 

16 app = FastAPI() 

17 app.mount("/", SPAStaticFiles(directory="frontend/dist", html=True), name="spa") 

18 

19Note: Mount SPA AFTER all API routes to ensure APIs take precedence. 

20""" 

21 

22from collections.abc import MutableMapping 

23from pathlib import Path 

24from typing import Any 

25 

26from starlette.responses import Response 

27from starlette.staticfiles import StaticFiles 

28 

29 

30class SPAStaticFiles(StaticFiles): 

31 """ 

32 StaticFiles mount that supports single-page application routing. 

33 

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. 

37 

38 For requests with file extensions (e.g., /assets/app.js), it returns 404 

39 if the file doesn't exist. 

40 

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 

45 

46 Raises: 

47 RuntimeError: If directory doesn't exist or index.html is missing 

48 """ 

49 

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 

60 

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) 

65 

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) 

71 

72 super().__init__(directory=directory, html=html) 

73 

74 async def get_response(self, path: str, scope: MutableMapping[str, Any]) -> Response: 

75 """ 

76 Get response for the requested path. 

77 

78 If the file exists, serve it. If not, and the path looks like a 

79 client-side route (no extension), serve index.html. 

80 

81 Args: 

82 path: Requested path (e.g., "assets/app.js" or "dashboard") 

83 scope: ASGI scope 

84 

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) 

91 

92 # Add caching headers if enabled 

93 if self.caching: 

94 response = self._add_cache_headers(path, response) 

95 

96 return response 

97 

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) 

102 

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 

108 

109 # Looks like a client-side route - serve index.html 

110 response = await super().get_response("index.html", scope) 

111 

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" 

115 

116 return response 

117 

118 # Re-raise other errors 

119 raise 

120 

121 def _add_cache_headers(self, path: str, response: Response) -> Response: 

122 """ 

123 Add appropriate cache headers based on file type. 

124 

125 Static assets (JS, CSS, images) get long cache times. 

126 index.html gets no-cache to ensure fresh content. 

127 

128 Args: 

129 path: File path 

130 response: Response object 

131 

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 

139 

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"} 

143 

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" 

147 

148 return response 

149 

150 

151def create_spa_static_files( 

152 directory: str, 

153 *, 

154 caching: bool = False, 

155) -> SPAStaticFiles | None: 

156 """ 

157 Factory function to create SPAStaticFiles handler. 

158 

159 Returns None if the directory doesn't exist or is invalid, 

160 allowing graceful degradation when frontend is not built. 

161 

162 Args: 

163 directory: Path to static files directory 

164 caching: Whether to add cache-control headers 

165 

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