Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(webui): serve frontend dist resolved per-request, not cached at startup#1741

Merged
itomek merged 4 commits into
mainfrom
tmi/issue-1088-static-mount-per-request
Jun 18, 2026
Merged

fix(webui): serve frontend dist resolved per-request, not cached at startup#1741
itomek merged 4 commits into
mainfrom
tmi/issue-1088-static-mount-per-request

Conversation

@itomek

@itomek itomek commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #1088

Why this matters

A first-run gaia chat --ui user on the silent npm-autoinstall path would see the "no frontend build found" fallback forever: the build finished in the background, but the server had cached the dist-missing decision at startup, so refreshing never picked it up — only a manual restart did. Now the server resolves the frontend dist per request, so the moment a build appears the next refresh serves the real UI, with no restart.

Evidence

Live test on this branch — one server process, --ui-dist pointed at an empty dir, dist populated in-place mid-run (no restart):

# empty dist at startup
GET /  ->  200, title "GAIA Agent UI — Backend API", isFallback: true
# copy the built dist into the SAME path, no restart, refresh:
GET /  ->  200, title "GAIA", isRealSPA: true, stillFallback: false

Before→after screenshots captured during validation: the fallback page becomes the full Agent UI in the same process (same PID, no restart).
How tested: python -m gaia.ui.server --ui-dist <empty-dir> on the branch, then cp the built dist into that dir and refresh the browser.

Tests

  • New integration tests tests/unit/chat/ui/test_spa_hot_dist.py — 5/5 pass (empty→fallback, build-appears→real SPA without restart, asset served post-build, healthy startup unchanged, path-traversal rejected)
  • pytest -k "server or static or spa" — 361 passed, 38 skipped (3 failures are pre-existing No module named 'mcp', present on main)
  • python util/lint.py --all — clean
  • Real-world (local Mac): fallback → real SPA, same process, no restart (transcript above)

After npm run build completes in the background, a browser refresh now
serves the real SPA without restarting the backend. Previously the
StaticFiles mount for /assets and the serve_spa route were registered
only when dist/ existed at create_app() time, caching a "no build found"
decision for the entire process lifetime.

The fix replaces the startup-conditional block with a single always-registered
serve_spa catch-all that re-checks dist/index.html on every request and falls
through to the actionable fallback HTML when the build is absent. The /assets
StaticFiles mount is replaced with a per-request route reusing the existing
_sanitize_static_path path-traversal protection. A startup-time warning is
retained so users with a missing pip wheel still see a clear diagnostic log.

Fixes #1088.
@github-actions github-actions Bot added the tests Test changes label Jun 18, 2026
@itomek itomek self-assigned this Jun 18, 2026
@itomek itomek marked this pull request as ready for review June 18, 2026 19:32
@itomek itomek requested a review from kovtcharov-amd as a code owner June 18, 2026 19:32
@github-actions

Copy link
Copy Markdown
Contributor

Summary

Solid, well-scoped fix for a real first-run papercut: serve_spa now stats the dist directory per request instead of branching once at startup, so a background npm run build that finishes after the server boots is picked up on the next refresh — no restart. The refactor preserves the two security-sensitive bits intact (the sanitize_static_path traversal guard and the tunnel-cookie bootstrap), and ships genuinely good test coverage including the no-restart scenario. The one thing worth the author's attention before merge: collapsing the /assets StaticFiles mount into the SPA catch-all changed the 404 semantics for missing assets — a missing hashed chunk now returns index.html (200) instead of a clean 404.

No security regressions found. No prompt-injection content in the diff.

Issues Found

