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

283 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Vite integration helper for FastAPI 

3Handles development and production asset loading 

4""" 

5 

6import hashlib 

7import json 

8import threading 

9import time 

10from dataclasses import dataclass, field 

11from pathlib import Path, PureWindowsPath 

12from typing import Any, Optional 

13from urllib.parse import quote 

14from markupsafe import Markup 

15from loguru import logger 

16 

17# Upper bound on the manifest.json bytes we are willing to read. The real 

18# manifest for this project is a few tens of KiB; anything vastly bigger is a 

19# corrupted or substituted file, and reading it whole would hand an unbounded 

20# allocation (and an unbounded `json.loads`) to whatever wrote it. We read one 

21# byte past the cap so "too large" is detected without materialising the file. 

22_MAX_MANIFEST_BYTES = 4 * 1024 * 1024 

23 

24# Causes we have already reported, so a persistent failure logs once instead 

25# of once per request. Keyed by cause rather than by a single boolean: a 

26# second, *different* failure still gets reported instead of being swallowed 

27# because some earlier transient already burned the only warning. The key is 

28# deliberately free of the manifest fingerprint: a manifest rewritten with 

29# fresh garbage on every request produces new bytes but the *same* operator 

30# problem, and keying on the bytes would re-warn once per render. The 

31# fingerprint goes in the message body instead, so the one line that is 

32# emitted still identifies the snapshot it describes. Module-level so the 

33# de-duplication is process-wide, matching the "warn once per process" intent 

34# of the original flag. 

35_warned_causes = set() 

36_warned_causes_lock = threading.Lock() 

37_WARNED_CAUSES_LIMIT = 64 

38 

39# Bound on remembered per-entry validation results (see `_entry_problem`). 

40# Concurrent renders each test this bound and then insert, so the memo can 

41# overshoot it by up to one entry per racing thread; it is a sanity cap on an 

42# otherwise unbounded key space, not an exact quota. 

43_VERIFIED_ENTRIES_LIMIT = 64 

44 

45# How long a positive per-entry verification stays good before that entry's 

46# reference closure is walked again. The memo exists so the healthy path costs 

47# one dict lookup instead of a `resolve()` plus an `is_file()` per referenced 

48# path; but a memo is only *discarded* when the manifest bytes change, so 

49# without an expiry a verification outlives the files it verified: deleting a 

50# chunk from `dist/` while leaving manifest.json alone left the entry serving 

51# a URL for a file that is no longer there, with no banner and no log line, 

52# until the next rebuild or a restart. Re-walking at most once per entry per 

53# window bounds how long that can last; a few seconds is far below the time 

54# any operator needs to notice, and it keeps the steady-state cost at 

55# essentially the memo's. 

56_VERIFICATION_TTL_SECONDS = 5.0 

57 

58# Bound to a module-level name so a test can drive the window with a fake 

59# clock instead of sleeping. Monotonic, so a wall-clock adjustment cannot make 

60# a memo look arbitrarily fresh (or expire it early). 

61_monotonic = time.monotonic 

62 

63 

64def _warn_once(cause_key, message): 

65 """Log ``message`` the first time this ``cause_key`` is seen.""" 

66 with _warned_causes_lock: 

67 if cause_key in _warned_causes: 

68 return 

69 if len(_warned_causes) >= _WARNED_CAUSES_LIMIT: 69 ↛ 73line 69 didn't jump to line 73 because the condition on line 69 was never true

70 # Pathological churn (e.g. a manifest whose *causes* keep 

71 # changing) must not grow this set without bound; dropping the 

72 # history costs at most a repeated warning. 

73 _warned_causes.clear() 

74 _warned_causes.add(cause_key) 

75 logger.warning(message) 

76 

77 

78def _for_log(value): 

79 """Render a manifest-supplied string safely for a single log line. 

80 

81 Manifest keys and file paths come from a JSON file on disk that this 

82 process does not write. A key containing a newline (or a CR, or an ANSI 

83 escape) would otherwise forge additional log lines - a fabricated 

84 "ERROR ..." record indistinguishable from a real one in any log 

85 aggregator. Non-printable characters are escaped and the result is 

86 truncated, so one bad manifest key cannot rewrite the log. 

87 """ 

88 text = value if isinstance(value, str) else str(value) 

89 if len(text) > 200: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 text = text[:200] + "..." 

91 return "".join( 

92 character if character.isprintable() else f"\\u{ord(character):04x}" 

93 for character in text 

94 ) 

95 

96 

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

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

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

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

101# 

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

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

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

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

106_FALLBACK_BANNER = """ 

107<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;"> 

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

109 The page below is unstyled and has no JavaScript. 

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

111 from the project root, then reload this page — the rebuild is picked up 

112 automatically, without a restart. If the banner is still here after a 

113 successful build and reload, the server log names the cause; restarting 

