From a5efac5da5f503afc96937e05a39653fe1fa5656 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 29 May 2026 17:11:55 +0200 Subject: [PATCH] Speed up startup: parallel JS loading + versioned asset caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loader.js: - Fetch /api/version first; append ?v= 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= (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 --- CHANGELOG.md | 16 ++++++++ server.py | 21 +++++++++- static/loader.js | 100 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 105 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a258613..ed551c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [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=` 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=` (no-store) so it is always fresh. + ### Changed - **Config and logs are now bind-mounted local folders** — replaced the diff --git a/server.py b/server.py index 66abbd6..2f54244 100644 --- a/server.py +++ b/server.py @@ -6,7 +6,8 @@ import os from logging.handlers import RotatingFileHandler from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from core.constants import STATIC_DIR, _BufferHandler @@ -44,6 +45,24 @@ try: except Exception as _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= by loader.js — safe to cache for 1 year. +# Section HTML uses ?v= (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 ─────────────────────────────────────────────────────────────────── app.include_router(admin.router) diff --git a/static/loader.js b/static/loader.js index 2499f7d..61d9782 100644 --- a/static/loader.js +++ b/static/loader.js @@ -1,58 +1,96 @@ (async function () { '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= 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 = + '

Failed to load section ' + + id + ': ' + e.message + '

'; + } + })); + + // ── 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) { var s = document.createElement('script'); - s.src = src; - s.onload = resolve; + s.src = src + '?v=' + _appVersion; + s.async = false; // fetch in parallel, execute in DOM order + s.onload = resolve; s.onerror = function () { reject(new Error('Failed to load ' + src)); }; document.body.appendChild(s); }); } - // 1. Fetch all section partials in parallel and inject into their shells. - // ?v= busts the browser cache so updated HTML is always used. - var _v = Date.now(); - await Promise.all(SECTIONS.map(async function (id) { - 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 = '

Failed to load section ' + id + ': ' + e.message + '

'; - } - })); + function _loadBatch(srcs) { + // Append all