feat(ui): version-busting query suffix on all served asset URLs

Browser-tester confirmed on spark-1 that the 7 <script src> tags and all
<link href> tags in the served HTML had no cache-busting suffix.  Because
there is no service worker, the standard HTTP cache would keep serving stale
JS/CSS to browsers that had already loaded a previous release — the root
cause of yesterday's 'is the user seeing stale JS?' investigation.

Fix: index_page() now appends ?v=<muxplex_version> to every static-asset
URL (src= and href= attributes whose path starts with /  and does not begin
with /api/) before returning the HTML response.  The version is read once at
module load from importlib.metadata.version('muxplex') — the same source
used by the doctor command — so it is always in sync with the installed
package.  Starlette's StaticFiles handler ignores query parameters when
serving files from disk, so the versioned URLs continue to resolve to the
same bytes; they just carry a new cache key on every release.

Affected assets (7 <script> + 6 <link> + 1 <img>):
  /vendor/xterm.js, /vendor/xterm-addon-fit.js,
  /vendor/xterm-addon-web-links.js, /vendor/xterm-addon-search.js,
  /vendor/addon-image.js, /app.js, /terminal.js
  /manifest.json, /favicon.ico, /favicon-32.png, /apple-touch-icon.png,
  /vendor/xterm.css, /style.css
  /wordmark-on-dark.svg

New tests in muxplex/tests/test_main.py:
  - test_index_all_asset_urls_have_version_suffix — every <script src> and
    <link href> in GET / carries ?v=<version>
  - test_index_vendor_scripts_each_versioned — all 7 expected script URLs
    are present with the version suffix
  - test_versioned_asset_url_resolves_to_static_file — static handler
    serves the file correctly when the query string is present