114 the server is the last resort. 

115</div> 

116""" 

117 

118 

119@dataclass(frozen=True, eq=False) 

120class _ManifestState: 

121 """Everything derived from one read of manifest.json, as one value. 

122 

123 The point of grouping these is that they must be read together or not at 

124 all. `vite_asset()` renders from a manifest, decides whether that 

125 manifest's entry is servable, and reports why it isn't - three questions 

126 that must all be answered about the *same* snapshot. Holding them in 

127 separate attributes let a positive verification of manifest A be stored 

128 under manifest B's fingerprint (a rebuild landing between the two reads), 

129 after which B was served with validation skipped, durably, until its 

130 bytes changed again. 

131 

132 So readers take `self._state` once and use only that object, and writers 

133 publish a whole new state in a single attribute store. The dataclass is 

134 frozen to make "mutate one field" impossible to write by accident. 

135 `verified_entries` is a mutable dict owned by *this* state: it memoises 

136 results for these bytes only, and a new state starts with a fresh one, so 

137 a memo can never outlive the manifest it describes. It cannot outlive the 

138 *files* it describes either by more than `_VERIFICATION_TTL_SECONDS`, 

139 because each result is stamped with the time it was taken and expires 

140 after that window. 

141 """ 

142 

143 manifest: Optional[dict] = None 

144 # SHA-256 of the exact manifest.json bytes that produced `manifest`, or 

145 # None when `manifest` did not come from a disk read (a direct 

146 # assignment, or a failure). Deriving it from the parsed bytes keeps the 

147 # pair coherent even if a rebuild replaces the file during startup, and 

148 # also detects a same-size rewrite whose mtime is restored or coarse. 

149 fingerprint: Optional[str] = None 

150 # SHA-256 of the last manifest.json bytes that were read and refused. 

151 # Acceptance is a pure function of those bytes (no filesystem lookups 

152 # happen during adoption), so identical bytes always get the identical 

153 # verdict: remembering the fingerprint lets the degraded path skip the 

154 # JSON parse until the file actually changes. 

155 rejected_fingerprint: Optional[str] = None 

156 # (cause, location) describing why this state has no usable manifest, or 

157 # None when no read has failed. Lets the fallback markup and its log 

158 # distinguish "no manifest file at all" from "manifest present but 

159 # refused", which call for different operator actions. 

160 failure: Optional[tuple] = None 

161 # Entry point -> monotonic timestamp of the check that found its whole 

162 # blocking reference closure on disk, for *this* manifest. Positive 

163 # results only: a rejected entry is re-checked on the next call so a 

164 # chunk that lands late is picked up without waiting for the manifest 

165 # bytes to change. A positive result is re-checked too, once its 

166 # timestamp is older than `_VERIFICATION_TTL_SECONDS`, so a file deleted 

167 # after the check is noticed within that window of the next render 

168 # rather than never - expiry is only evaluated when the entry is next 

169 # requested, not on a timer, so an idle server never notices on its own. 

170 verified_entries: dict = field(default_factory=dict) 

171 

172 

173class ViteHelper: 

174 """Helper class for Vite integration with FastAPI. 

175 

176 The `init_app()` / `_load_manifest()` pair is the legacy Flask entry 

177 point; the shipped application reaches this class through 

178 `init_for_fastapi()`. 

179 """ 

180 

181 def __init__(self, app=None): 

182 self.app = app 

183 self.is_dev = False 

184 # Set by `_load_manifest()` / `init_for_fastapi()` so 

185 # `_refresh_manifest_if_stale()` knows where to re-check for a 

186 # rebuilt/just-appeared manifest. Stays None for helpers built 

187 # directly in tests, which is the signal to skip any filesystem 

188 # access. 

189 self._manifest_path = None 

190 # The single coherent snapshot every reader takes a reference to. 

191 self._state = _ManifestState() 

192 

193 if app: 

194 self.init_app(app) 

195 

196 @property 

197 def manifest(self): 

198 """The manifest dict of the current snapshot (None before any load).""" 

199 return self._state.manifest 

200 

201 @manifest.setter 

202 def manifest(self, value): 

203 # Assigning a manifest directly starts a brand-new snapshot: no 

204 # fingerprint describes these bytes (they never came from a read), 

205 # and no earlier verification result may carry over to them. 

206 self._state = _ManifestState(manifest=value) 

207 

208 @property 

209 def _manifest_fingerprint(self): 

210 return self._state.fingerprint 

211 

212 @property 

213 def _rejected_fingerprint(self): 

214 return self._state.rejected_fingerprint 

215 

216 @property 

217 def _manifest_failure(self): 

218 return self._state.failure 

219 

220 @property 

221 def _verified_entries(self): 

222 return self._state.verified_entries 

223 

224 def init_app(self, app): 

225 """Initialize the helper with Flask app""" 

226 self.app = app 

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

228 

229 if not self.is_dev: 

230 # Load manifest in production 

231 self._load_manifest() 

232 

233 # Register template functions 

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

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

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

237 self.missing_assets_banner 

238 ) 

239 

240 def _load_manifest(self): 

241 """Load Vite manifest file""" 

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

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

244 self._manifest_path = manifest_path 

245 self._state = _ManifestState(manifest={}) 

246 self._adopt_manifest_from_disk() 

247 

248 def vite_hmr(self): 

249 """Return HMR client script for development""" 

250 if self.is_dev: 

251 return Markup( 

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

253 ) 

254 return "" 

255 

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

257 """ 

