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
« 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"""
6import json
7import threading
8from pathlib import Path
9from markupsafe import Markup
10from loguru import logger
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()
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"""
37class ViteHelper:
38 """Helper class for Vite integration with Flask"""
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
51 if app:
52 self.init_app(app)
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)
59 if not self.is_dev:
60 # Load manifest in production
61 self._load_manifest()
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 )
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
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 = {}
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 ""
91 def vite_asset(self, entry_point="js/app.js"):
92 """
93 Return appropriate script tags for the entry point
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 )
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()
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']}"
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'
125 # Include the main JS file
126 js_tag = f'<script type="module" src="{file_path}"></script>'
128 return Markup(css_tags + js_tag)
130 return self._fallback_assets(entry_point)
132 def _fallback_assets(self, entry_point=None):
133 """Fallback markup used when the Vite manifest or a requested entry
134 isn't available.
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.
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
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 )
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).
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.
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.
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
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
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)
224 def init_for_fastapi(self, static_dir, jinja2_templates):
225 """Initialize the helper for FastAPI (no Flask app dependency).
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
236 self.is_dev = bool(get_env_setting("vite.dev_mode", False))
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
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 = {}
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 )
268# Create global instance
269vite = ViteHelper()