🟡 Important — missing /assets/* files now return index.html (200) instead of 404 (src/gaia/ui/server.py:784)

Previously /assets/* was served by a dedicated StaticFiles mount, which 404s on a missing file. That mount is gone; every path now flows through serve_spa, and any path that doesn't resolve to a real file falls through to the index.html SPA fallback:

sanitized = _sanitize_static_path(resolved_dist, full_path)
if sanitized is not None and sanitized.is_file():
    return FileResponse(str(sanitized))
# /assets/old-chunk-abc123.js (no longer on disk) falls through to here →
return FileResponse(str(index_html), headers=_NO_CACHE)   # 200, HTML

Effect: a stale page (or service worker) requesting a hash-named chunk that no longer exists gets index.html served back with Content-Type: text/html, which the browser then tries to execute as JS — the classic Uncaught SyntaxError: Unexpected token '<' instead of a recoverable 404. SPA fallback is correct for route paths but not for /assets/, which should 404 when the file is absent.

Low real-world impact for a local single-user UI (correct builds don't reference missing chunks), but it's a genuine semantics change worth guarding explicitly:

        if sanitized is not None and sanitized.is_file():
            return FileResponse(str(sanitized))

        # A missing file under /assets/ is a real 404 — don't mask a broken
        # hashed chunk with index.html (the browser would parse HTML as JS).
        if full_path.startswith("assets/"):
            return HTMLResponse(content="Not Found", status_code=404)

A small test asserting GET /assets/does-not-exist.js → 404 would lock the behavior in.

🟢 Minor — stale comment describes logging that doesn't exist (src/gaia/ui/server.py:771-773)

The fallback branch claims log throttling, but there's no logger call here at all:

        if not index_html.is_file():
            # Frontend build not present yet -- serve the actionable fallback.
            # Log only on first miss to avoid flooding logs during a build.
            return HTMLResponse(content=_FALLBACK_HTML, status_code=200)

Per CLAUDE.md, comments should describe what the code does. Drop the second line (or, if you want the first-miss log, add it):

        if not index_html.is_file():
            # Frontend build not present yet -- serve the actionable fallback.
            return HTMLResponse(content=_FALLBACK_HTML, status_code=200)

🟢 Minor — stale # noqa: F401 on a now-used import (src/gaia/ui/server.py:70)

from .utils import sanitize_static_path as _sanitize_static_path  # noqa: F401

The import is now unconditionally used in serve_spa, so the "imported but unused" suppression is no longer needed and can be removed.

🟢 Minor — serve_spa resolves the dist path twice per request (src/gaia/ui/server.py:767,784)

serve_spa calls _webui_dist.resolve(), and _sanitize_static_path calls .resolve() on the same base again (utils.py:347). Negligible for a localhost UI, but you could resolve once and pass the already-resolved base down. Not worth a change on its own — flagging only for awareness.

Strengths

  • Right fix for the reported bug, minimal blast radius — moving the dist check into the request path is exactly what Agent UI backend caches static-mount decision at startup #1088 needs, and the change is confined to one function.
  • Security-sensitive logic preserved verbatim — the traversal guard (sanitize_static_path) and the tunnel-cookie bootstrap (HttpOnly / SameSite=Strict / Secure, token-stripping 303) survived the refactor unchanged, and the comment explaining why the cookie is never planted off an asset path is retained.
  • Tests cover the behavior that actually matteredtest_real_spa_served_after_build_appears exercises the cold→warm transition in a single process with no app recreation, which is the precise failure mode users hit; plus the healthy-startup and traversal cases.

Verdict

Approve with suggestions. No blocking issues for a local UI. I'd recommend addressing the /assets/* 404 semantics (🟡) before merge — it's a one-liner plus a test — and the two trivial comment/noqa cleanups while you're in the file.

kovtcharov-amd
kovtcharov-amd previously approved these changes Jun 18, 2026

@kovtcharov-amd kovtcharov-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — per-request dist resolution is correct, and the security-sensitive bits (token-cookie bootstrap, _sanitize_static_path traversal guard) are preserved through the refactor. StaticFiles is still needed for the uploads mount, so no dead import. The empty → build-appears → served transition is well covered by the new tests.

Two trivial, non-blocking nits:

  • The serve_spa miss branch comment says "Log only on first miss to avoid flooding logs during a build," but the branch doesn't actually log — drop the comment or add the log.
  • Missing /assets/* now falls through to index.html (200) rather than 404, since the StaticFiles mount is gone. That's conventional SPA behavior, just noting the change.

Review follow-up on #1088. Collapsing the /assets StaticFiles mount into the
SPA catch-all changed 404 semantics: a missing hashed chunk fell through to
index.html (200, text/html), which the browser parses as JS
(Uncaught SyntaxError: Unexpected token '<'). Guard /assets/* so a missing
asset is a real 404; route paths still fall back to the SPA.

Also: drop a stale comment claiming log throttling that never existed, and an
obsolete noqa:F401 on sanitize_static_path (now unconditionally used).
@itomek

itomek commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — addressed in 20baf94:

  • 🟡 /assets/* 404 semantics — a missing file under /assets/ now returns a real 404 instead of falling through to index.html (which the browser would parse as JS, Uncaught SyntaxError: Unexpected token '<'). Route paths still fall back to the SPA. Locked in by a new test test_missing_asset_returns_404_not_index_html: GET /assets/old-chunk-abc123.js → 404, GET /some/app/route → 200 SPA.
  • 🟢 stale log comment — removed the line claiming first-miss log throttling; there was no logger call there.
  • 🟢 stale noqa: F401 — dropped from the sanitize_static_path import, now unconditionally used.

Not changed: the double .resolve() per request — negligible for a localhost UI and you flagged it as not worth a change on its own. Happy to fold it in if you would prefer.

spa_hot_dist suite now 6/6; lint --all clean.

@itomek itomek enabled auto-merge June 18, 2026 20:28
@itomek itomek added this pull request to the merge queue Jun 18, 2026
Merged via the queue into main with commit bd155fa Jun 18, 2026
31 checks passed
@itomek itomek deleted the tmi/issue-1088-static-mount-per-request branch June 18, 2026 22:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent UI backend caches static-mount decision at startup

3 participants