258 Return appropriate script tags for the entry point 

259 

260 In development: Points to Vite dev server 

261 In production: Uses manifest to get hashed filenames 

262 """ 

263 if self.is_dev: 

264 # Development mode - use Vite dev server 

265 # Development entry names come from trusted template calls; this is a development-only asset tag. 

266 return Markup( # nosec B704 

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

268 ) 

269 

270 self._refresh_manifest_if_stale() 

271 # Read the snapshot once into a local: a concurrent refresh (which is 

272 # not serialised, and really is concurrent on a free-threaded build) 

273 # can publish a new state at any point, and the manifest, the 

274 # verification verdict and the failure reason below must all describe 

275 # the one snapshot this call renders from. 

276 state = self._state 

277 manifest = state.manifest 

278 if not manifest: 

279 # No usable manifest at all - either the file is absent or it was 

280 # read and refused. The entry name is irrelevant because nothing 

281 # resolves against an unusable manifest; `_fallback_assets` 

282 # distinguishes the two causes from `state.failure`. 

283 return self._fallback_assets(state) 

284 

285 problem = self._entry_problem(state, entry_point) 

286 if problem is not None: 

287 return self._fallback_assets(state, entry_point, problem) 

288 

289 # Get the built file from manifest 

290 file_info = manifest.get(entry_point) 

291 if not isinstance(file_info, dict): 291 ↛ 302line 291 didn't jump to line 302 because the condition on line 291 was never true

292 # Defence in depth. `_entry_problem` already established that this 

293 # key holds an object with a string 'file' *in this snapshot*, and 

294 # the snapshot cannot be swapped underneath us, but the dict it 

295 # points at is still shared mutable state (tests assign 

296 # `helper.manifest` directly, and nothing stops a future caller 

297 # editing it in place). Fail closed to the banner rather than 

298 # raise out of template rendering and 500 the page. The cause is 

299 # passed explicitly: the entry is present, so the default 

300 # "absent from the manifest" would send an operator looking for 

301 # a key that is sitting right there. 

302 return self._fallback_assets( 

303 state, 

304 entry_point, 

305 "its manifest entry stopped being an object after it was " 

306 "verified", 

307 ) 

308 

309 file_name = file_info.get("file") 

310 if not isinstance(file_name, str): 

311 # Same reasoning: a bare `file_info['file']` here turns a manifest 

312 # whose entry lost its 'file' key into a KeyError escaping into 

313 # Jinja, i.e. a 500 on every page instead of a degraded one. The 

314 # entry itself is present, so the cause is named rather than 

315 # left to the "absent from the manifest" default. 

316 return self._fallback_assets( 

317 state, 

318 entry_point, 

319 "its manifest entry has no 'file' string", 

320 ) 

321 

322 # Encode filename characters before putting them into trusted markup. 

323 # A literal ?, # or % must still address the file we verified, and 

324 # quotes must remain URL data rather than HTML attribute delimiters. 

325 file_path = f"/static/dist/{quote(file_name, safe='/')}" 

326 

327 # Include CSS if present 

328 css_tags = "" 

329 for css_file in file_info.get("css") or (): 

330 css_tags += f'<link rel="stylesheet" href="/static/dist/{quote(css_file, safe="/")}">\n' 

331 

332 # Include the main JS file 

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

334 

335 # Manifest asset paths are verified and URL-quoted before these fixed tags are assembled. 

336 return Markup(css_tags + js_tag) # nosec B704 

337 

338 def _fallback_assets(self, state=None, entry_point=None, problem=None): 

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

340 isn't usable. 

341 

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

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

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

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

346 missing/stale), so we log it once per distinct cause and return HTML 

347 comments in place of the script and stylesheet tags. The comments 

348 are for whoever views source; the operator-visible signal is the 

349 red banner from `missing_assets_banner()`, which templates render at 

350 the top of `<body>`, and the log line emitted here. 

351 

352 ``state`` is the snapshot the caller rendered from, so the reason 

353 reported here describes the same bytes the caller refused to serve 

354 rather than whatever the current attribute happens to hold; it 

355 defaults to the live snapshot for callers with nothing to render. 

356 

357 ``entry_point`` is None when no manifest is in memory at all, which 

358 covers both "the file is absent" and "the file was read and refused"; 

359 the snapshot's ``failure`` tells those apart, and a refusal was 

360 already reported by `_fail_closed()` naming the file and the cause, so 

361 it is not re-reported here under a misleading "manifest not found" 

362 headline. ``entry_point`` is the requested entry name (e.g. 

363 ``"js/app.js"``) when a manifest is loaded but that entry cannot be 

364 served, with ``problem`` naming the offending manifest key or file. 

365 Every caller that has an entry name also knows why that entry was 

366 refused and says so; the default below is only a last resort for a 

367 future caller that does not, so it is deliberately non-committal 

368 rather than asserting a specific cause it cannot know. Guessing 

369 "absent from the manifest" here sent operators looking for a key 

370 that was sitting in the file. 

371 The cases are logged differently because they call for different 

372 operator actions (rebuild everything vs. investigate one entry). 

373 """ 

