fix: detect stale static bundles in build pipeline and at runtime (#1064) (#1121)

* fix: detect stale static bundles in build pipeline and at runtime (#1064)

Add a packaging-time sanity check and a backend startup self-check that
verifies index.html only references /assets/*.js and /assets/*.css files
that actually exist on disk. This is the most common root cause of the
'Preparing backend...' / blank-page bug seen on the Windows immutable
Release packages: vite emits a fresh index.html with new content hashes,
but the desktop packaging step picks up a stale static/ directory, so
the browser receives a 404 (returned as application/json) for the main
bundle and refuses to execute it.

- scripts/check_static_assets.py: cross-platform sanity script.
- scripts/build-backend{.ps1,-macos.sh}: run the script after npm build
  on the source static/, and again after PyInstaller on the packaged
  static directory; fail the build on mismatch.
- api/app.py: log an actionable ERROR when an inconsistency is detected
  at startup so the root cause appears in logs/desktop.log instead of
  surfacing as a silent blank page.
- api/app.py: replace the StaticFiles mount with an explicit /assets/*
  route so misses return a plain-text 404 with a JS/CSS Content-Type,
  not the default JSON error response that masked diagnosis in #1064.
- tests/test_static_assets_consistency.py: 7 deterministic tests for
  both code paths.

Refs #1064 #1065 #1050
This commit is contained in:
mumu
2026-04-25 20:22:59 +08:00
committed by GitHub
parent 9a08ba47fa
commit 0924dce274
6 changed files with 545 additions and 6 deletions

View File

@@ -15,17 +15,98 @@ FastAPI 应用工厂模块
app = create_app()
"""
import logging
import mimetypes
import os
import re
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.parse import unquote
from typing import List, Optional
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
logger = logging.getLogger(__name__)
# Match src="/assets/foo.js" / href="/assets/foo.css" produced by the
# vite build. Used by the startup self-check to surface packaging
# mismatches early (see GitHub #1064 / #1065 / #1050).
_INDEX_ASSET_REF_PATTERN = re.compile(
r"""(?:src|href)\s*=\s*["'](/assets/[^"']+)["']""",
re.IGNORECASE,
)
_SAFE_MISSING_ASSET_MEDIA_TYPES = frozenset({"text/css", "text/javascript"})
def _check_frontend_assets_consistency(static_dir: Path) -> List[str]:
"""
Verify that ``index.html`` only references assets that actually exist
under ``static_dir``. Returns the list of missing references; an empty
list means the bundle is consistent.
Logs an actionable error when a mismatch is detected so the root cause
is visible in ``logs/desktop.log`` instead of surfacing as a silent
blank page.
"""
index_html = static_dir / "index.html"
if not index_html.is_file():
return []
try:
html = index_html.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
logger.warning("Failed to read %s for asset check: %s", index_html, exc)
return []
missing: List[str] = []
for match in _INDEX_ASSET_REF_PATTERN.finditer(html):
ref = match.group(1)
candidate = static_dir / ref.lstrip("/")
if not candidate.is_file() and ref not in missing:
missing.append(ref)
if missing:
logger.error(
"Frontend bundle is inconsistent: index.html references %d asset(s) "
"that are not present on disk under %s. This will surface as a "
"blank page in the desktop app (see GitHub #1064 / #1065). "
"Missing: %s. Re-run the frontend build and make sure the packaging "
"step copies the freshly generated static/ directory.",
len(missing),
static_dir,
", ".join(missing),
)
return missing
def _resolve_asset_path(assets_dir: Path, asset_path: str) -> Optional[Path]:
"""Resolve a requested asset path while keeping it confined to assets_dir."""
decoded_path = unquote(asset_path)
if not decoded_path or decoded_path.startswith(("/", "\\")):
return None
if "\x00" in decoded_path:
return None
if "\\" in decoded_path:
return None
if ":" in decoded_path.split("/", 1)[0]:
return None
assets_root = assets_dir.resolve()
candidate = (assets_root / decoded_path).resolve()
if not candidate.is_relative_to(assets_root):
return None
return candidate
def _missing_asset_media_type(asset_path: str) -> str:
"""Return a safe media type for a missing asset response."""
content_type, _ = mimetypes.guess_type(asset_path)
if content_type in _SAFE_MISSING_ASSET_MEDIA_TYPES:
return content_type
return "text/plain"
from api.v1 import api_v1_router
from api.middlewares.auth import add_auth_middleware
@@ -121,6 +202,11 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI:
has_frontend = static_dir.exists() and (static_dir / "index.html").exists()
if has_frontend:
# Surface bundle inconsistencies as soon as the app starts so that
# blank-page reports (#1064 / #1065 / #1050) can be diagnosed from
# logs/desktop.log instead of via browser devtools.
_check_frontend_assets_consistency(static_dir)
@app.get("/", include_in_schema=False)
async def root():
"""根路由 - 返回前端页面"""
@@ -177,11 +263,38 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI:
# ============================================================
if has_frontend:
# 挂载静态资源目录
# Serve `/assets/*` explicitly so that misses return a plain-text
# 404 with the correct Content-Type instead of the default JSON
# error response. JSON for a JS/CSS request is what masked the
# blank-page root cause in #1064; here we make it obvious that the
# static file simply does not exist on disk.
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
assets_static_files = StaticFiles(directory=str(assets_dir), check_dir=False)
assets_root = assets_dir.resolve()
@app.api_route(
"/assets/{asset_path:path}",
methods=["GET", "HEAD"],
include_in_schema=False,
)
async def serve_asset(request: Request, asset_path: str):
file_path = _resolve_asset_path(assets_dir, asset_path)
if file_path is None:
return Response(
content="not found",
status_code=404,
media_type="text/plain",
)
if file_path.is_file():
relative_path = file_path.relative_to(assets_root).as_posix()
return await assets_static_files.get_response(relative_path, request.scope)
return Response(
content="asset not found",
status_code=404,
media_type=_missing_asset_media_type(asset_path),
)
# SPA 路由回退
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(request: Request, full_path: str):

View File

@@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [文档] 优化根 README 结构保留功能特性、技术栈、快速开始、推送效果、Web、Agent、赞助商和新闻源链接入口将细配置、交易纪律和基本面语义收口到完整指南并将 Docker 徽章指向官方镜像页
- [文档] 同步英文与繁中 README 的精简入口结构,并补齐完整指南中的 LLM 用量 API 与持仓管理说明
- [文档] 调整 AI 协作与 PR 模板中的 README 维护规则,明确 README 非必要不更新,细节优先进入专题文档
- [修复] 桌面端打包链路新增 `scripts/check_static_assets.py` 静态资源一致性检查,并在 `build-backend(.ps1|-macos.sh)` 的源 `static/` 与 PyInstaller 产物里各跑一次;同时在后端启动时校验 `index.html` 引用的 `/assets/*.js`/`*.css` 是否真实存在,发现错配时直接在 `logs/desktop.log` 打印明确错误,避免重现 Release 包打开后白屏Refs #1064 / #1065 / #1050
- [改进] 后端 `/assets/*` 由显式路由托管,资源缺失时返回与请求扩展名匹配的 `text/javascript` / `text/css` 404而不是被默认 JSON 错误响应误导排查Refs #1064
## [3.13.0] - 2026-04-21

View File

@@ -30,6 +30,9 @@ fi
npm run build
popd >/dev/null
log "Verifying static asset references (source)..."
"${PYTHON_BIN}" "${SCRIPT_DIR}/check_static_assets.py" "${ROOT_DIR}/static"
log "Building backend executable..."
if ! "${PYTHON_BIN}" -m PyInstaller --version >/dev/null 2>&1; then
"${PYTHON_BIN}" -m pip install pyinstaller
@@ -109,4 +112,15 @@ popd >/dev/null
cp -R "${ROOT_DIR}/dist/stock_analysis" "${ROOT_DIR}/dist/backend/stock_analysis"
log "Verifying static asset references (packaged)..."
packaged_static="${ROOT_DIR}/dist/backend/stock_analysis/_internal/static"
if [[ ! -d "${packaged_static}" ]]; then
packaged_static="${ROOT_DIR}/dist/backend/stock_analysis/static"
fi
if [[ -d "${packaged_static}" ]]; then
"${PYTHON_BIN}" "${SCRIPT_DIR}/check_static_assets.py" "${packaged_static}"
else
log "WARNING: could not locate packaged static directory under dist/backend/stock_analysis; skipping post-package check."
fi
log "Backend build completed."

View File

@@ -15,6 +15,12 @@ if ([string]::IsNullOrWhiteSpace($pythonBin)) {
Write-Host "Using Python: $pythonBin"
Write-Host 'Verifying static asset references (source)...'
& $pythonBin "${PSScriptRoot}\check_static_assets.py" 'static'
if ($LASTEXITCODE -ne 0) {
throw "Static asset sanity check failed for source static/. See GitHub #1064."
}
function Test-PythonCode {
param(
[string]$Python,
@@ -124,4 +130,18 @@ if (!(Test-Path 'dist\stock_analysis')) {
Copy-Item -Path 'dist\stock_analysis' -Destination 'dist\backend\stock_analysis' -Recurse -Force
Write-Host 'Verifying static asset references (packaged)...'
$packagedStatic = Join-Path 'dist\backend\stock_analysis' '_internal\static'
if (-not (Test-Path $packagedStatic)) {
$packagedStatic = Join-Path 'dist\backend\stock_analysis' 'static'
}
if (Test-Path $packagedStatic) {
& $pythonBin "${PSScriptRoot}\check_static_assets.py" $packagedStatic
if ($LASTEXITCODE -ne 0) {
throw "Static asset sanity check failed for packaged $packagedStatic. See GitHub #1064."
}
} else {
Write-Warning "Could not locate packaged static directory under dist\backend\stock_analysis; skipping post-package check."
}
Write-Host 'Backend build completed.'

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Static frontend sanity check for the desktop / server packaging pipeline.
Validates that ``index.html`` only references ``/assets/*.js`` and
``/assets/*.css`` files that actually exist on disk. A mismatch here is the
most common cause of the "Preparing backend..." / blank-page bug reported in
GitHub issues #1064, #1065, #1050: vite re-builds with a new content hash,
but the packaging step picks up a stale ``static/`` directory or copies the
files out of sync, so the browser receives a 404 (often as JSON) for the
main bundle and refuses to execute it.
Usage:
python scripts/check_static_assets.py [<static_dir>]
Exits 0 when consistent, non-zero with a human-readable message otherwise.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import List, Tuple
# Match src="/assets/foo.js" or href="/assets/foo.css", with single or double
# quotes. Vite emits absolute paths by default (``base: '/'``).
_ASSET_PATTERN = re.compile(
r"""(?:src|href)\s*=\s*["'](/assets/[^"']+)["']""",
re.IGNORECASE,
)
def _parse_referenced_assets(index_html: str) -> List[str]:
"""Return the unique list of ``/assets/...`` paths referenced by ``index.html``."""
seen: List[str] = []
for match in _ASSET_PATTERN.finditer(index_html):
ref = match.group(1)
if ref not in seen:
seen.append(ref)
return seen
def check_static_dir(static_dir: Path) -> Tuple[List[str], List[str]]:
"""
Inspect ``static_dir`` and return ``(referenced, missing)``.
``referenced`` is the list of ``/assets/...`` paths declared in
``index.html``. ``missing`` is the subset that does not exist on disk.
Raises ``FileNotFoundError`` if ``index.html`` itself is missing.
"""
index_html_path = static_dir / "index.html"
if not index_html_path.is_file():
raise FileNotFoundError(f"index.html not found under {static_dir}")
html = index_html_path.read_text(encoding="utf-8", errors="replace")
referenced = _parse_referenced_assets(html)
missing: List[str] = []
for ref in referenced:
# ref looks like "/assets/index-xxx.js"; strip the leading slash so
# it resolves relative to ``static_dir``.
candidate = static_dir / ref.lstrip("/")
if not candidate.is_file():
missing.append(ref)
return referenced, missing
def main(argv: List[str]) -> int:
if len(argv) > 1:
static_dir = Path(argv[1]).resolve()
else:
static_dir = (Path(__file__).resolve().parent.parent / "static").resolve()
print(f"[check_static_assets] inspecting {static_dir}")
try:
referenced, missing = check_static_dir(static_dir)
except FileNotFoundError as exc:
print(f"[check_static_assets] ERROR: {exc}", file=sys.stderr)
print(
"[check_static_assets] Hint: build the frontend first via "
"`cd apps/dsa-web && npm install && npm run build`.",
file=sys.stderr,
)
return 2
if not referenced:
print(
"[check_static_assets] WARNING: index.html does not reference any "
"/assets/* files; this is unusual for a vite build.",
file=sys.stderr,
)
return 0
if missing:
print(
"[check_static_assets] ERROR: index.html references assets that "
"are not present on disk:",
file=sys.stderr,
)
for ref in missing:
print(f" - {ref}", file=sys.stderr)
print(
"[check_static_assets] This produces a blank page on first load "
"(see GitHub #1064 / #1065). Re-run the frontend build and make "
"sure the packaging step copies the freshly generated static/ "
"directory.",
file=sys.stderr,
)
return 1
print(
f"[check_static_assets] OK: {len(referenced)} asset reference(s) "
f"resolved successfully."
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@@ -0,0 +1,268 @@
# -*- coding: utf-8 -*-
"""Tests for ``scripts/check_static_assets.py`` and the equivalent
backend startup self-check in ``api.app``.
Both code paths target the blank-page / "Preparing backend..." regression
captured in GitHub issues #1064, #1065 and #1050: vite produces a fresh
``index.html`` that references ``/assets/index-<hash>.js``, but the
packaging step copies a stale ``static/assets`` directory, so the bundle
referenced by ``index.html`` does not exist on disk.
"""
from __future__ import annotations
import importlib
import logging
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
SCRIPT_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
check_static_assets = importlib.import_module("check_static_assets")
def _write_index(static_dir: Path, body: str) -> None:
static_dir.mkdir(parents=True, exist_ok=True)
(static_dir / "index.html").write_text(body, encoding="utf-8")
def _vite_index(js_name: str, css_name: str) -> str:
return (
"<!doctype html><html><head>"
f'<script type="module" crossorigin src="/assets/{js_name}"></script>'
f'<link rel="stylesheet" crossorigin href="/assets/{css_name}">'
"</head><body><div id=\"root\"></div></body></html>"
)
def test_check_static_dir_passes_when_assets_match(tmp_path: Path) -> None:
static_dir = tmp_path / "static"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
(assets_dir / "index-abc.js").write_text("// js", encoding="utf-8")
(assets_dir / "index-abc.css").write_text("/* css */", encoding="utf-8")
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
referenced, missing = check_static_assets.check_static_dir(static_dir)
assert sorted(referenced) == ["/assets/index-abc.css", "/assets/index-abc.js"]
assert missing == []
def test_check_static_dir_detects_stale_bundle(tmp_path: Path) -> None:
"""index.html references new hash but assets/ holds an old hash."""
static_dir = tmp_path / "static"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
# Stale on-disk bundle.
(assets_dir / "index-OLD.js").write_text("// old", encoding="utf-8")
(assets_dir / "index-OLD.css").write_text("/* old */", encoding="utf-8")
# Fresh index.html points to a different hash.
_write_index(static_dir, _vite_index("index-NEW.js", "index-NEW.css"))
referenced, missing = check_static_assets.check_static_dir(static_dir)
assert "/assets/index-NEW.js" in referenced
assert sorted(missing) == ["/assets/index-NEW.css", "/assets/index-NEW.js"]
def test_check_static_dir_raises_when_index_missing(tmp_path: Path) -> None:
static_dir = tmp_path / "static"
static_dir.mkdir()
with pytest.raises(FileNotFoundError):
check_static_assets.check_static_dir(static_dir)
def test_main_returns_nonzero_when_assets_missing(tmp_path: Path, capsys) -> None:
static_dir = tmp_path / "static"
(static_dir / "assets").mkdir(parents=True)
_write_index(static_dir, _vite_index("index-MISSING.js", "index-MISSING.css"))
rc = check_static_assets.main(["check_static_assets.py", str(static_dir)])
captured = capsys.readouterr()
assert rc == 1
assert "ERROR" in captured.err
assert "/assets/index-MISSING.js" in captured.err
def test_main_returns_zero_when_consistent(tmp_path: Path) -> None:
static_dir = tmp_path / "static"
assets = static_dir / "assets"
assets.mkdir(parents=True)
(assets / "main.js").write_text("// ok", encoding="utf-8")
(assets / "main.css").write_text("/* ok */", encoding="utf-8")
_write_index(static_dir, _vite_index("main.js", "main.css"))
rc = check_static_assets.main(["check_static_assets.py", str(static_dir)])
assert rc == 0
def test_backend_startup_check_logs_when_bundle_inconsistent(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
from api import app as app_module
static_dir = tmp_path / "static"
(static_dir / "assets").mkdir(parents=True)
_write_index(static_dir, _vite_index("index-NEW.js", "index-NEW.css"))
with caplog.at_level(logging.ERROR, logger="api.app"):
missing = app_module._check_frontend_assets_consistency(static_dir)
assert sorted(missing) == ["/assets/index-NEW.css", "/assets/index-NEW.js"]
assert any(
"Frontend bundle is inconsistent" in record.getMessage()
for record in caplog.records
)
def test_backend_startup_check_silent_when_bundle_consistent(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
from api import app as app_module
static_dir = tmp_path / "static"
assets = static_dir / "assets"
assets.mkdir(parents=True)
(assets / "index-abc.js").write_text("// js", encoding="utf-8")
(assets / "index-abc.css").write_text("/* css */", encoding="utf-8")
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
with caplog.at_level(logging.ERROR, logger="api.app"):
missing = app_module._check_frontend_assets_consistency(static_dir)
assert missing == []
assert not any(
"Frontend bundle is inconsistent" in record.getMessage()
for record in caplog.records
)
def test_missing_asset_returns_safe_404_content_types(tmp_path: Path) -> None:
from api.app import create_app
static_dir = tmp_path / "static"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
(assets_dir / "index-abc.js").write_text("// ok", encoding="utf-8")
(assets_dir / "index-abc.css").write_text("/* ok */", encoding="utf-8")
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
client = TestClient(create_app(static_dir=static_dir))
js_response = client.get("/assets/index-missing.js")
css_response = client.get("/assets/index-missing.css")
html_response = client.get("/assets/%3Cscript%3Ealert(1)%3C/script%3E.html")
assert js_response.status_code == 404
assert js_response.text == "asset not found"
assert js_response.headers["content-type"].startswith("text/javascript")
assert css_response.status_code == 404
assert css_response.text == "asset not found"
assert css_response.headers["content-type"].startswith("text/css")
assert html_response.status_code == 404
assert html_response.text == "asset not found"
assert html_response.headers["content-type"].startswith("text/plain")
def test_existing_asset_is_served_from_explicit_assets_route(tmp_path: Path) -> None:
from api.app import create_app
static_dir = tmp_path / "static"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
js_file = assets_dir / "index-abc.js"
css_file = assets_dir / "index-abc.css"
js_file.write_text("console.log('ok')", encoding="utf-8")
css_file.write_text("body{color:#fff}", encoding="utf-8")
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
client = TestClient(create_app(static_dir=static_dir))
js_response = client.get("/assets/index-abc.js")
css_response = client.get("/assets/index-abc.css")
assert js_response.status_code == 200
assert js_response.text == "console.log('ok')"
assert js_response.headers["content-type"].startswith("text/javascript")
assert css_response.status_code == 200
assert css_response.text == "body{color:#fff}"
assert css_response.headers["content-type"].startswith("text/css")
def test_existing_asset_supports_head_and_conditional_requests(tmp_path: Path) -> None:
from api.app import create_app
static_dir = tmp_path / "static"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
js_file = assets_dir / "index-abc.js"
js_file.write_text("console.log('ok')", encoding="utf-8")
(assets_dir / "index-abc.css").write_text("body{color:#fff}", encoding="utf-8")
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
client = TestClient(create_app(static_dir=static_dir))
get_response = client.get("/assets/index-abc.js")
etag = get_response.headers["etag"]
head_response = client.head("/assets/index-abc.js")
cached_response = client.get(
"/assets/index-abc.js",
headers={"if-none-match": etag},
)
assert get_response.status_code == 200
assert head_response.status_code == 200
assert head_response.content == b""
assert head_response.headers["etag"] == etag
assert head_response.headers["content-type"].startswith("text/javascript")
assert cached_response.status_code == 304
assert cached_response.content == b""
assert cached_response.headers["etag"] == etag
@pytest.mark.parametrize(
"request_path",
[
"/assets/..%5C..%5Csecret.txt",
"/assets/C:%5Csecret.txt",
"/assets/%5Cwindows%5Csystem32%5Cconfig",
"/assets/%2e%2e/%2e%2e/secret.txt",
"/assets/%2500.js",
],
)
def test_asset_traversal_attempts_are_rejected(
tmp_path: Path,
request_path: str,
) -> None:
from api.app import create_app
static_dir = tmp_path / "static"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
(assets_dir / "index-abc.js").write_text("// ok", encoding="utf-8")
(assets_dir / "index-abc.css").write_text("/* ok */", encoding="utf-8")
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
outside_secret = tmp_path / "secret.txt"
outside_secret.write_text("top secret", encoding="utf-8")
client = TestClient(create_app(static_dir=static_dir))
response = client.get(request_path)
assert response.status_code == 404
assert response.text == "not found"
assert "top secret" not in response.text