Cleanup in js #16

Merged
gru merged 11 commits from cleanup_in_js into master 2026-06-02 23:02:29 +02:00
6 changed files with 73 additions and 31 deletions
Showing only changes of commit 62572ec273 - Show all commits
+3 -2
View File
@@ -16,7 +16,7 @@ from .config import (
SOCKETIO_CORS_ALLOWED_ORIGINS,
)
from .db import init_db
from .services.frontend_assets import asset_path, bootstrap_css_path, static_hash, validate_offline_assets
from .services.frontend_assets import asset_path, bootstrap_css_path, initialize_static_hash, static_hash, validate_offline_assets
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
@@ -56,6 +56,7 @@ def register_error_pages(app: Flask) -> None:
def create_app() -> Flask:
validate_offline_assets()
app = Flask(__name__)
initialize_static_hash(Path(app.static_folder or ""))
from .logging_config import configure_logging
configure_logging(app)
if PROXY_FIX_ENABLE:
@@ -83,7 +84,7 @@ def create_app() -> Flask:
path = Path(app.static_folder or "") / filename
try:
path.stat()
# Note: A single static hash keeps module imports, CSS and local libraries on the same cache version.
# Note: A single JS/CSS hash keeps module imports, stylesheets and local libraries on the same cache version.
return url_for("static", filename=filename, v=current_static_hash())
except OSError:
return url_for("static", filename=filename)
+6 -4
View File
@@ -7499,8 +7499,8 @@
"tags": [
"System"
],
"summary": "Get static asset version hash",
"description": "Returns the current hash for app static assets. Clients can compare it with window.PYTORRENT.staticHash and reload when it changes.",
"summary": "Get current frontend JS/CSS hash",
"description": "Returns the startup-computed hash for app JavaScript and CSS assets. The value is kept in memory and returned without scanning static files per request.",
"responses": {
"200": {
"description": "Static asset hash",
@@ -7513,10 +7513,12 @@
"type": "boolean"
},
"static_hash": {
"type": "string"
"type": "string",
"description": "Short SHA-256-based hash of frontend JavaScript and CSS files computed once at app startup."
},
"version": {
"type": "string"
"type": "string",
"description": "Alias of static_hash for simple client version checks."
}
}
}
+2 -4
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
from ._shared import *
from flask import current_app
from pathlib import Path
from ..services import operation_logs
from ..services.frontend_assets import static_hash
@@ -51,8 +49,8 @@ def system_status():
@bp.get("/static_hash")
def static_hash_get():
# Note: The frontend uses this lightweight version to detect changed static assets on mobile browsers.
value = static_hash(Path(current_app.static_folder or ""))
# Note: This returns the startup-computed JS/CSS version without scanning files per request.
value = static_hash()
return ok({"static_hash": value, "version": value})
+53 -19
View File
@@ -190,34 +190,68 @@ def validate_offline_assets() -> None:
)
_STATIC_HASH_CACHE: dict[tuple[str, int], str] = {}
_STATIC_HASH_VALUE = "dev"
_STATIC_HASH_READY = False
def static_hash(static_root: Path | None = None) -> str:
"""Return one short hash for all app static files.
Note: This value is used as the shared browser-cache version, so any static file
change invalidates app.js imports, CSS and local frontend assets together.
def _versioned_static_files(root: Path) -> list[Path]:
"""Return static files that should invalidate frontend JS/CSS caches.
Note: Only JavaScript and CSS affect the executable frontend version. Images,
favicons and user-provided tracker icons stay outside this lightweight hash.
"""
return [
path
for path in root.rglob("*")
if path.is_file()
and path.suffix.lower() in {".js", ".css"}
and "tracker_favicons" not in path.parts
]
def compute_static_hash(static_root: Path | None = None) -> str:
"""Compute one short startup hash for frontend JavaScript and CSS files.
Note: This function reads JS/CSS files and should be called during app
startup, not from frequent request handlers.
"""
import hashlib
root = static_root or (BASE_DIR / "pytorrent" / "static")
files = [path for path in root.rglob("*") if path.is_file() and "tracker_favicons" not in path.parts]
fingerprint = f"{root}:{sum(path.stat().st_mtime_ns for path in files)}:{sum(path.stat().st_size for path in files)}"
cached = _STATIC_HASH_CACHE.get((fingerprint, len(files)))
if cached:
return cached
digest = hashlib.sha256()
for path in sorted(files):
files = sorted(_versioned_static_files(root), key=lambda item: item.as_posix())
for path in files:
rel = path.relative_to(root).as_posix()
stat = path.stat()
digest.update(rel.encode("utf-8"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(str(stat.st_mtime_ns).encode("ascii"))
try:
digest.update(path.read_bytes())
stat = path.stat()
content = path.read_bytes()
except OSError:
continue
digest.update(rel.encode("utf-8"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(content)
value = digest.hexdigest()[:16]
_STATIC_HASH_CACHE.clear()
_STATIC_HASH_CACHE[(fingerprint, len(files))] = value
return value
return value or "dev"
def initialize_static_hash(static_root: Path | None = None) -> str:
"""Compute and store the frontend static hash once for this process.
Note: The API endpoint and template helpers only return this in-memory value,
which keeps mobile version checks ultra-light.
"""
global _STATIC_HASH_VALUE, _STATIC_HASH_READY
_STATIC_HASH_VALUE = compute_static_hash(static_root)
_STATIC_HASH_READY = True
return _STATIC_HASH_VALUE
def static_hash(static_root: Path | None = None) -> str:
"""Return the startup frontend static hash without rescanning files.
Note: The optional argument is kept for compatibility with existing callers;
it is only used for a lazy fallback before app startup initialization.
"""
if not _STATIC_HASH_READY:
return initialize_static_hash(static_root)
return _STATIC_HASH_VALUE
+8 -1
View File
@@ -99,9 +99,16 @@ async function loadModuleSources(){
return moduleSourcesPromise;
}
function normalizeRuntimeSource(source){
const text = String(source || '');
// Note: Some generated source chunks may end with a literal \\n marker;
// normalize only this trailing marker to avoid invalid Function() source.
return text.endsWith('\\n') ? `${text.slice(0, -2)}\n` : text;
}
export async function buildRuntimeSource(){
const sources = await loadModuleSources();
return `(() => {\n${sources.join('\n')}\n})();\n`;
return `(() => {\n${sources.map(normalizeRuntimeSource).join('\n')}\n})();\n`;
}
export async function startApp(){
+1 -1
View File
@@ -1 +1 @@
export const bootstrapRuntimeSource = " async function checkStaticAssetVersion(){ try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(checkStaticAssetVersion, 120000);\n window.addEventListener('focus', checkStaticAssetVersion);\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\\n";
export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";