374 if state is None: 

375 state = self._state 

376 if entry_point is not None: 

377 problem = problem or "it is not servable from the current build" 

378 safe_entry = _for_log(entry_point) 

379 _warn_once( 

380 f"entry:{safe_entry}:{problem}", 

381 f"Vite manifest entry '{safe_entry}' cannot be served: " 

382 f"{problem}. This asset came from a stale or incomplete " 

383 "build (unstyled page or missing JavaScript). Run " 

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

385 "static/dist, then reload.", 

386 ) 

387 # Deliberately free of the entry name, the manifest path and the 

388 # hashed filename: those go to the log, which is not served to 

389 # every visitor, and nothing interpolated into this markup is 

390 # then attacker- or manifest-controlled. 

391 detail = ( 

392 "the requested entry is not servable from the current " 

393 "build - see the server log" 

394 ) 

395 elif state.failure is not None: 

396 cause, _location = state.failure 

397 # `_fail_closed()` already logged this, naming the file. `cause` 

398 # is one of this module's own literals. 

399 detail = ( 

400 f"the manifest was read and refused ({cause}) - see the " 

401 "server log" 

402 ) 

403 else: 

404 _warn_once( 

405 f"manifest-absent:{self._manifest_path}", 

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

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

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

409 "static/dist, then reload.", 

410 ) 

411 detail = "using existing static files as fallback" 

412 

413 # Diagnostic detail is selected from fixed module messages; paths and entry names stay in logs. 

414 return Markup( # nosec B704 

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

416 f"<!-- {detail} -->\n" 

417 ) 

418 

419 def _refresh_manifest_if_stale(self): 

420 """Refresh production state from a coherent manifest snapshot. 

421 

422 Detecting a same-size rewrite whose mtime is restored or coarse 

423 requires looking at content, not metadata, so every production call 

424 reads and hashes the (size-capped) manifest.json. `main` did no 

425 filesystem work at all on the healthy path - an accurate claim there, 

426 and exactly why `main` can never notice a rebuild without a restart. 

427 This trades that for staleness detection. JSON parsing happens only 

428 when the bytes differ from the one fingerprint the current state 

429 carries - a state records either the manifest it adopted or the 

430 bytes it refused, never both - so a steady state, healthy or 

431 degraded, costs a bounded read plus a hash and no parse. 

432 

433 Templates currently invoke both the asset and the banner helper, so 

434 each helper invocation performs this check independently, and the 

435 two can legitimately disagree: `vite_asset()` in `<head>` and 

436 `missing_assets_banner()`/`assets_are_missing()` at the top of 

437 `<body>` each take their own snapshot. Each is internally coherent, 

438 but a rebuild landing between them can put valid script tags and the 

439 red banner on the same page. That is a cosmetic one-render artefact, 

440 not a stale-URL bug, and the next render agrees with itself again. 

441 The corollary is the steady-state cost: a page that calls both 

442 helpers pays this read-plus-hash twice, once per helper, not once. 

443 

444 One steady state costs more than that, and deliberately so: a 

445 manifest that parses fine while a file it references is missing from 

446 `dist/`. Adoption succeeds, so the read and hash are the small ones, 

447 but the *entry* is unservable and negative verification results are 

448 not memoised (that is what lets a chunk landing a second later be 

449 picked up on the very next render). Every render therefore re-walks 

450 that entry's blocking closure and performs a `resolve()` plus an 

451 `is_file()` for each path in it - so the cost of that state is 

452 linear in the size of the closure, not of the manifest. On the real 

453 67-entry manifest, `js/app.js`'s own blocking closure is just two 

454 files - itself and its one stylesheet (65 of the 67 entries are 

455 fonts nothing asks for, and the on-demand diagram-export chunk is 

456 excluded from this closure by design, see `_entry_problem`) - but 

457 `resolve()` re-derives the real path from scratch for every 

458 referenced file rather than reusing the already-resolved `dist/` 

459 prefix, so most of the cost is stat-ing each directory component 

460 down to `dist/`, not the handful of files themselves. Measured 

461 against this repository's own checkout depth, a single degraded 

462 closure walk costs on the order of thirty `stat`/`lstat` syscalls, 

463 and a page renders both helpers (see above), so on the order of 

464 fifty of those syscalls are paid per page for as long as the build 

465 stays broken - a state an operator is expected to fix rather than 

466 run in. The healthy state pays the same walk once per entry per 

467 `_VERIFICATION_TTL_SECONDS`, which is what bounds how long a file 

468 deleted from `dist/` after verification can go unnoticed. 

469 

470 The content hash comes from the exact bytes being parsed. A 

471 replacement between a read and a separate stat cannot pair manifest A 

472 in memory with manifest B's fingerprint: the next call hashes B and 

473 adopts it. Missing, oversized, malformed, or non-object manifests fail 

474 closed by clearing cached URLs, which activates fallback markup and 

475 the visible banner while a later valid build can still be adopted 

476 automatically. 

477 """ 

