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 pathlib
import pwd
import re
import socket
import ssl
import shutil
@@ -527,6 +528,15 @@ _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
# which machine each muxplex instance is running on.
_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
@@ -1204,12 +1214,22 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, device_id: str) ->
@app.get("/", response_class=HTMLResponse)
@app.get("/index.html", response_class=HTMLResponse)
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 = html.replace(
"<title>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)