This commit is contained in:
Brian Krabach
2026-05-17 17:22:25 -07:00
parent b30891f3e5
commit c5e146bcf7
2 changed files with 198 additions and 1 deletions
+21 -1
View File
@@ -17,6 +17,7 @@ import logging
import os import os
import pathlib import pathlib
import pwd import pwd
import re
import socket import socket
import ssl import ssl
import shutil import shutil
@@ -527,6 +528,15 @@ _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
# which machine each muxplex instance is running on. # which machine each muxplex instance is running on.
_HOSTNAME = socket.gethostname().split(".")[0] _HOSTNAME = socket.gethostname().split(".")[0]
# Canonical version string — sourced from package metadata (same as `app.version`
# and the `doctor` command). Used to append `?v=<version>` to every static-asset
# URL so browsers immediately pick up new code on each release.
_UI_VERSION: str = importlib.metadata.version("muxplex")
# Matches src="/<path>" and href="/<path>" in served HTML, excluding /api/ URLs.
# Used by index_page() to inject cache-busting version query parameters.
_ASSET_URL_RE = re.compile(r'((?:src|href)=")((?!/api/)/[^"?#]*)')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes
@@ -1204,12 +1214,22 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, device_id: str) ->
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@app.get("/index.html", response_class=HTMLResponse) @app.get("/index.html", response_class=HTMLResponse)
async def index_page(): async def index_page():
"""Serve index.html with hostname injected into the page title.""" """Serve index.html with hostname injected into the page title.
Also appends ``?v=<version>`` to every static-asset URL (script src, link
href) so browsers immediately pick up new code on each release rather than
serving stale JS/CSS from the HTTP cache. API URLs (/api/...) are
excluded — they are not HTTP-cached by browsers.
"""
html = (_FRONTEND_DIR / "index.html").read_text() html = (_FRONTEND_DIR / "index.html").read_text()
html = html.replace( html = html.replace(
"<title>muxplex</title>", "<title>muxplex</title>",
f"<title>{_HOSTNAME} \u2014 muxplex</title>", f"<title>{_HOSTNAME} \u2014 muxplex</title>",
) )
html = _ASSET_URL_RE.sub(
lambda m: f"{m.group(1)}{m.group(2)}?v={_UI_VERSION}",
html,
)
return HTMLResponse(html) return HTMLResponse(html)
+177
View File
@@ -0,0 +1,177 @@
"""
Tests for the cache-busting version suffix on static asset URLs served by index_page().
Verifies that GET / (the main dashboard) injects ?v=<version> on every
<script src=""> and <link href=""> URL so browsers pick up new code
immediately after each release, rather than serving stale JS/CSS from
the HTTP cache.
"""
import importlib.metadata
import pytest
from bs4 import BeautifulSoup
from fastapi.testclient import TestClient
from muxplex.main import app
# ---------------------------------------------------------------------------
# Shared fixtures (mirror test_api.py setup so tests run cleanly in isolation)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def patch_startup_and_state(tmp_path, monkeypatch):
"""Redirect state/PID files to tmp_path and stub out long-running startup tasks."""
tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
tmp_pid_dir = tmp_path / "ttyd"
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
async def _mock_kill_orphan():
return False
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
@pytest.fixture(autouse=True)
def reset_federation_cache():
"""Clear _federation_cache before and after each test."""
import muxplex.main as main_mod
main_mod._federation_cache.clear()
yield
main_mod._federation_cache.clear()
@pytest.fixture
def client(monkeypatch):
"""Authenticated TestClient with the app lifespan active."""
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
with TestClient(app) as c:
from muxplex.auth import create_session_cookie
from muxplex.main import _auth_secret, _auth_ttl
cookie = create_session_cookie(_auth_secret, _auth_ttl)
c.cookies.set("muxplex_session", cookie)
yield c
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_index_soup(client) -> BeautifulSoup:
response = client.get("/")
assert response.status_code == 200, f"GET / returned {response.status_code}"
return BeautifulSoup(response.text, "html.parser")
# ---------------------------------------------------------------------------
# Test 1 — every script src and link href carries the version suffix
# ---------------------------------------------------------------------------
def test_index_all_asset_urls_have_version_suffix(client):
"""GET / must inject ?v=<version> on every <script src> and <link href> asset URL.
Regression guard for the "is the user seeing stale JS?" investigation:
verifies that the standard HTTP cache is busted on every release by
appending a version query parameter to each static asset reference.
"""
version = importlib.metadata.version("muxplex")
soup = _get_index_soup(client)
# All <script src="…"> tags
script_tags = soup.find_all("script", src=True)
assert len(script_tags) >= 7, (
f"Expected at least 7 <script src> tags, found {len(script_tags)}"
)
for tag in script_tags:
src = tag["src"]
assert f"?v={version}" in src, (
f"<script src> missing ?v={version} suffix: {src!r}"
)
# All <link href="…"> tags
link_tags = soup.find_all("link", href=True)
assert len(link_tags) >= 1, "Expected at least one <link href> tag"
for tag in link_tags:
href = tag["href"]
assert f"?v={version}" in href, (
f"<link href> missing ?v={version} suffix: {href!r}"
)
# ---------------------------------------------------------------------------
# Test 2 — vendor scripts are individually versioned (not just app.js)
# ---------------------------------------------------------------------------
def test_index_vendor_scripts_each_versioned(client):
"""All five vendor JS bundles must carry the version suffix, not just app.js.
The browser-tester on spark-1 observed bare vendor URLs. This test
ensures that xterm.js and its addons are cache-busted alongside the
first-party scripts.
"""
version = importlib.metadata.version("muxplex")
soup = _get_index_soup(client)
script_srcs = [tag["src"] for tag in soup.find_all("script", src=True)]
expected_versioned = [
f"/vendor/xterm.js?v={version}",
f"/vendor/xterm-addon-fit.js?v={version}",
f"/vendor/xterm-addon-web-links.js?v={version}",
f"/vendor/xterm-addon-search.js?v={version}",
f"/vendor/addon-image.js?v={version}",
f"/app.js?v={version}",
f"/terminal.js?v={version}",
]
for expected in expected_versioned:
assert expected in script_srcs, (
f"Expected versioned script {expected!r}; found srcs: {script_srcs}"
)
# ---------------------------------------------------------------------------
# Test 3 — versioned asset URLs still resolve to the actual static files
# ---------------------------------------------------------------------------
def test_versioned_asset_url_resolves_to_static_file(client):
"""GET /app.js?v=<version> must return HTTP 200 (static handler ignores query string).
Sanity check: adding the version suffix must not break asset loading.
Starlette's StaticFiles handler ignores query parameters when looking up
files on disk, so the versioned URL must serve identically to the bare URL.
"""
version = importlib.metadata.version("muxplex")
# First-party assets
for path in ("/app.js", "/terminal.js", "/style.css"):
url = f"{path}?v={version}"
resp = client.get(url)
assert resp.status_code == 200, (
f"Versioned URL {url!r} returned {resp.status_code}, expected 200"
)
# Vendor asset
vendor_url = f"/vendor/xterm.js?v={version}"
resp = client.get(vendor_url)
assert resp.status_code == 200, (
f"Versioned vendor URL {vendor_url!r} returned {resp.status_code}, expected 200"
)