478 if self.is_dev or not self._manifest_path: 

479 return 

480 self._adopt_manifest_from_disk() 

481 

482 def _adopt_manifest_from_disk(self): 

483 """Read, parse, and adopt a coherent disk snapshot. 

484 

485 The fingerprint is calculated from the exact bytes parsed below. This 

486 avoids a read-then-stat race at startup and ensures a same-size, 

487 same-mtime content rewrite is observed. It is taken before the size 

488 check so that an oversized manifest is remembered by the bytes that 

489 were actually read, exactly like every other refusal - an oversize 

490 refusal that stored no fingerprint left the state permanently 

491 "unknown", so nothing downstream could tell a repeat of the same bad 

492 file from a fresh one. 

493 

494 Adoption itself touches only the bytes: unreadable, oversized, 

495 unparseable, and non-object manifests are refused, everything else is 

496 adopted. Whether the files a *requested* entry points at are actually 

497 on disk is checked per entry in `_entry_problem()`, so one dangling 

498 reference in an entry nothing asks for cannot blank the whole UI. 

499 Because adoption is a pure function of the bytes, identical bytes get 

500 an identical verdict - which is what makes the `rejected_fingerprint` 

501 fast path below safe: re-parsing refused bytes could not change the 

502 outcome, so the degraded path parses once, not once per request. 

503 

504 A failure deliberately drops any old cached manifest rather than 

505 preserving URLs that a rebuild may have deleted. Adoption and refusal 

506 both publish one whole new `_ManifestState` in a single attribute 

507 store, so a concurrent reader sees either the old snapshot or the new 

508 one and never a mixture of the two. 

509 

510 Two concurrent refreshes are not serialised, so the state that ends 

511 up published may be the one built from the *older* bytes. That is 

512 still a coherent snapshot rather than a mixture, and it self-heals: 

513 the next call hashes what is on disk, finds it differs from the 

514 fingerprint the published state carries, and adopts it. 

515 """ 

516 try: 

517 with self._manifest_path.open("rb") as handle: 

518 raw_manifest = handle.read(_MAX_MANIFEST_BYTES + 1) 

519 except OSError: 

520 # Absent, unreadable (EACCES), or a directory. There is nothing to 

521 # fingerprint, so this is re-checked on every call: that is what 

522 # lets a manifest that appears later be picked up. 

523 self._fail_closed("no readable manifest file") 

524 return 

525 

526 fingerprint = hashlib.sha256(raw_manifest).hexdigest() 

527 state = self._state 

528 if state.manifest and fingerprint == state.fingerprint: 

529 return 

530 if not state.manifest and fingerprint == state.rejected_fingerprint: 

531 return 

532 

533 if len(raw_manifest) > _MAX_MANIFEST_BYTES: 

534 self._fail_closed( 

535 f"larger than the {_MAX_MANIFEST_BYTES}-byte read cap", 

536 fingerprint, 

537 ) 

538 return 

539 

540 try: 

541 manifest = json.loads(raw_manifest.decode("utf-8-sig")) 

542 except ValueError: 

543 self._fail_closed("not valid JSON", fingerprint) 

544 return 

545 except RecursionError: 

546 # Deeply nested JSON exhausts the interpreter's stack rather than 

547 # raising ValueError; without this the exception would escape into 

548 # template rendering and 500 every page instead of degrading. 

549 self._fail_closed("nested too deeply to parse", fingerprint) 

550 return 

551 

552 if not isinstance(manifest, dict) or not manifest: 

553 self._fail_closed("not a non-empty JSON object", fingerprint) 

554 return 

555 

556 self._state = _ManifestState(manifest=manifest, fingerprint=fingerprint) 

557 

558 def _fail_closed(self, cause, fingerprint=None): 

