Coverage for src/local_deep_research/web/utils/vite_helper.py: 100%

99 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Vite integration helper for Flask 

3Handles development and production asset loading 

4""" 

5 

6import json 

7import threading 

8from pathlib import Path 

9from markupsafe import Markup 

10from loguru import logger 

11 

12# Emitted to the server log the first time a page is served with no Vite 

13# manifest (i.e. `npm run build` has never been run / assets are stale). 

14# Module-level flag so we warn once per process instead of on every request. 

15_missing_manifest_warned = False 

16_missing_manifest_warned_lock = threading.Lock() 

17 

18# Visible, self-contained warning banner rendered when the Vite build output 

19# is missing. Inline styles are intentional: the real stylesheet is exactly 

20# what failed to load, so we can't rely on it, and the banner carries no JS 

21# dependency because the JS bundle is the very thing that's broken. 

22# 

23# Templates render this via `vite_missing_assets_banner()` at the top of 

24# <body> rather than from the `vite_asset()` call in <head> — flow content in 

25# <head> only renders because the HTML parser's error recovery force-opens 

26# <body>, which no strict parser or template test would reproduce. 

27_FALLBACK_BANNER = """ 

28<div id="ldr-vite-missing-assets-banner" style="position:relative;z-index:99999;display:block;width:100%;box-sizing:border-box;background:#b91c1c;color:#ffffff;padding:12px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.5;text-align:center;border-bottom:2px solid #7f1d1d;"> 

29 <strong>Frontend assets have not been built.</strong> 

30 The page below is unstyled and has no JavaScript. 

31 Run <code style="background:rgba(255,255,255,0.25);padding:2px 6px;border-radius:3px;font-family:monospace;">npm run build</code> 

32 from the project root, then reload this page. If this banner is still here afterward, restart the server too. 

33</div> 

34""" 

35 

36 

37class ViteHelper: 

38 """Helper class for Vite integration with Flask""" 

39 

40 def __init__(self, app=None): 

41 self.app = app 

42 self.manifest = None 

43 self.is_dev = False 

44 # Set by `_load_manifest()` / `init_for_fastapi()` so the 

45 # already-broken path (see `_refresh_manifest_if_missing`) knows 

46 # where to re-check for a manifest that just appeared. Stays None 

47 # for helpers built directly in tests, which is the signal to skip 

48 # any filesystem access. 

49 self._manifest_path = None 

50 

51 if app: 

52 self.init_app(app) 

53 

54 def init_app(self, app): 

55 """Initialize the helper with Flask app""" 

56 self.app = app 

57 self.is_dev = app.debug or app.config.get("VITE_DEV_MODE", False) 

58 

59 if not self.is_dev: 

60 # Load manifest in production 

61 self._load_manifest() 

62 

63 # Register template functions 

64 app.jinja_env.globals["vite_asset"] = self.vite_asset 

65 app.jinja_env.globals["vite_hmr"] = self.vite_hmr 

66 app.jinja_env.globals["vite_missing_assets_banner"] = ( 

67 self.missing_assets_banner 

68 ) 

69 

70 def _load_manifest(self): 

71 """Load Vite manifest file""" 

72 static_dir = self.app.config.get("STATIC_DIR", "static") 

73 manifest_path = Path(static_dir) / "dist" / ".vite" / "manifest.json" 

74 self._manifest_path = manifest_path 

75 

76 if manifest_path.exists(): 

77 with open(manifest_path, "r", encoding="utf-8-sig") as f: 

78 self.manifest = json.load(f) 

79 else: 

80 # Fallback if manifest doesn't exist yet 

81 self.manifest = {} 

82 

83 def vite_hmr(self): 

84 """Return HMR client script for development""" 

85 if self.is_dev: 

86 return Markup( 

87 '<script type="module" src="http://localhost:5173/@vite/client"></script>' 

88 ) 

89 return "" 

90 

91 def vite_asset(self, entry_point="js/app.js"): 

92 """ 

93 Return appropriate script tags for the entry point 

94 

95 In development: Points to Vite dev server 

96 In production: Uses manifest to get hashed filenames 

97 """ 

98 if self.is_dev: 

99 # Development mode - use Vite dev server 

100 return Markup( 

101 f'<script type="module" src="http://localhost:5173/{entry_point}"></script>' 

102 ) 

103 

104 # Production mode - use manifest. Re-check disk first: this is a 

105 # no-op whenever assets are already known to be present (see 

106 # `_refresh_manifest_if_missing`), so the healthy request path 

107 # still does zero extra I/O. 

108 self._refresh_manifest_if_missing() 

109 if not self.manifest: 

110 # Manifest file itself is missing - the entry name is irrelevant 

111 # because nothing resolves against an absent manifest. 

112 return self._fallback_assets() 

113 

114 # Get the built file from manifest 

115 if entry_point in self.manifest: 

116 file_info = self.manifest[entry_point] 

117 file_path = f"/static/dist/{file_info['file']}" 

118 

119 # Include CSS if present 

120 css_tags = "" 

121 if "css" in file_info: 

122 for css_file in file_info["css"]: 

123 css_tags += f'<link rel="stylesheet" href="/static/dist/{css_file}">\n' 

124 

125 # Include the main JS file 

126 js_tag = f'<script type="module" src="{file_path}"></script>' 

127 

128 return Markup(css_tags + js_tag) 

129 

130 return self._fallback_assets(entry_point) 

131 

132 def _fallback_assets(self, entry_point=None): 

133 """Fallback markup used when the Vite manifest or a requested entry 

134 isn't available. 

135 

136 This only fires in production mode (`is_dev` is False) - dev-server 

