From e7591356082736d0a5d9a15e9a60ba529841d67e Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 23 Jul 2026 10:29:53 +0300 Subject: [PATCH] Bonsai: cache-bust webui static assets so shipped JS/CSS changes reach users Browsers were caching /static/js and /static/css for the standalone webui (costing, gantt, drawings, index, demo pages) indefinitely, so a shipped JS fix (e.g. the Download CSV button) would only reach a user after a manual hard refresh. Two changes, applied consistently across all five webui pages. 1. Every locally served link/script tag in the pystache templates now carries a ?v= query string, falling back to a static asset mtime hash when BONSAI_VERSION isn't set (e.g. running sioserver.py standalone). Since get_bonsai_version() includes the build's commit hash, the token changes on every shipped update. 2. Responses under /static/ and /jsgantt/ now carry Cache-Control: no-cache, must-revalidate. This covers what query stamping alone can't reach: cost.js and gantt.js statically import utilities/costui.js by a fixed relative path with no query string, so that nested module still needed server side revalidation to pick up changes. Verified against a live aiohttp instance of sioserver.py: rendered HTML for all five routes shows the stamped URLs, and the token changes when BONSAI_VERSION changes between two server runs. A conditional GET against a static file with a stale If-Modified-Since header confirms the cheap 304 revalidation path still works. Also used this instance plus a real headless Chromium (Playwright) to click test the previously untested Download CSV button on the costing page. The ribbon renders it correctly, and clicking it (with a synthetic cost-items table injected into the DOM to stand in for a connected Blender's data) triggers a real Blob download with the correct filename and CSV content. No bug found, the button works as intended. AI-generated with Claude Code. --- src/bonsai/bonsai/bim/data/webui/sioserver.py | 58 +++++++++++++++++-- .../bim/data/webui/templates/costing.html | 6 +- .../bonsai/bim/data/webui/templates/demo.html | 6 +- .../bim/data/webui/templates/drawings.html | 6 +- .../bim/data/webui/templates/gantt.html | 10 ++-- .../bim/data/webui/templates/index.html | 6 +- 6 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index 91c3c4e8eb..4098778fac 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -10,6 +10,7 @@ if bonsai_lib_path: import argparse import base64 import json +import urllib.parse import xml.etree.ElementTree as ET import pystache @@ -18,8 +19,53 @@ from aiohttp import web sio_port = 8080 # default port + +def get_asset_version() -> str: + """A cache-busting token appended to locally served static asset URLs. + + Browsers otherwise keep serving a stale cached copy of static/js and + static/css after Bonsai ships a code change, until the user does a hard + refresh. Using the Bonsai version (which includes the build's commit + hash) means the token changes on every shipped update. + """ + if bonsai_version: + return urllib.parse.quote(bonsai_version, safe="") + # Fallback for standalone runs without BONSAI_VERSION set (e.g. running + # sioserver.py directly outside of Blender): derive a token from the + # newest mtime among the static assets, so it still changes whenever the + # shipped files change. + static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + latest_mtime = 0 + for root, _dirs, files in os.walk(static_dir): + for name in files: + latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name)))) + return f"dev-{latest_mtime}" + + +asset_version = get_asset_version() + + +@web.middleware +async def no_cache_static_middleware(request: web.Request, handler): + """Force revalidation of locally served static assets. + + Query-string version stamping (see `asset_version`) busts the cache for + the HTML-referenced entry points, but JS files that statically import + other local modules (e.g. cost.js/gantt.js importing utilities/costui.js) + reference those modules by an un-stamped relative path. Marking all + /static/ and /jsgantt/ responses as no-cache makes browsers always + revalidate with the server (a cheap conditional GET / 304 when nothing + changed), so nested imports also pick up shipped changes without + requiring a hard refresh. + """ + response = await handler(request) + if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"): + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + + sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000) -app = web.Application() +app = web.Application(middlewares=[no_cache_static_middleware]) sio.attach(app) @@ -199,28 +245,28 @@ class BlenderNamespace(socketio.AsyncNamespace): async def schedules(request): with open("templates/index.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") async def costing(request): with open("templates/costing.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") async def sequencing(request): with open("templates/gantt.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") async def documentation(request): with open("templates/drawings.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") @@ -229,7 +275,7 @@ async def documentation(request): async def demo(request): with open("templates/demo.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") diff --git a/src/bonsai/bonsai/bim/data/webui/templates/costing.html b/src/bonsai/bonsai/bim/data/webui/templates/costing.html index 6e076203c3..948ba4a00e 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/costing.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/costing.html @@ -7,7 +7,7 @@ - +