559 """Drop the cached manifest and report why, once per distinct cause. 

560 

561 "Distinct cause" means the (location, cause) pair, not the bytes: a 

562 manifest being rewritten with fresh garbage produces a new 

563 fingerprint on every render while remaining the *same* problem for 

564 the operator, and keying the de-duplication on the bytes turned "warn 

565 once" into one warning per render. The fingerprint is named in the 

566 message instead, so the single line that is emitted still identifies 

567 which bytes it looked at. 

568 """ 

569 location = self._manifest_path 

570 self._state = _ManifestState( 

571 manifest={}, 

572 rejected_fingerprint=fingerprint, 

573 failure=( 

574 cause, 

575 str(location) if location is not None else "<unknown path>", 

576 ), 

577 ) 

578 if location is None or self.is_dev: 

579 # Nothing to name, or dev mode - where the manifest is expected to 

580 # be absent because Vite serves from memory. 

581 return 

582 seen = ( 

583 f" (sha256 {fingerprint[:12]})" if fingerprint is not None else "" 

584 ) 

585 _warn_once( 

586 f"manifest:{location}:{cause}", 

587 f"Vite manifest at {location} is unusable ({cause}){seen}; the " 

588 "frontend is being served without built assets (unstyled page, " 

589 "no JavaScript). Run 'npm run build' from the project root, " 

590 "then reload.", 

591 ) 

592 

593 @staticmethod 

594 def _is_contained_file(dist_root, relative_path): 

595 """True when ``relative_path`` names a real file inside ``dist_root``. 

596 

597 The path has to be *relative*, has to name a file, and has to be 

598 written in already-normalised form, because the very same string is 

599 percent-encoded into a URL as `/static/dist/<path>`. Anything pathlib folds 

600 away silently is therefore a trap: this function verifies one path 

601 and the browser then requests another. 

602 

603 `dist_root / relative_path` lets an absolute operand win, so an 

604 absolute path pointing at a file *inside* dist/ passed containment 

605 and `is_file()`, and then rendered a `src` that concatenated the 

606 static prefix with the server's own directory layout - a 404 that 

607 publishes that layout to every visitor. A trailing slash, a doubled 

608 separator, a `.` segment and a `..` segment are the same shape of 

609 bug: `js/app.js/`, `js//app.js` and `js/app.js/.` all resolve to the 

610 same file here and all render URLs that no static handler maps back 

611 to the file that was checked. Reject the lot rather than serve a URL 

612 that only looks verified. 

613 """ 

614 if not isinstance(relative_path, str) or not relative_path: 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true

615 return False 

616 # Segment-level check on the raw string, before pathlib sees it. An 

617 # empty segment covers a leading separator, a trailing separator and 

618 # `a//b`; `.` and `..` cover the segments pathlib would fold away. 

619 # Both separators are inspected because nothing guarantees the 

620 # manifest was written on this platform. 

621 for separator in ("/", "\\"): 

622 if any( 

623 segment in ("", ".", "..") 

624 for segment in relative_path.split(separator) 

625 ): 

626 return False 

627 if PureWindowsPath(relative_path).drive: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 return False 

629 from ...security.path_validator import resolve_allow_missing 

630 

631 try: 

632 # URL paths use UTF-8; a filesystem surrogate cannot be served 

633 # through that encoding even if this host can store its bytes. 

634 relative_path.encode("utf-8") 

635 # resolve_allow_missing(), not a bare resolve(): an unresolvable 

636 # path (a loop, a symlink chain whose resolved form outgrows 

637 # PATH_MAX) raises instead of coming back half resolved. 

638 target = resolve_allow_missing(dist_root / relative_path) 

639 return target.is_relative_to(dist_root) and target.is_file() 

640 except (OSError, ValueError): 

641 return False 

642 

643 def _entry_problem(self, state, entry_point): 

