Speed up startup: parallel JS loading + versioned asset caching

loader.js:
- Fetch /api/version first; append ?v=<version> to every script URL
- Load scripts with async=false (fetch in parallel, execute in DOM order)
- 4 sequential batches instead of 17 sequential awaits:
    utils → settings → [9 feature modules] → init → [4 post-init] → nav
- 17 round-trips → 6 round-trips; 9 and 4 files now download in parallel
- Section HTML keeps ?v=<timestamp> (no-store, always fresh)

server.py:
- Add static_cache_headers middleware:
    /static/js/*?v=...  → Cache-Control: max-age=31536000, immutable
    /static/sections/*  → Cache-Control: no-store
    /static/* (no v=)   → Cache-Control: max-age=3600

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-29 17:11:55 +02:00
parent 7f35d32f3e
commit a5efac5da5
3 changed files with 105 additions and 32 deletions

View File

@ -7,6 +7,22 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Performance
- **Parallel JS module loading**`loader.js` previously loaded all 16
modules sequentially (17 round-trips). Restructured into 4 ordered batches
with `async=false` so files are fetched in parallel but execute in the
correct dependency order:
`utils``settings`*(9 feature modules in parallel)*`init`
*(4 post-init modules in parallel)*`nav`
Round-trips reduced from 17 to 6; 9 files now download simultaneously.
- **Version-based JS/CSS cache busting**`loader.js` fetches
`/api/version` first and appends `?v=<version>` to every script URL.
A new `Cache-Control: public, max-age=31536000, immutable` middleware in
`server.py` lets the browser cache versioned assets for a full year.
Bumping the version (via `scripts/release.py`) invalidates the cache.
Section HTML keeps `?v=<timestamp>` (no-store) so it is always fresh.
### Changed ### Changed
- **Config and logs are now bind-mounted local folders** — replaced the - **Config and logs are now bind-mounted local folders** — replaced the

View File

@ -6,7 +6,8 @@ import os
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from core.constants import STATIC_DIR, _BufferHandler from core.constants import STATIC_DIR, _BufferHandler
@ -44,6 +45,24 @@ try:
except Exception as _log_err: except Exception as _log_err:
logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err) logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err)
# ── Static-asset caching middleware ──────────────────────────────────────────
# JS and CSS are served with ?v=<version> by loader.js — safe to cache for 1 year.
# Section HTML uses ?v=<timestamp> (always fresh) so no long-term cache there.
@app.middleware("http")
async def static_cache_headers(request: Request, call_next):
response: Response = await call_next(request)
path = request.url.path
has_version = bool(request.query_params.get("v"))
if has_version and (path.startswith("/static/js/") or path.endswith(".css")):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif path.startswith("/static/sections/"):
response.headers["Cache-Control"] = "no-store"
elif path.startswith("/static/") and not has_version:
response.headers["Cache-Control"] = "public, max-age=3600"
return response
# ── Routers ─────────────────────────────────────────────────────────────────── # ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(admin.router) app.include_router(admin.router)

View File

@ -1,58 +1,96 @@
(async function () { (async function () {
'use strict'; 'use strict';
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; const SECTIONS = [
's-voices', 's-clone', 's-design', 's-studio', 's-tryout',
's-performance', 's-routing', 's-connect', 's-settings',
's-llms', 's-conversation',
];
function loadScript(src) { // ── Fetch app version once for JS cache-busting ──────────────────────────
// JS files are served with ?v=<version> so the browser can cache them
// aggressively. Sections use Date.now() to always get fresh HTML.
var _appVersion = 'dev';
try {
var _vr = await fetch('/api/version');
var _vd = await _vr.json();
_appVersion = _vd.version || _appVersion;
} catch (_) {}
// ── 1. Fetch all section HTML partials in parallel ────────────────────────
var _ts = Date.now(); // sections always fresh during a session
await Promise.all(SECTIONS.map(async function (id) {
try {
var res = await fetch('/static/sections/' + id + '.html?v=' + _ts);
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
var el = document.getElementById(id);
if (el) el.innerHTML = await res.text();
} catch (e) {
console.error('[loader] section', id, 'failed:', e.message);
var el = document.getElementById(id);
if (el) el.innerHTML =
'<p style="color:var(--red);padding:24px">Failed to load section <b>' +
id + '</b>: ' + e.message + '</p>';
}
}));
// ── 2. Load JS in parallel batches ────────────────────────────────────────
// Scripts with async=false are FETCHED in parallel but EXECUTED in DOM
// insertion order — safe for modules that share a global scope.
//
// Batch order:
// A) utils.js — defines $, toast, escHtml; needed by everything
// B) settings.js — declares _appSettings; needed before features read it
// C) Feature modules — parallel fetch + ordered execute
// D) init.js — calls loadSettings() + initBenchmarkSampleControls()
// E) Post-init modules — parallel fetch + ordered execute
// F) nav.js — navigation overrides; must be last
function _load(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = src; s.src = src + '?v=' + _appVersion;
s.async = false; // fetch in parallel, execute in DOM order
s.onload = resolve; s.onload = resolve;
s.onerror = function () { reject(new Error('Failed to load ' + src)); }; s.onerror = function () { reject(new Error('Failed to load ' + src)); };
document.body.appendChild(s); document.body.appendChild(s);
}); });
} }
// 1. Fetch all section partials in parallel and inject into their shells. function _loadBatch(srcs) {
// ?v= busts the browser cache so updated HTML is always used. // Append all <script async=false> at once → browser fetches all in parallel
var _v = Date.now(); // and executes them in the order they were appended.
await Promise.all(SECTIONS.map(async function (id) { return Promise.all(srcs.map(_load));
try {
var res = await fetch('/static/sections/' + id + '.html?v=' + _v);
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
var html = await res.text();
var el = document.getElementById(id);
if (el) el.innerHTML = html;
} catch (e) {
console.error('[loader] section', id, 'failed:', e.message);
var el = document.getElementById(id);
if (el) el.innerHTML = '<p style="color:var(--red);padding:24px">Failed to load section <b>' + id + '</b>: ' + e.message + '</p>';
} }
}));
// 2. Load main application logic (runs init immediately on parse — sections must exist first) // A — foundation (sequential: each must finish before the next batch starts)
const JS_MODULES = [ await _load('/static/js/utils.js');
'/static/js/utils.js', await _load('/static/js/settings.js');
// C — feature modules (9 files fetched in parallel, executed in order)
await _loadBatch([
'/static/js/voice-inspector.js', '/static/js/voice-inspector.js',
'/static/js/voice-sources.js', '/static/js/voice-sources.js',
'/static/js/integrations.js', '/static/js/integrations.js',
'/static/js/routing.js', '/static/js/routing.js',
'/static/js/settings.js',
'/static/js/voice-clone.js', '/static/js/voice-clone.js',
'/static/js/voice-library.js', '/static/js/voice-library.js',
'/static/js/tts-preview.js', '/static/js/tts-preview.js',
'/static/js/benchmark.js', '/static/js/benchmark.js',
'/static/js/stt.js', '/static/js/stt.js',
'/static/js/init.js', ]);
// D — init (needs everything above to be defined)
await _load('/static/js/init.js');
// E — post-init modules (4 files fetched in parallel, executed in order)
await _loadBatch([
'/static/js/engines.js', '/static/js/engines.js',
'/static/js/ai-backends.js', '/static/js/ai-backends.js',
'/static/js/generation.js', '/static/js/generation.js',
'/static/js/conversation.js', '/static/js/conversation.js',
]; ]);
for (const src of JS_MODULES) {
await loadScript(src);
}
// 3. Apply scroll-based navigation overrides (must run after app.js defines switchTab etc.) // F — navigation
await loadScript('/static/nav.js'); await _load('/static/nav.js');
})(); })();