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
« 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"""
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
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
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
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
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
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
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)
78def _for_log(value):
79 """Render a manifest-supplied string safely for a single log line.
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 )
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"""
119@dataclass(frozen=True, eq=False)
120class _ManifestState:
121 """Everything derived from one read of manifest.json, as one value.
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.
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 """
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)
173class ViteHelper:
174 """Helper class for Vite integration with FastAPI.
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 """
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()
193 if app:
194 self.init_app(app)
196 @property
197 def manifest(self):
198 """The manifest dict of the current snapshot (None before any load)."""
199 return self._state.manifest
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)
208 @property
209 def _manifest_fingerprint(self):
210 return self._state.fingerprint
212 @property
213 def _rejected_fingerprint(self):
214 return self._state.rejected_fingerprint
216 @property
217 def _manifest_failure(self):
218 return self._state.failure
220 @property
221 def _verified_entries(self):
222 return self._state.verified_entries
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)
229 if not self.is_dev:
230 # Load manifest in production
231 self._load_manifest()
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 )
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()
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 ""
256 def vite_asset(self, entry_point="js/app.js"):
257 """
258 Return appropriate script tags for the entry point
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 )
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)
285 problem = self._entry_problem(state, entry_point)
286 if problem is not None:
287 return self._fallback_assets(state, entry_point, problem)
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 )
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 )
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='/')}"
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'
332 # Include the main JS file
333 js_tag = f'<script type="module" src="{file_path}"></script>'
335 # Manifest asset paths are verified and URL-quoted before these fixed tags are assembled.
336 return Markup(css_tags + js_tag) # nosec B704
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.
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.
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.
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"
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 )
419 def _refresh_manifest_if_stale(self):
420 """Refresh production state from a coherent manifest snapshot.
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.
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.
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.
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()
482 def _adopt_manifest_from_disk(self):
483 """Read, parse, and adopt a coherent disk snapshot.
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.
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.
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.
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
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
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
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
552 if not isinstance(manifest, dict) or not manifest:
553 self._fail_closed("not a non-empty JSON object", fingerprint)
554 return
556 self._state = _ManifestState(manifest=manifest, fingerprint=fingerprint)
558 def _fail_closed(self, cause, fingerprint=None):
559 """Drop the cached manifest and report why, once per distinct cause.
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 )
593 @staticmethod
594 def _is_contained_file(dist_root, relative_path):
595 """True when ``relative_path`` names a real file inside ``dist_root``.
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.
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
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
643 def _entry_problem(self, state, entry_point):
644 """Return None if ``entry_point`` is servable, else why it isn't.
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.
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.
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.
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
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
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"
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
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 )
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
734 @classmethod
735 def _closure_problem(cls, manifest, dist_root, roots, deferred):
736 """Walk the reference closure from ``roots``; return the first problem.
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)
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 )
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 )
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 )
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")))
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)
818 return None
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)
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
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
862 def init_for_fastapi(self, static_dir, jinja2_templates):
863 """Initialize the helper for FastAPI (no Flask app dependency).
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
874 self.is_dev = bool(get_env_setting("vite.dev_mode", False))
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()
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 )
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 )
908# Create global instance
909vite = ViteHelper()