644 """Return None if ``entry_point`` is servable, else why it isn't. 

645 

646 ``state`` is the snapshot to answer the question about, passed in 

647 rather than re-read from `self`: the verdict, the manifest it 

648 describes and the memo it is recorded in have to be the same 

649 snapshot, or a verification of manifest A gets stored against 

650 manifest B and B is then served with validation skipped. 

651 

652 Validation is scoped to what serving this entry actually needs: the 

653 entry itself, its `css`, and the transitive closure of its `imports`. 

654 Entries outside that closure are irrelevant - the manifest's 60-odd 

655 font entries are never `vite_asset()` targets (every template asks for 

656 `js/app.js`), and one font missing from `dist/` must not take the 

657 whole UI down when on `main` it cost a single 404. 

658 

659 `dynamicImports` are deliberately *outside* the blocking closure. 

660 They are the chunks the browser fetches later, on demand: the real 

661 `js/app.js` entry lazily imports a canvg chunk that only the diagram 

662 export path ever needs, and letting it gate the closure means one 

663 unflushed lazy chunk blanks every page including the login form. They 

664 are still walked, and a problem in them is warned about once, because 

665 an operator does want to know that a feature will fail when it is 

666 reached - but it degrades one feature rather than the whole app. 

667 

668 Every path that *is* reached must resolve to a real file inside 

669 `dist/`, so a symlink pointing out of the tree, or a chunk that has 

670 not been flushed yet, is refused. Cycles terminate; dangling manifest 

671 references are refused. Positive results are remembered in this 

672 state's own memo, so the healthy path costs one dict lookup for 

673 `_VERIFICATION_TTL_SECONDS` and then one closure walk; negative ones 

674 are not remembered at all, so a late-arriving chunk recovers on the 

675 very next call. The expiry is what keeps the memo from outliving the 

676 files it describes: manifest.json is not rewritten when a chunk is 

677 deleted from `dist/`, so nothing else would ever invalidate it. 

678 """ 

679 verified = state.verified_entries 

680 verified_at = verified.get(entry_point) 

681 if verified_at is not None: 

682 # Clamped so a clock that runs backwards (a fake clock in a test, 

683 # or a real one stepped back by NTP) cannot produce a negative 

684 # delta and make this memo look permanently fresh. 

685 age = max(0.0, _monotonic() - verified_at) 

686 if age < _VERIFICATION_TTL_SECONDS: 

687 return None 

688 

689 manifest = state.manifest 

690 dist_root = None 

691 if self._manifest_path is not None: 

692 from ...security.path_validator import resolve_allow_missing 

693 

694 try: 

695 dist_root = resolve_allow_missing( 

696 self._manifest_path.parent.parent 

697 ) 

698 except OSError: 

699 return "the dist/ directory could not be resolved" 

700 

701 deferred: list[tuple[str, Any]] = [] 

702 problem = self._closure_problem( 

703 manifest, dist_root, [entry_point], deferred 

704 ) 

705 if problem is not None: 

706 return problem 

707 

708 if deferred: 

709 lazy_problem = self._lazy_closure_problem( 

710 manifest, dist_root, deferred 

711 ) 

712 if lazy_problem is not None: 

713 safe_entry = _for_log(entry_point) 

714 _warn_once( 

715 f"lazy:{safe_entry}:{lazy_problem}", 

716 f"Vite entry '{safe_entry}' is servable, but a chunk it " 

717 f"loads on demand is not: {lazy_problem}. The page will " 

718 "render; the feature that pulls that chunk in will fail " 

719 "at runtime. Run 'npm run build' from the project root " 

720 "to regenerate static/dist, then reload.", 

721 ) 

722 

723 # Remembering more than the bound is refused rather than handled by 

724 # clearing: entry points come from templates, so a real deployment 

725 # has one or two, and this dict is discarded wholesale the moment the 

726 # manifest bytes change. Never clearing it also means a concurrent 

727 # reader's lookup cannot race a wipe. Re-stamping an entry that is 

728 # already remembered is always allowed, so a full memo cannot pin an 

729 # entry to a timestamp that never advances. 

730 if entry_point in verified or len(verified) < _VERIFIED_ENTRIES_LIMIT: 730 ↛ 732line 730 didn't jump to line 732 because the condition on line 730 was always true

731 verified[entry_point] = _monotonic() 

732 return None 

733 

734 @classmethod 

735 def _closure_problem(cls, manifest, dist_root, roots, deferred): 

736 """Walk the reference closure from ``roots``; return the first problem. 

737 

738 ``deferred`` is a list that collects ``(key, dynamicImports value)`` 

739 pairs instead of following them - the blocking pass, which must not 

740 let a lazily-loaded chunk decide whether the page renders at all. 

741 Pass None to follow `dynamicImports` inline, which is what the 

742 advisory pass does. 

743 """ 

744 pending = list(roots) 

745 visited = set() 

746 while pending: 

747 key = pending.pop() 

748 if key in visited: 

749 continue 

750 visited.add(key) 

751 

752 entry = manifest.get(key) 

753 if not isinstance(entry, dict): 

754 return ( 

755 f"manifest key '{_for_log(key)}' is absent or not an object" 

756 ) 

757 

758 file_path = entry.get("file") 

759 if not isinstance(file_path, str): 

760 return f"manifest key '{_for_log(key)}' has no 'file' string" 

761 if dist_root is not None and not cls._is_contained_file( 

762 dist_root, file_path 

763 ): 

764 return ( 

765 f"'{_for_log(file_path)}' (manifest key " 

766 f"'{_for_log(key)}') is not a file inside dist/" 

767 ) 

768 

769 css_paths = entry.get("css") 

770 if css_paths is not None: 

771 if not isinstance(css_paths, list): 771 ↛ 772line 771 didn't jump to line 772 because the condition on line 771 was never true