137 mode returns from `vite_asset()` before ever reaching here, so it is 

138 never mistaken for a legitimate "no manifest yet" state. It means 

139 `npm run build` has never been run (or the built entry point is 

140 missing/stale), so we log it once and render a visible banner 

141 instead of silently shipping an unstyled, non-interactive page. 

142 

143 ``entry_point`` is None when the manifest file itself is missing, 

144 and is the requested entry name (e.g. ``"js/app.js"``) when the 

145 manifest exists but does not contain that entry. The two cases 

146 are logged differently because they call for different operator 

147 actions (rebuild everything vs. investigate why a specific entry 

148 is absent from an otherwise-present manifest). 

149 """ 

150 global _missing_manifest_warned 

151 with _missing_manifest_warned_lock: 

152 if not _missing_manifest_warned: 

153 if entry_point is None: 

154 logger.warning( 

155 "Vite manifest not found - the frontend was served " 

156 "without built assets (unstyled page, no JavaScript). " 

157 "Run 'npm run build' from the project root to generate " 

158 "static/dist, then restart/reload." 

159 ) 

160 else: 

161 logger.warning( 

162 f"Vite manifest entry '{entry_point}' not found - " 

163 "this asset was served from a stale/missing build " 

164 "(unstyled page or missing JavaScript). Run " 

165 "'npm run build' from the project root to regenerate " 

166 "static/dist, then restart/reload." 

167 ) 

168 _missing_manifest_warned = True 

169 

170 return Markup( 

171 "\n<!-- Vite build not found - run 'npm run build' to generate production assets -->\n" 

172 "<!-- Using existing static files as fallback -->\n" 

173 ) 

174 

175 def _refresh_manifest_if_missing(self): 

176 """Re-read the manifest from disk if it is currently known to be 

177 missing (or missing the app entry). 

178 

179 This is what lets "run `npm run build`, then reload this page" 

180 actually work for the `pip install -e .` / source audience this 

181 banner targets: `init_for_fastapi()` loads the manifest exactly 

182 once at process startup, and `ldr-web` runs uvicorn without 

183 `--reload`, so without this the in-memory manifest would stay 

184 stale until the operator restarts the server, no matter how many 

185 times they rebuild and reload. 

186 

187 Only called from the already-broken path (guarded below), so a 

188 healthy production request — the overwhelming common case — never 

189 pays for a `stat()`/read here. 

190 

191 A build in progress can leave `manifest.json` truncated or 

192 mid-write; a decode/read failure is treated the same as "still 

193 missing" rather than propagated, since crashing the request would 

194 be worse than leaving the fallback banner up for one more reload. 

195 """ 

196 if self.is_dev or not self._manifest_path: 

197 return 

198 if self.manifest and "js/app.js" in self.manifest: 

199 return 

200 try: 

201 if self._manifest_path.exists(): 

202 with open(self._manifest_path, "r", encoding="utf-8-sig") as f: 

203 manifest = json.load(f) 

204 if manifest: 

205 self.manifest = manifest 

206 except (OSError, ValueError): 

207 # ValueError covers json.JSONDecodeError (a manifest caught 

208 # mid-write). Keep serving the existing fallback state. 

209 pass 

210 

211 def assets_are_missing(self) -> bool: 

212 """True when a production page would render without built assets.""" 

213 if self.is_dev: 

214 return False 

215 self._refresh_manifest_if_missing() 

216 return not self.manifest or "js/app.js" not in self.manifest 

217 

218 def missing_assets_banner(self): 

219 """Body-level banner shown when the frontend was never built.""" 

220 if not self.assets_are_missing(): 

221 return Markup("") 

222 return Markup(_FALLBACK_BANNER) 

223 

224 def init_for_fastapi(self, static_dir, jinja2_templates): 

225 """Initialize the helper for FastAPI (no Flask app dependency). 

226 

227 Args: 

228 static_dir: Path to the static directory. 

229 jinja2_templates: FastAPI Jinja2Templates instance. 

230 """ 

231 # Dev mode via env setting — in dev, templates link to the Vite 

232 # dev server (port 5173) for HMR. In prod (default), we read 

233 # the hashed manifest. 

234 from ...settings.env_registry import get_env_setting 

235 

236 self.is_dev = bool(get_env_setting("vite.dev_mode", False)) 

237 

238 # Load manifest using static_dir directly 

239 manifest_path = Path(static_dir) / "dist" / ".vite" / "manifest.json" 

240 self._manifest_path = manifest_path 

241 if manifest_path.exists(): 

242 with open(manifest_path, "r", encoding="utf-8") as f: 

243 self.manifest = json.load(f) 

244 elif self.is_dev: 

245 # Dev mode: manifest absent is expected (Vite serves from memory). 

246 self.manifest = {} 

247 else: 

248 # Prod mode: manifest missing is a real problem — warn loudly 

249 # instead of silently serving a blank page. 

250 from loguru import logger 

251 

252 logger.warning( 

253 f"Vite manifest not found at {manifest_path}. " 

254 "Run `npm run build` to generate production assets, or " 

255 "set LDR_VITE_DEV_MODE=true and run `npm run dev`. " 

256 "The app will render without JS/CSS until this is fixed." 

257 ) 

258 self.manifest = {} 

259 

260 # Register template functions on the Jinja2 environment 

261 jinja2_templates.env.globals["vite_asset"] = self.vite_asset 

262 jinja2_templates.env.globals["vite_hmr"] = self.vite_hmr 

263 jinja2_templates.env.globals["vite_missing_assets_banner"] = ( 

264 self.missing_assets_banner 

265 ) 

266 

267 

268# Create global instance 

269vite = ViteHelper()