From 32bf52cc182862e97325f17278c31b89c2c4535a Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 22:24:52 +0300 Subject: [PATCH] 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 --- core/webui/__init__.py | 19 ++++++ core/webui/assets.py | 126 +++++++++++++++++++++++++++++++++++++ core/webui/spa.py | 23 +++++++ tests/webui/test_assets.py | 96 ++++++++++++++++++++++++++++ web_server.py | 110 +++----------------------------- 5 files changed, 274 insertions(+), 100 deletions(-) create mode 100644 core/webui/__init__.py create mode 100644 core/webui/assets.py create mode 100644 core/webui/spa.py create mode 100644 tests/webui/test_assets.py diff --git a/core/webui/__init__.py b/core/webui/__init__.py new file mode 100644 index 00000000..79bc922c --- /dev/null +++ b/core/webui/__init__.py @@ -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", +] diff --git a/core/webui/assets.py b/core/webui/assets.py new file mode 100644 index 00000000..8c055921 --- /dev/null +++ b/core/webui/assets.py @@ -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'', + f'', + ]) + + 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'' + for css_file in entry_meta.get("css", []) + ) + + entry_file = entry_meta.get("file") + if not entry_file: + return "" + + return f'' diff --git a/core/webui/spa.py b/core/webui/spa.py new file mode 100644 index 00000000..d572f262 --- /dev/null +++ b/core/webui/spa.py @@ -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) diff --git a/tests/webui/test_assets.py b/tests/webui/test_assets.py new file mode 100644 index 00000000..21660899 --- /dev/null +++ b/tests/webui/test_assets.py @@ -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 == ( + '\n' + '' + ) + + +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 == '' + assert html_body == '' + + +@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" diff --git a/web_server.py b/web_server.py index d4449315..5622751f 100644 --- a/web_server.py +++ b/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'', - f'', - ]) - - 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'') - return '\n'.join(head_assets) - - entry_file = entry.get('file') - if not entry_file: - return '' - return f'' - - @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('/') 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()