772 return ( 

773 f"manifest key '{_for_log(key)}' has a non-list 'css'" 

774 ) 

775 for css_path in css_paths: 

776 if not isinstance(css_path, str): 776 ↛ 777line 776 didn't jump to line 777 because the condition on line 776 was never true

777 return ( 

778 f"manifest key '{_for_log(key)}' has a " 

779 "non-string CSS entry" 

780 ) 

781 if dist_root is not None and not cls._is_contained_file( 

782 dist_root, css_path 

783 ): 

784 return ( 

785 f"'{_for_log(css_path)}' (manifest key " 

786 f"'{_for_log(key)}') is not a file inside dist/" 

787 ) 

788 

789 fields = ["imports"] 

790 if deferred is None: 

791 fields.append("dynamicImports") 

792 elif entry.get("dynamicImports") is not None: 

793 deferred.append((key, entry.get("dynamicImports"))) 

794 

795 for field_name in fields: 

796 references = entry.get(field_name) 

797 if references is None: 

798 continue 

799 if not isinstance(references, list): 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true

800 return ( 

801 f"manifest key '{_for_log(key)}' has a non-list " 

802 f"'{field_name}'" 

803 ) 

804 for reference in references: 

805 if not isinstance(reference, str): 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true

806 return ( 

807 f"manifest key '{_for_log(key)}' has a " 

808 f"non-string '{field_name}' reference" 

809 ) 

810 if reference not in manifest: 

811 return ( 

812 f"manifest key '{_for_log(key)}' imports " 

813 f"'{_for_log(reference)}', which the manifest " 

814 "does not define" 

815 ) 

816 pending.append(reference) 

817 

818 return None 

819 

820 @classmethod 

821 def _lazy_closure_problem(cls, manifest, dist_root, deferred): 

822 """Validate the on-demand closure; a problem here is advisory only.""" 

823 roots = [] 

824 for key, references in deferred: 

825 if not isinstance(references, list): 825 ↛ 826line 825 didn't jump to line 826 because the condition on line 825 was never true

826 return ( 

827 f"manifest key '{_for_log(key)}' has a non-list " 

828 "'dynamicImports'" 

829 ) 

830 for reference in references: 

831 if not isinstance(reference, str): 831 ↛ 832line 831 didn't jump to line 832 because the condition on line 831 was never true

832 return ( 

833 f"manifest key '{_for_log(key)}' has a non-string " 

834 "'dynamicImports' reference" 

835 ) 

836 if reference not in manifest: 

837 return ( 

838 f"manifest key '{_for_log(key)}' lazily imports " 

839 f"'{_for_log(reference)}', which the manifest does " 

840 "not define" 

841 ) 

842 roots.append(reference) 

843 return cls._closure_problem(manifest, dist_root, roots, None) 

844 

845 def assets_are_missing(self) -> bool: 

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

847 if self.is_dev: 

848 return False 

849 self._refresh_manifest_if_stale() 

850 state = self._state 

851 if not state.manifest: 

852 return True 

853 return self._entry_problem(state, "js/app.js") is not None 

854 

855 def missing_assets_banner(self): 

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

857 if not self.assets_are_missing(): 

858 return Markup("") 

859 # This banner is a fixed module literal and contains no request or manifest data. 

860 return Markup(_FALLBACK_BANNER) # nosec B704 

861 

862 def init_for_fastapi(self, static_dir, jinja2_templates): 

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

864 

865 Args: 

866 static_dir: Path to the static directory. 

867 jinja2_templates: FastAPI Jinja2Templates instance. 

868 """ 

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

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

871 # the hashed manifest. 

872 from ...settings.env_registry import get_env_setting 

873 

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

875 

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

877 self._manifest_path = manifest_path 

878 self._state = _ManifestState(manifest={}) 

879 self._adopt_manifest_from_disk() 

880 

881 if not self.manifest and not self.is_dev: 

882 # Prod mode: no usable manifest is a real problem — warn loudly 

883 # instead of silently serving a blank page. This covers a 

884 # manifest that is present but was refused, not only an absent 

885 # one: both render the same assetless page, and gating on 

886 # `manifest_path.exists()` would stay silent for the former. 

887 # 

888 # A manifest that *parses* while a chunk it references is missing 

889 # is not covered here and cannot be: adoption succeeds, so this 

890 # branch is not taken, and the problem only surfaces when an 

891 # entry is actually requested. Its first log line therefore 

892 # arrives at the first render, not at startup. 

893 logger.warning( 

894 f"No usable Vite manifest at {manifest_path}. " 

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

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

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

898 ) 

899 

900 # Register template functions on the Jinja2 environment 

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

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

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

904 self.missing_assets_banner 

905 ) 

906 

907 

908# Create global instance 

909vite = ViteHelper()