Extract WebUI asset helpers
- move Vite manifest handling and SPA route rules into core/webui - keep web_server.py focused on Flask route wiring - add tests for asset rendering and manifest reload behavior - keep image URL normalization coverage alongside the metadata helpers
This commit is contained in:
parent
497a6f41ed
commit
32bf52cc18
5 changed files with 274 additions and 100 deletions
19
core/webui/__init__.py
Normal file
19
core/webui/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""WebUI delivery helpers."""
|
||||
|
||||
from core.webui.assets import (
|
||||
build_webui_vite_assets,
|
||||
clear_webui_vite_manifest_cache,
|
||||
default_static_url_builder,
|
||||
get_webui_vite_manifest_path,
|
||||
load_webui_vite_manifest,
|
||||
)
|
||||
from core.webui.spa import should_serve_webui_spa
|
||||
|
||||
__all__ = [
|
||||
"build_webui_vite_assets",
|
||||
"clear_webui_vite_manifest_cache",
|
||||
"default_static_url_builder",
|
||||
"get_webui_vite_manifest_path",
|
||||
"load_webui_vite_manifest",
|
||||
"should_serve_webui_spa",
|
||||
]
|
||||
126
core/webui/assets.py
Normal file
126
core/webui/assets.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""WebUI Vite asset helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from utils.logging_config import get_logger as _create_logger
|
||||
|
||||
logger = _create_logger("webui.assets")
|
||||
|
||||
DEFAULT_WEBUI_VITE_ENTRY = "src/app/main.tsx"
|
||||
DEFAULT_WEBUI_VITE_BASE = "/static/dist/"
|
||||
DEFAULT_WEBUI_VITE_DEV_URL = "http://127.0.0.1:5173"
|
||||
DEFAULT_WEBUI_VITE_DEV_ENV = "SOULSYNC_WEBUI_VITE_DEV"
|
||||
DEFAULT_WEBUI_VITE_URL_ENV = "SOULSYNC_WEBUI_VITE_URL"
|
||||
|
||||
_MANIFEST_CACHE: dict[str, tuple[float | None, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def get_webui_vite_manifest_path() -> Path:
|
||||
"""Return the generated Vite manifest path inside the repo."""
|
||||
return Path(__file__).resolve().parents[2] / "webui" / "static" / "dist" / ".vite" / "manifest.json"
|
||||
|
||||
|
||||
def clear_webui_vite_manifest_cache() -> None:
|
||||
"""Clear the in-process manifest cache. Primarily useful for tests."""
|
||||
_MANIFEST_CACHE.clear()
|
||||
|
||||
|
||||
def default_static_url_builder(filename: str) -> str:
|
||||
"""Build a Flask-style static URL without requiring Flask imports."""
|
||||
return f"/static/{filename.lstrip('/')}"
|
||||
|
||||
|
||||
def _env_truthy(value: str | None) -> bool:
|
||||
return (value or "").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _resolve_dev_mode(dev: bool | None) -> bool:
|
||||
if dev is not None:
|
||||
return bool(dev)
|
||||
return _env_truthy(os.environ.get(DEFAULT_WEBUI_VITE_DEV_ENV))
|
||||
|
||||
|
||||
def _resolve_dev_url(dev_url: str | None) -> str:
|
||||
if dev_url:
|
||||
return dev_url.rstrip("/")
|
||||
return os.environ.get(DEFAULT_WEBUI_VITE_URL_ENV, DEFAULT_WEBUI_VITE_DEV_URL).rstrip("/")
|
||||
|
||||
|
||||
def load_webui_vite_manifest(manifest_path: str | Path | None = None) -> dict[str, Any]:
|
||||
"""Load and cache the generated Vite manifest."""
|
||||
path = Path(manifest_path) if manifest_path else get_webui_vite_manifest_path()
|
||||
cache_key = str(path)
|
||||
manifest_mtime = None
|
||||
if path.exists():
|
||||
try:
|
||||
manifest_mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
manifest_mtime = None
|
||||
|
||||
cached = _MANIFEST_CACHE.get(cache_key)
|
||||
if cached and cached[0] == manifest_mtime:
|
||||
return cached[1]
|
||||
|
||||
if path.exists():
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
manifest = json.load(handle)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load webui manifest: %s", exc)
|
||||
manifest = {}
|
||||
else:
|
||||
manifest = {}
|
||||
|
||||
_MANIFEST_CACHE[cache_key] = (manifest_mtime, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def build_webui_vite_assets(
|
||||
placement: str = "body",
|
||||
*,
|
||||
dev: bool | None = None,
|
||||
dev_url: str | None = None,
|
||||
entry: str = DEFAULT_WEBUI_VITE_ENTRY,
|
||||
manifest_path: str | Path | None = None,
|
||||
manifest_loader: Callable[[], dict[str, Any]] | None = None,
|
||||
static_url_builder: Callable[[str], str] | None = None,
|
||||
) -> str:
|
||||
"""Return HTML tags for the WebUI bundle or dev client."""
|
||||
if placement not in ("head", "body"):
|
||||
return ""
|
||||
|
||||
dev_mode = _resolve_dev_mode(dev)
|
||||
vite_url = _resolve_dev_url(dev_url)
|
||||
static_url = static_url_builder or default_static_url_builder
|
||||
|
||||
if dev_mode:
|
||||
if placement == "head":
|
||||
return ""
|
||||
base = DEFAULT_WEBUI_VITE_BASE.rstrip("/")
|
||||
return "\n".join([
|
||||
f'<script type="module" src="{vite_url}{base}/@vite/client"></script>',
|
||||
f'<script type="module" src="{vite_url}{base}/{entry.lstrip("/")}"></script>',
|
||||
])
|
||||
|
||||
loader = manifest_loader or (lambda: load_webui_vite_manifest(manifest_path))
|
||||
manifest = loader()
|
||||
entry_meta = manifest.get(entry)
|
||||
if not entry_meta:
|
||||
return ""
|
||||
|
||||
if placement == "head":
|
||||
return "\n".join(
|
||||
f'<link rel="stylesheet" href="{static_url(f"dist/{css_file}")}">'
|
||||
for css_file in entry_meta.get("css", [])
|
||||
)
|
||||
|
||||
entry_file = entry_meta.get("file")
|
||||
if not entry_file:
|
||||
return ""
|
||||
|
||||
return f'<script type="module" src="{static_url(f"dist/{entry_file}")}"></script>'
|
||||
23
core/webui/spa.py
Normal file
23
core/webui/spa.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""WebUI SPA routing helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
EXACT_EXCLUDED_PATHS = {"/callback", "/status"}
|
||||
PREFIX_EXCLUDED_PATHS = (
|
||||
"/api",
|
||||
"/auth",
|
||||
"/callback/",
|
||||
"/deezer/",
|
||||
"/socket.io",
|
||||
"/static",
|
||||
"/stream",
|
||||
"/tidal/",
|
||||
)
|
||||
|
||||
|
||||
def should_serve_webui_spa(pathname: str) -> bool:
|
||||
"""Return True when a request path should fall through to the SPA."""
|
||||
normalized = pathname.rstrip("/") or "/"
|
||||
if normalized in EXACT_EXCLUDED_PATHS:
|
||||
return False
|
||||
return not normalized.startswith(PREFIX_EXCLUDED_PATHS)
|
||||
96
tests/webui/test_assets.py
Normal file
96
tests/webui/test_assets.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Tests for the WebUI asset helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from core.webui import (
|
||||
build_webui_vite_assets,
|
||||
clear_webui_vite_manifest_cache,
|
||||
load_webui_vite_manifest,
|
||||
should_serve_webui_spa,
|
||||
)
|
||||
|
||||
|
||||
def test_build_webui_vite_assets_renders_dev_scripts():
|
||||
html = build_webui_vite_assets("body", dev=True, dev_url="http://127.0.0.1:5173")
|
||||
assert html == (
|
||||
'<script type="module" src="http://127.0.0.1:5173/static/dist/@vite/client"></script>\n'
|
||||
'<script type="module" src="http://127.0.0.1:5173/static/dist/src/app/main.tsx"></script>'
|
||||
)
|
||||
|
||||
|
||||
def test_build_webui_vite_assets_renders_manifest_assets():
|
||||
manifest = {
|
||||
"src/app/main.tsx": {
|
||||
"css": ["assets/main.css"],
|
||||
"file": "assets/main.js",
|
||||
}
|
||||
}
|
||||
|
||||
html_head = build_webui_vite_assets(
|
||||
"head",
|
||||
manifest_loader=lambda: manifest,
|
||||
static_url_builder=lambda filename: f"/assets/{filename}",
|
||||
)
|
||||
html_body = build_webui_vite_assets(
|
||||
"body",
|
||||
manifest_loader=lambda: manifest,
|
||||
static_url_builder=lambda filename: f"/assets/{filename}",
|
||||
)
|
||||
|
||||
assert html_head == '<link rel="stylesheet" href="/assets/dist/assets/main.css">'
|
||||
assert html_body == '<script type="module" src="/assets/dist/assets/main.js"></script>'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pathname",
|
||||
[
|
||||
"/api/issues",
|
||||
"/auth/spotify",
|
||||
"/callback",
|
||||
"/callback/extra",
|
||||
"/deezer/callback",
|
||||
"/socket.io",
|
||||
"/static/app.js",
|
||||
"/stream/file",
|
||||
"/tidal/callback",
|
||||
"/status",
|
||||
],
|
||||
)
|
||||
def test_should_serve_webui_spa_blocks_reserved_paths(pathname):
|
||||
assert should_serve_webui_spa(pathname) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pathname",
|
||||
[
|
||||
"/",
|
||||
"/issues",
|
||||
"/issues?issueId=7",
|
||||
"/artists/Opeth",
|
||||
"/discover",
|
||||
],
|
||||
)
|
||||
def test_should_serve_webui_spa_allows_client_routes(pathname):
|
||||
assert should_serve_webui_spa(pathname) is True
|
||||
|
||||
|
||||
def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path):
|
||||
clear_webui_vite_manifest_cache()
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/one.js"}}))
|
||||
|
||||
first = load_webui_vite_manifest(manifest_path)
|
||||
assert first["src/app/main.tsx"]["file"] == "assets/one.js"
|
||||
|
||||
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/two.js"}}))
|
||||
future = time.time() + 10
|
||||
os.utime(manifest_path, (future, future))
|
||||
|
||||
second = load_webui_vite_manifest(manifest_path)
|
||||
assert second["src/app/main.tsx"]["file"] == "assets/two.js"
|
||||
110
web_server.py
110
web_server.py
|
|
@ -20,7 +20,7 @@ from pathlib import Path
|
|||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, url_for
|
||||
from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
from utils.logging_config import get_logger, setup_logging
|
||||
from utils.async_helpers import run_async
|
||||
|
|
@ -97,6 +97,7 @@ from core.metadata.cache import get_metadata_cache
|
|||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata import is_internal_image_host
|
||||
from core.metadata import normalize_image_url as fix_artist_image_url
|
||||
from core.webui import build_webui_vite_assets, should_serve_webui_spa
|
||||
from core.metadata.registry import (
|
||||
clear_cached_metadata_client,
|
||||
get_metadata_source_label,
|
||||
|
|
@ -292,13 +293,12 @@ app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE
|
|||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
|
||||
|
||||
|
||||
# Cache-bust query string for static assets — appended to every
|
||||
# url_for('static', ...) URL via the context processor below. Computed
|
||||
# once per process start so each server restart invalidates the
|
||||
# browser's cached copy of every JS/CSS file. This is the surefire
|
||||
# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
|
||||
# itself changes, so the browser cannot reuse a previously-cached
|
||||
# response no matter what its Cache-Control header said.
|
||||
# Cache-bust query string for static assets. Computed once per process
|
||||
# start so each server restart invalidates the browser's cached copy of
|
||||
# every JS/CSS file. This is the surefire fix for "user has stale JS
|
||||
# even after Ctrl+Shift+R" — the URL itself changes, so the browser
|
||||
# cannot reuse a previously-cached response no matter what its
|
||||
# Cache-Control header said.
|
||||
import time as _cache_bust_time
|
||||
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
|
||||
|
||||
|
|
@ -371,100 +371,10 @@ def _log_rejected_socketio_origin():
|
|||
request.headers.get('X-Forwarded-Proto', ''),
|
||||
)
|
||||
|
||||
# --- WebUI asset injection ---
|
||||
_webui_vite_manifest = None
|
||||
_webui_vite_manifest_mtime = None
|
||||
_webui_vite_entry = 'src/app/main.tsx'
|
||||
_webui_vite_manifest_path = Path(base_dir) / 'webui' / 'static' / 'dist' / '.vite' / 'manifest.json'
|
||||
_webui_vite_dev = os.environ.get('SOULSYNC_WEBUI_VITE_DEV', '').lower() in ('1', 'true', 'yes', 'on')
|
||||
_webui_vite_url = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
|
||||
_webui_vite_base = '/static/dist/'
|
||||
|
||||
def _should_serve_webui_spa(pathname: str) -> bool:
|
||||
normalized = pathname.rstrip('/') or '/'
|
||||
excluded_exact_paths = {'/callback', '/status'}
|
||||
excluded_prefixes = (
|
||||
'/api',
|
||||
'/auth',
|
||||
'/callback/',
|
||||
'/deezer/',
|
||||
'/socket.io',
|
||||
'/static',
|
||||
'/stream',
|
||||
'/tidal/',
|
||||
)
|
||||
|
||||
if normalized in excluded_exact_paths:
|
||||
return False
|
||||
|
||||
return not normalized.startswith(excluded_prefixes)
|
||||
|
||||
|
||||
def _webui_vite_dev_asset_url(asset_path: str) -> str:
|
||||
return f'{_webui_vite_url}{_webui_vite_base.rstrip("/")}/{asset_path.lstrip("/")}'
|
||||
|
||||
|
||||
def _load_webui_vite_manifest():
|
||||
global _webui_vite_manifest, _webui_vite_manifest_mtime
|
||||
|
||||
manifest_mtime = None
|
||||
if _webui_vite_manifest_path.exists():
|
||||
try:
|
||||
manifest_mtime = _webui_vite_manifest_path.stat().st_mtime
|
||||
except OSError:
|
||||
manifest_mtime = None
|
||||
|
||||
if _webui_vite_manifest is not None and manifest_mtime == _webui_vite_manifest_mtime:
|
||||
return _webui_vite_manifest
|
||||
|
||||
if _webui_vite_manifest_path.exists():
|
||||
try:
|
||||
with _webui_vite_manifest_path.open('r', encoding='utf-8') as handle:
|
||||
_webui_vite_manifest = json.load(handle)
|
||||
_webui_vite_manifest_mtime = manifest_mtime
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to load webui manifest: {exc}")
|
||||
_webui_vite_manifest = {}
|
||||
_webui_vite_manifest_mtime = manifest_mtime
|
||||
else:
|
||||
_webui_vite_manifest = {}
|
||||
_webui_vite_manifest_mtime = None
|
||||
return _webui_vite_manifest
|
||||
|
||||
|
||||
def _build_webui_vite_assets(placement='body'):
|
||||
if placement not in ('head', 'body'):
|
||||
return ''
|
||||
|
||||
if _webui_vite_dev:
|
||||
if placement == 'head':
|
||||
return ''
|
||||
return '\n'.join([
|
||||
f'<script type="module" src="{_webui_vite_dev_asset_url("@vite/client")}"></script>',
|
||||
f'<script type="module" src="{_webui_vite_dev_asset_url(_webui_vite_entry)}"></script>',
|
||||
])
|
||||
|
||||
manifest = _load_webui_vite_manifest()
|
||||
entry = manifest.get(_webui_vite_entry)
|
||||
if not entry:
|
||||
return ''
|
||||
|
||||
if placement == 'head':
|
||||
head_assets = []
|
||||
for css_file in entry.get('css', []):
|
||||
head_assets.append(f'<link rel="stylesheet" href="{url_for("static", filename=f"dist/{css_file}")}">')
|
||||
return '\n'.join(head_assets)
|
||||
|
||||
entry_file = entry.get('file')
|
||||
if not entry_file:
|
||||
return ''
|
||||
return f'<script type="module" src="{url_for("static", filename=f"dist/{entry_file}")}"></script>'
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_webui_assets():
|
||||
return {
|
||||
'vite_assets': _build_webui_vite_assets,
|
||||
'vite_assets': build_webui_vite_assets,
|
||||
}
|
||||
|
||||
# --- Profile Context (before_request hook) ---
|
||||
|
|
@ -3439,7 +3349,7 @@ def service_worker():
|
|||
@app.route('/<path:page>')
|
||||
def spa_catch_all(page):
|
||||
# Serve index.html for client-side routes; let Flask handle real routes first.
|
||||
if not _should_serve_webui_spa(f'/{page}'):
|
||||
if not should_serve_webui_spa(f'/{page}'):
|
||||
abort(404)
|
||||
return index()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue