fix: revalidate frontend assets across builds

This commit is contained in:
Patrick Buckley
2026-08-12 17:36:43 -07:00
parent 998271b016
commit 28ef63a10c
9 changed files with 980 additions and 51 deletions
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""Browser regression for the two-layer frontend cache contract.
This harness serves a versioned entry module which imports the real,
unversioned ``shared/interactive.js`` module. It loads an old pane build in a
real Chrome profile, switches the server to a same-version replacement whose
pane has the same byte length and mtime, then performs a normal browser reload
without clearing or disabling the HTTP cache.
The old fixture advertises ``user_turn=0&tool_turn=0`` in its EventSource URL;
the current source advertises both capabilities as ``1``. A passing run
therefore proves both layers of the contract:
* the package-versioned entry URL revalidates and may return 304; and
* its unversioned transitive pane import revalidates by content and returns the
current bytes rather than surviving from the prior build.
The page also loads representative KaTeX, Highlight.js, and HLS.js assets.
Their installed version directories are discovered from ``shared_static`` at
runtime, so a normal vendor-version bump requires no harness edit; reload must
reuse them under the immutable policy.
Usage::
uv run python scripts/asset_cache_e2e.py
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import socket
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from recovery_e2e import CDP, _find_chrome, _launch_chrome, _page_ws_url # noqa: E402
from turnstone import __version__ # noqa: E402
from turnstone.core.web_helpers import ( # noqa: E402
RevalidatingStaticFiles,
version_html,
)
_PAGE_HTML = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>ASSET-CACHE-PENDING</title>
<!-- VENDORED_ASSET_TAGS -->
<script>
window.__assetCacheUrls = [];
class RecordingEventSource {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
constructor(url) {
this.url = String(url);
this.readyState = RecordingEventSource.CONNECTING;
window.__assetCacheUrls.push(this.url);
}
close() {
this.readyState = RecordingEventSource.CLOSED;
}
}
window.EventSource = RecordingEventSource;
window.addEventListener("error", (event) => {
document.title = "ASSET-CACHE-FAILED-" + String(event.message || "script").slice(0, 80);
});
window.addEventListener("unhandledrejection", (event) => {
document.title = "ASSET-CACHE-FAILED-" + String(event.reason || "promise").slice(0, 80);
});
</script>
</head>
<body>
<main id="pane"></main>
<script type="module" src="/static/asset_cache_boot.js"
onerror="document.title='ASSET-CACHE-FAILED-module-load'"></script>
</body>
</html>
"""
_BOOT_JS = """import { InteractivePane } from "/shared/interactive.js";
const fixtureWorkstreamId = "00000000-0000-0000-0000-000000000001";
const pane = new InteractivePane(fixtureWorkstreamId, { base: "" });
document.getElementById("pane").appendChild(pane.el);
pane.connectSSE(fixtureWorkstreamId);
const url = window.__assetCacheUrls.at(-1) || "";
const generation = url.includes("user_turn=1") && url.includes("tool_turn=1")
? "CURRENT"
: url.includes("user_turn=0") && url.includes("tool_turn=0")
? "OLD"
: "FAILED-CAPABILITIES";
window.__assetCacheResult = { generation, url };
document.title = "ASSET-CACHE-" + generation;
"""
@dataclass
class CacheState:
phase: str = "old"
requests: list[dict[str, Any]] = field(default_factory=list)
lock: threading.Lock = field(default_factory=threading.Lock)
def record(self, item: dict[str, Any]) -> None:
with self.lock:
self.requests.append(item)
def matching(self, phase: str, path: str) -> list[dict[str, Any]]:
with self.lock:
return [
item for item in self.requests if item["phase"] == phase and item["path"] == path
]
class SwitchingStaticFiles:
"""Select one immutable build snapshot at request dispatch time."""
def __init__(self, state: CacheState, old_dir: Path, current_dir: Path) -> None:
self._state = state
self._apps = {
"old": RevalidatingStaticFiles(directory=str(old_dir)),
"current": RevalidatingStaticFiles(directory=str(current_dir)),
}
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
await self._apps[self._state.phase](scope, receive, send)
class RecordingApp:
"""Record static HTTP validators and status without perturbing streaming."""
def __init__(self, app: Any, state: CacheState) -> None:
self._app = app
self._state = state
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
path = str(scope.get("path", ""))
if scope.get("type") != "http" or not path.startswith(("/static/", "/shared/")):
await self._app(scope, receive, send)
return
phase = self._state.phase
request_headers = {
key.decode("latin-1").lower(): value.decode("latin-1")
for key, value in scope.get("headers", [])
}
async def record_send(message: dict[str, Any]) -> None:
if message["type"] == "http.response.start":
response_headers = {
key.decode("latin-1").lower(): value.decode("latin-1")
for key, value in message.get("headers", [])
}
self._state.record(
{
"phase": phase,
"path": path,
"query": scope.get("query_string", b"").decode("latin-1"),
"if_none_match": request_headers.get("if-none-match"),
"status": message["status"],
"etag": response_headers.get("etag"),
"cache_control": response_headers.get("cache-control"),
}
)
await send(message)
await self._app(scope, receive, record_send)
def _old_interactive(current: bytes) -> bytes:
old = current
for needle, replacement in (
(b'"user_turn=1"', b'"user_turn=0"'),
(b'"&tool_turn=1"', b'"&tool_turn=0"'),
):
if old.count(needle) != 1:
raise RuntimeError(f"expected exactly one {needle.decode()} capability literal")
old = old.replace(needle, replacement, 1)
if len(old) != len(current):
raise AssertionError("old and current pane fixtures must have equal byte length")
return old
def _discover_vendor_assets(source_shared: Path) -> tuple[str, ...]:
selected = (
("katex", "katex.min.css"),
("katex", "katex.min.js"),
("hljs", "highlight.min.js"),
("hls", "hls.min.js"),
)
paths = []
for library, filename in selected:
matches = sorted(source_shared.glob(f"{library}-*/{filename}"))
if not matches:
raise RuntimeError(f"no vendored {library} asset named {filename} was found")
paths.extend(f"/shared/{match.relative_to(source_shared).as_posix()}" for match in matches)
return tuple(paths)
def _vendor_tags(vendor_paths: tuple[str, ...]) -> str:
tags = []
for path in vendor_paths:
if path.endswith(".css"):
tags.append(f'<link rel="stylesheet" href="{path}">')
else:
tags.append(f'<script src="{path}"></script>')
return "\n ".join(tags)
def _prepare_builds(scratch: Path) -> tuple[Path, Path, Path, tuple[str, ...]]:
import turnstone
package_dir = Path(turnstone.__file__).resolve().parent
source_shared = package_dir / "shared_static"
vendor_paths = _discover_vendor_assets(source_shared)
old_shared = scratch / "old" / "shared"
current_shared = scratch / "current" / "shared"
static_dir = scratch / "static"
shutil.copytree(source_shared, old_shared)
shutil.copytree(source_shared, current_shared)
static_dir.mkdir()
(static_dir / "asset_cache_boot.js").write_text(_BOOT_JS, encoding="utf-8")
current_asset = current_shared / "interactive.js"
old_asset = old_shared / "interactive.js"
current = current_asset.read_bytes()
old_asset.write_bytes(_old_interactive(current))
# Reproduce the metadata collision which defeated Starlette's default ETag.
fixed_mtime_ns = 1_700_000_000_123_456_789
for asset in (old_asset, current_asset):
os.utime(asset, ns=(fixed_mtime_ns, fixed_mtime_ns))
old_stat = old_asset.stat()
current_stat = current_asset.stat()
if (old_stat.st_size, old_stat.st_mtime_ns) != (
current_stat.st_size,
current_stat.st_mtime_ns,
):
raise AssertionError("pane fixture size/mtime collision was not preserved")
return old_shared, current_shared, static_dir, vendor_paths
def _make_app(
state: CacheState,
old_dir: Path,
current_dir: Path,
static_dir: Path,
vendor_paths: tuple[str, ...],
) -> Any:
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Mount, Route
async def page(_request: Any) -> HTMLResponse:
page_html = _PAGE_HTML.replace("<!-- VENDORED_ASSET_TAGS -->", _vendor_tags(vendor_paths))
return HTMLResponse(
version_html(page_html),
headers={"Cache-Control": "no-store"},
)
app = Starlette(
routes=[
Route("/asset-cache-e2e", page),
Mount(
"/static",
app=RevalidatingStaticFiles(directory=str(static_dir)),
),
Mount(
"/shared",
app=SwitchingStaticFiles(state, old_dir, current_dir),
),
]
)
return RecordingApp(app, state)
def _start_server(app: Any) -> tuple[Any, threading.Thread, socket.socket, str]:
import uvicorn
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen(128)
port = int(sock.getsockname()[1])
server = uvicorn.Server(
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="off")
)
thread = threading.Thread(
target=server.run,
kwargs={"sockets": [sock]},
name="asset-cache-e2e-server",
daemon=True,
)
thread.start()
deadline = time.monotonic() + 10
while not server.started and thread.is_alive() and time.monotonic() < deadline:
time.sleep(0.05)
if not server.started:
server.should_exit = True
thread.join(timeout=2)
sock.close()
raise RuntimeError("asset cache test server did not start")
return server, thread, sock, f"http://127.0.0.1:{port}"
def _wait_for_generation(cdp: CDP, expected: str, timeout: float = 20) -> dict[str, str]:
deadline = time.monotonic() + timeout
last_title = ""
while time.monotonic() < deadline:
last_title = cdp.title()
result = cdp.evaluate("window.__assetCacheResult || null")
if isinstance(result, dict) and result.get("generation") == expected:
return {"generation": str(result["generation"]), "url": str(result["url"])}
if last_title.startswith("ASSET-CACHE-FAILED"):
raise RuntimeError(last_title)
time.sleep(0.1)
raise TimeoutError(f"expected {expected}, last title was {last_title!r}")
def _one(state: CacheState, phase: str, path: str) -> dict[str, Any]:
requests = state.matching(phase, path)
if len(requests) != 1:
raise AssertionError(f"expected one {phase} request for {path}, got {requests!r}")
return requests[0]
def _verify_trace(state: CacheState, vendor_paths: tuple[str, ...]) -> tuple[str, list[str]]:
entry_path = "/static/asset_cache_boot.js"
pane_path = "/shared/interactive.js"
old_entry = _one(state, "old", entry_path)
current_entry = _one(state, "current", entry_path)
old_pane = _one(state, "old", pane_path)
current_pane = _one(state, "current", pane_path)
expected_query = f"v={__version__}"
if old_entry["query"] != expected_query or current_entry["query"] != expected_query:
raise AssertionError("entry URL did not retain the same package version across builds")
if old_entry["status"] != 200 or old_pane["status"] != 200:
raise AssertionError("old build did not populate the browser cache")
if current_entry["status"] != 304 or not current_entry["if_none_match"]:
raise AssertionError(f"versioned entry did not revalidate to 304: {current_entry!r}")
if current_pane["status"] != 200 or not current_pane["if_none_match"]:
raise AssertionError(
f"transitive pane did not revalidate to current bytes: {current_pane!r}"
)
if old_pane["etag"] == current_pane["etag"]:
raise AssertionError("content-derived pane validators did not change")
if current_pane["cache_control"] != "no-cache":
raise AssertionError("transitive pane lost its revalidation policy")
vendor_trace = []
immutable = "public, max-age=31536000, immutable"
for path in vendor_paths:
old_vendor = _one(state, "old", path)
if old_vendor["query"] or old_vendor["status"] != 200:
raise AssertionError(f"versioned vendor URL was rewritten or failed: {old_vendor!r}")
if old_vendor["cache_control"] != immutable:
raise AssertionError(f"versioned vendor asset was not immutable: {old_vendor!r}")
revisits = state.matching("current", path)
if revisits:
if len(revisits) != 1 or revisits[0]["status"] not in (200, 304):
raise AssertionError(f"unexpected vendor reload trace: {revisits!r}")
if revisits[0]["cache_control"] != immutable:
raise AssertionError(f"vendor reload lost immutable policy: {revisits[0]!r}")
vendor_trace.append(f"{path}: revisited-{revisits[0]['status']}")
else:
vendor_trace.append(f"{path}: cache-hit")
verdict = f"ASSET-CACHE-READY-entry304-pane200-user1-tool1-vendor{len(vendor_paths)}"
return verdict, vendor_trace
def _stop_process(proc: Any) -> None:
if proc.poll() is not None:
return
proc.terminate()
with contextlib.suppress(Exception):
proc.wait(timeout=5)
if proc.poll() is None:
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=2)
def main() -> int:
chrome = _find_chrome()
if not chrome:
print("ASSET-CACHE-FAILED-no-chrome")
return 2
with tempfile.TemporaryDirectory(prefix="turnstone-asset-cache-e2e-") as raw_scratch:
scratch = Path(raw_scratch)
old_dir, current_dir, static_dir, vendor_paths = _prepare_builds(scratch)
state = CacheState()
server, server_thread, sock, base_url = _start_server(
_make_app(state, old_dir, current_dir, static_dir, vendor_paths)
)
chrome_proc = None
cdp = None
try:
chrome_proc, cdp_port = _launch_chrome(chrome, scratch / "chrome-profile")
cdp = CDP(_page_ws_url(cdp_port))
cdp.cmd("Page.enable")
cdp.cmd("Runtime.enable")
cdp.cmd("Network.enable")
cdp.cmd("Page.navigate", {"url": f"{base_url}/asset-cache-e2e"})
old_result = _wait_for_generation(cdp, "OLD")
state.phase = "current"
cdp.cmd("Page.reload", {"ignoreCache": False})
current_result = _wait_for_generation(cdp, "CURRENT")
if "user_turn=0" not in old_result["url"] or "tool_turn=0" not in old_result["url"]:
raise AssertionError(f"old pane did not expose old capabilities: {old_result!r}")
if (
"user_turn=1" not in current_result["url"]
or "tool_turn=1" not in current_result["url"]
):
raise AssertionError(
f"reloaded pane did not expose current capabilities: {current_result!r}"
)
verdict, vendor_trace = _verify_trace(state, vendor_paths)
cdp.evaluate(f"document.title = {json.dumps(verdict)}")
print(verdict)
print(f" old EventSource: {old_result['url']}")
print(f" current EventSource: {current_result['url']}")
for item in vendor_trace:
print(f" vendor: {item}")
return 0
except Exception as exc:
print(f"ASSET-CACHE-FAILED-{type(exc).__name__}: {exc}")
return 1
finally:
if cdp is not None:
cdp.close()
if chrome_proc is not None:
_stop_process(chrome_proc)
server.should_exit = True
server_thread.join(timeout=10)
with contextlib.suppress(OSError):
sock.close()
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -185,8 +185,8 @@ esac
echo "" echo ""
echo "NOTE: If you added a NEW library (not just updating a version), also update" echo "NOTE: If you added a NEW library (not just updating a version), also update"
echo " the _ASSET_RE regex in turnstone/core/web_helpers.py — its negative lookahead" echo " _VERSIONED_VENDOR_DIR in turnstone/core/web_helpers.py — it controls both"
echo " skips vendored directories to avoid double-versioning static asset URLs." echo " HTML version rewriting and immutable static-response caching."
echo "" echo ""
echo "Verify the update:" echo "Verify the update:"
echo " git diff --stat" echo " git diff --stat"
+4
View File
@@ -91,6 +91,8 @@ class TestServerVersioning:
def test_shared_static_unversioned(self, client): def test_shared_static_unversioned(self, client):
resp = client.get("/shared/base.css") resp = client.get("/shared/base.css")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers["cache-control"] == "no-cache"
assert resp.headers["etag"]
class TestConsoleVersioning: class TestConsoleVersioning:
@@ -147,3 +149,5 @@ class TestConsoleVersioning:
resp = client.get("/static/app.js") resp = client.get("/static/app.js")
body = resp.text body = resp.text
assert "/v1/api/cluster" in body assert "/v1/api/cluster" in body
assert resp.headers["cache-control"] == "no-cache"
assert resp.headers["etag"]
+163
View File
@@ -1587,6 +1587,168 @@ class TestConsoleProxy:
mock_collector.get_node_detail.return_value = None mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/static/app.js") resp = client.get("/node/unknown/static/app.js")
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.headers["cache-control"] == "no-store"
@pytest.mark.parametrize("mount", ["static", "shared"])
@pytest.mark.parametrize(
"suffix",
[
"%2e%2e/%2e%2e/v1/api/workstreams/private/history",
"%2E%2E/%2E%2E/v1/api/workstreams/private/history",
"%2e%2e%2f%2e%2e%2fv1%2fapi%2fworkstreams",
"%5c..%5c..%5cv1%5capi%5cworkstreams",
],
)
def test_proxy_static_rejects_encoded_traversal_before_upstream(self, mount, suffix):
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console import server as csrv
handler = csrv.proxy_static if mount == "static" else csrv.proxy_shared_static
app = Starlette(
routes=[Route(f"/node/{{node_id}}/{mount}/{{path:path}}", endpoint=handler)]
)
app.state.proxy_client = MagicMock()
with TestClient(app) as client:
resp = client.get(f"/node/node-a/{mount}/katex-0.18.4/{suffix}")
assert resp.status_code == 400
assert resp.headers["cache-control"] == "no-store"
app.state.proxy_client.get.assert_not_called()
def test_proxy_static_percent_encodes_each_valid_path_segment(self, monkeypatch):
from types import SimpleNamespace
import httpx
from turnstone.console import server as csrv
upstream = httpx.Response(
200,
content=b"asset",
request=httpx.Request("GET", "http://n:1/static/nested/asset"),
)
calls = []
async def _mock_get(url, *, headers):
calls.append((url, headers))
return upstream
proxy_client = MagicMock(spec=httpx.AsyncClient)
proxy_client.get = MagicMock(side_effect=_mock_get)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)),
path_params={"node_id": "node-a", "path": "nested/asset name?#.js"},
headers={},
)
monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda request: {})
monkeypatch.setattr(csrv, "_get_server_url", lambda request, node_id: "http://n:1")
resp = asyncio.run(csrv.proxy_static(request))
assert calls == [("http://n:1/static/nested/asset%20name%3F%23.js", {})]
assert resp.status_code == 200
assert resp.headers["cache-control"] == "no-cache"
@pytest.mark.parametrize(
("handler_name", "mount", "path"),
[
("proxy_static", "static", "app.js"),
("proxy_shared_static", "shared", "interactive.js"),
],
)
def test_proxy_static_forwards_conditional_request_and_validators(
self, monkeypatch, handler_name, mount, path
):
from types import SimpleNamespace
import httpx
from turnstone.console import server as csrv
upstream = httpx.Response(
304,
headers={
"etag": '"current-build"',
"last-modified": "Wed, 12 Aug 2026 12:00:00 GMT",
},
request=httpx.Request("GET", f"http://n:1/{mount}/{path}"),
)
calls = []
async def _mock_get(url, *, headers):
calls.append((url, headers))
return upstream
proxy_client = MagicMock(spec=httpx.AsyncClient)
proxy_client.get = MagicMock(side_effect=_mock_get)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)),
path_params={"node_id": "node-a", "path": path},
headers={"if-none-match": '"current-build"'},
)
monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda request: {"X-Auth": "test"})
monkeypatch.setattr(csrv, "_get_server_url", lambda request, node_id: "http://n:1")
resp = asyncio.run(getattr(csrv, handler_name)(request))
assert calls == [
(
f"http://n:1/{mount}/{path}",
{"X-Auth": "test", "if-none-match": '"current-build"'},
)
]
assert resp.status_code == 304
assert resp.headers["cache-control"] == "no-cache"
assert resp.headers["etag"] == '"current-build"'
assert resp.headers["last-modified"] == "Wed, 12 Aug 2026 12:00:00 GMT"
@pytest.mark.parametrize("status_code", [404, 500])
def test_proxy_vendor_static_error_is_never_immutable(self, status_code):
import httpx
from turnstone.console.server import _proxy_static_response
upstream = httpx.Response(
status_code,
content=b"transient failure",
request=httpx.Request("GET", "http://n:1/shared/katex-0.18.4/missing.css"),
)
resp = _proxy_static_response(upstream, "katex-0.18.4/missing.css")
assert resp.status_code == status_code
assert resp.headers["cache-control"] == "no-store"
@pytest.mark.parametrize(
("upstream_policy", "expected"),
[
("no-store", "no-store"),
("private, max-age=600", "private, max-age=600"),
("public, max-age=0, must-revalidate", "public, max-age=0, must-revalidate"),
("public, max-age=60", "public, max-age=60"),
],
)
def test_proxy_vendor_static_preserves_stricter_upstream_policy(
self, upstream_policy, expected
):
import httpx
from turnstone.console.server import _proxy_static_response
upstream = httpx.Response(
200,
content=b"asset",
headers={"cache-control": upstream_policy},
request=httpx.Request("GET", "http://n:1/shared/katex-0.18.4/katex.js"),
)
resp = _proxy_static_response(upstream, "katex-0.18.4/katex.js")
assert resp.headers["cache-control"] == expected
def test_proxy_api_unknown_node_returns_404(self, client, mock_collector): def test_proxy_api_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None mock_collector.get_node_detail.return_value = None
@@ -2447,6 +2609,7 @@ class TestProxySharedStatic:
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS) client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
resp = client.get("/node/unknown/shared/base.css") resp = client.get("/node/unknown/shared/base.css")
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.headers["cache-control"] == "no-store"
client.close() client.close()
+23 -3
View File
@@ -26,6 +26,8 @@ def client():
def test_valid_ws_id_injects_data_attr(client): def test_valid_ws_id_injects_data_attr(client):
from turnstone import __version__
ws_id = "a" * 32 ws_id = "a" * 32
resp = client.get(f"/coordinator/{ws_id}") resp = client.get(f"/coordinator/{ws_id}")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -35,9 +37,27 @@ def test_valid_ws_id_injects_data_attr(client):
assert f'data-ws-id="{ws_id}"' in body assert f'data-ws-id="{ws_id}"' in body
# Template placeholder is fully substituted. # Template placeholder is fully substituted.
assert "{{WS_ID}}" not in body assert "{{WS_ID}}" not in body
# Sanity: the shared static imports are wired. # First-party tags are versioned; version-named vendor assets stay stable.
assert "/shared/base.css" in body assert f"/shared/base.css?v={__version__}" in body
assert "/static/coordinator/coordinator.js" in body assert f"/static/coordinator/coordinator.css?v={__version__}" in body
assert "/shared/katex-0.18.4/katex.min.css?v=" not in body
# Inline module imports are outside version_html's src/href boundary. The
# static route's no-cache policy makes this URL revalidate on every reload.
assert 'from "/static/coordinator/coordinator.js"' in body
def test_coordinator_page_revalidates_with_etag(client):
ws_id = "b" * 32
first = client.get(f"/coordinator/{ws_id}")
assert first.headers["cache-control"] == "no-cache"
assert first.headers["etag"]
unchanged = client.get(
f"/coordinator/{ws_id}", headers={"If-None-Match": first.headers["etag"]}
)
assert unchanged.status_code == 304
assert unchanged.headers["cache-control"] == "no-cache"
assert unchanged.headers["etag"] == first.headers["etag"]
def test_non_hex_ws_id_returns_400(client): def test_non_hex_ws_id_returns_400(client):
+145
View File
@@ -2,6 +2,10 @@
from __future__ import annotations from __future__ import annotations
import os
import pytest
class TestVersionHtml: class TestVersionHtml:
def test_app_css_gets_version(self): def test_app_css_gets_version(self):
@@ -54,6 +58,13 @@ class TestVersionHtml:
result = version_html(html) result = version_html(html)
assert result == html # unchanged assert result == html # unchanged
def test_vendor_prefix_lookalike_is_still_versioned(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/hls-2-player.js"></script>'
result = version_html(html)
assert "/shared/hls-2-player.js?v=" in result
def test_external_urls_not_modified(self): def test_external_urls_not_modified(self):
from turnstone.core.web_helpers import version_html from turnstone.core.web_helpers import version_html
@@ -116,6 +127,140 @@ class TestVersionHtml:
assert result == html # unchanged — already has query string assert result == html # unchanged — already has query string
class TestRevalidatingStaticFiles:
def test_same_versioned_url_revalidates_after_asset_changes(self, tmp_path):
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
from turnstone import __version__
from turnstone.core.web_helpers import RevalidatingStaticFiles
asset = tmp_path / "app.js"
old_body = b"export const generation = 'old';"
new_body = b"export const generation = 'new';"
assert len(old_body) == len(new_body)
asset.write_bytes(old_body)
original_stat = asset.stat()
app = Starlette(
routes=[Mount("/static", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
)
with TestClient(app) as client:
url = f"/static/app.js?v={__version__}"
first = client.get(url)
assert first.status_code == 200
assert first.headers["cache-control"] == "no-cache"
assert "last-modified" not in first.headers
old_etag = first.headers["etag"]
asset.write_bytes(new_body)
os.utime(
asset,
ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns),
)
changed_stat = asset.stat()
assert changed_stat.st_size == original_stat.st_size
assert changed_stat.st_mtime_ns == original_stat.st_mtime_ns
changed = client.get(url, headers={"If-None-Match": old_etag})
assert changed.status_code == 200
assert changed.content == new_body
assert changed.headers["etag"] != old_etag
assert changed.headers["cache-control"] == "no-cache"
assert "last-modified" not in changed.headers
stale_date_only = client.get(
url,
headers={"If-Modified-Since": "Wed, 31 Dec 9999 23:59:59 GMT"},
)
assert stale_date_only.status_code == 200
assert stale_date_only.content == new_body
unchanged = client.get(url, headers={"If-None-Match": changed.headers["etag"]})
assert unchanged.status_code == 304
assert unchanged.headers["cache-control"] == "no-cache"
def test_version_named_vendor_asset_is_immutable(self, tmp_path):
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
from turnstone.core.web_helpers import RevalidatingStaticFiles
vendor_dir = tmp_path / "katex-0.18.4"
vendor_dir.mkdir()
(vendor_dir / "katex.min.css").write_text(".katex {}", encoding="utf-8")
app = Starlette(
routes=[Mount("/shared", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
)
with TestClient(app) as client:
resp = client.get("/shared/katex-0.18.4/katex.min.css")
assert resp.status_code == 200
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
assert resp.headers["etag"]
def test_missing_asset_is_not_cached(self, tmp_path):
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
from turnstone.core.web_helpers import RevalidatingStaticFiles
app = Starlette(
routes=[Mount("/shared", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
)
with TestClient(app) as client:
resp = client.get("/shared/not-deployed-yet.js")
assert resp.status_code == 404
assert resp.headers["cache-control"] == "no-store"
class TestStaticAssetCacheControl:
@pytest.mark.parametrize(
("path", "expected"),
[
("interactive.js", "no-cache"),
("hls-2-player.js", "no-cache"),
("katex-0.18.4/katex.min.css", "public, max-age=31536000, immutable"),
("hljs-11.11.1/highlight.min.js", "public, max-age=31536000, immutable"),
("katex-0.18.4/../private.json", "no-store"),
(r"katex-0.18.4\..\private.json", "no-store"),
("nested//asset.js", "no-store"),
],
)
def test_policy_requires_a_canonical_exact_vendor_path(self, path, expected):
from turnstone.core.web_helpers import static_asset_cache_control
assert static_asset_cache_control(path) == expected
def test_every_packaged_versioned_vendor_directory_uses_the_shared_policy(self):
import re
from pathlib import Path
import turnstone
from turnstone.core.web_helpers import static_asset_cache_control, version_html
shared_dir = Path(turnstone.__file__).resolve().parent / "shared_static"
versioned_dir = re.compile(r"^[a-z][a-z0-9_-]*-\d+(?:\.\d+)+$")
vendor_dirs = sorted(
path.name
for path in shared_dir.iterdir()
if path.is_dir() and versioned_dir.fullmatch(path.name)
)
assert vendor_dirs
for directory in vendor_dirs:
asset_path = f"{directory}/asset.js"
assert static_asset_cache_control(asset_path) == "public, max-age=31536000, immutable"
html = f'<script src="/shared/{asset_path}"></script>'
assert version_html(html) == html
class TestLatin1SafeFilename: class TestLatin1SafeFilename:
"""Content-Disposition filename sanitizer — must yield a value that is """Content-Disposition filename sanitizer — must yield a value that is
both latin-1 encodable (Starlette) and control-char free (h11).""" both latin-1 encodable (Starlette) and control-char free (h11)."""
+67 -41
View File
@@ -14,6 +14,7 @@ import argparse
import asyncio import asyncio
import contextlib import contextlib
import functools import functools
import hashlib
import json import json
import logging import logging
import math import math
@@ -38,7 +39,6 @@ from starlette.background import BackgroundTask
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from turnstone.api.console_spec import build_console_spec from turnstone.api.console_spec import build_console_spec
from turnstone.api.docs import make_docs_handler, make_openapi_handler from turnstone.api.docs import make_docs_handler, make_openapi_handler
@@ -110,8 +110,12 @@ from turnstone.core.skill_field_validation import SKILL_RUNTIME_CONFIG_FIELDS
from turnstone.core.skill_kind import SkillKind from turnstone.core.skill_kind import SkillKind
from turnstone.core.skill_parser import MAX_SKILL_DESCRIPTION_LEN from turnstone.core.skill_parser import MAX_SKILL_DESCRIPTION_LEN
from turnstone.core.web_helpers import ( from turnstone.core.web_helpers import (
RevalidatingStaticFiles,
is_safe_static_asset_path,
read_json_or_400, read_json_or_400,
require_storage_or_503, require_storage_or_503,
static_asset_cache_control,
version_html,
) )
from turnstone.core.workstream import ( from turnstone.core.workstream import (
Workstream, Workstream,
@@ -147,10 +151,6 @@ _HTML_ETAG = ""
def _load_static() -> None: def _load_static() -> None:
import hashlib
from turnstone.core.web_helpers import version_html
global _HTML, _HTML_ETAG global _HTML, _HTML_ETAG
_HTML = version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8")) _HTML = version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8"))
_HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324 _HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324
@@ -3032,52 +3032,66 @@ async def proxy_index(request: Request) -> Response:
return JSONResponse({"error": "Node unreachable"}, status_code=502) return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def proxy_static(request: Request) -> Response: def _proxy_static_request_headers(request: Request) -> dict[str, str]:
"""GET /node/{node_id}/static/{path} — proxy static files.""" """Build upstream headers for a cache-aware static asset request."""
headers = _proxy_auth_headers(request)
for name in ("if-none-match", "if-modified-since"):
value = request.headers.get(name)
if value:
headers[name] = value
return headers
def _proxy_static_response(resp: httpx.Response, path: str) -> Response:
"""Preserve upstream validators and apply the local static cache policy."""
cache_control = "no-store"
if resp.status_code in (200, 304):
cache_control = resp.headers.get("cache-control") or static_asset_cache_control(path)
headers = {"Cache-Control": cache_control}
for name in ("content-type", "etag", "last-modified"):
value = resp.headers.get(name)
if value:
headers[name] = value
return Response(content=resp.content, status_code=resp.status_code, headers=headers)
def _static_proxy_error(message: str, status_code: int) -> JSONResponse:
return JSONResponse(
{"error": message}, status_code=status_code, headers={"Cache-Control": "no-store"}
)
async def _proxy_static_mount(request: Request, mount: str) -> Response:
"""Proxy one validated static mount without URL-normalization ambiguity."""
node_id = request.path_params["node_id"] node_id = request.path_params["node_id"]
path = request.path_params["path"] path = request.path_params["path"]
if not is_safe_static_asset_path(path):
return _static_proxy_error("Invalid static asset path", 400)
server_url = _get_server_url(request, node_id) server_url = _get_server_url(request, node_id)
if not server_url: if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404) return _static_proxy_error("Node not found", 404)
encoded_path = "/".join(urllib.parse.quote(segment, safe="") for segment in path.split("/"))
client: httpx.AsyncClient = request.app.state.proxy_client client: httpx.AsyncClient = request.app.state.proxy_client
try: try:
resp = await client.get( resp = await client.get(
f"{server_url}/static/{path}", f"{server_url}/{mount}/{encoded_path}",
headers=_proxy_auth_headers(request), headers=_proxy_static_request_headers(request),
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/octet-stream"),
) )
return _proxy_static_response(resp, path)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
log.debug("Proxy static error for %s/%s: %s", node_id, path, exc) log.debug("Proxy %s error for %s/%s: %s", mount, node_id, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502) return _static_proxy_error("Node unreachable", 502)
async def proxy_static(request: Request) -> Response:
"""GET /node/{node_id}/static/{path} — proxy static files."""
return await _proxy_static_mount(request, "static")
async def proxy_shared_static(request: Request) -> Response: async def proxy_shared_static(request: Request) -> Response:
"""GET /node/{node_id}/shared/{path} — proxy shared static files.""" """GET /node/{node_id}/shared/{path} — proxy shared static files."""
node_id = request.path_params["node_id"] return await _proxy_static_mount(request, "shared")
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
try:
resp = await client.get(
f"{server_url}/shared/{path}",
headers=_proxy_auth_headers(request),
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/octet-stream"),
)
except httpx.HTTPError as exc:
log.debug("Proxy shared static error for %s/%s: %s", node_id, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
# Auth endpoints the console handles locally instead of forwarding to # Auth endpoints the console handles locally instead of forwarding to
@@ -3965,14 +3979,18 @@ async def coordinator_page(request: Request) -> Response:
if not template_path.is_file(): if not template_path.is_file():
return JSONResponse({"error": "coordinator UI template missing"}, status_code=500) return JSONResponse({"error": "coordinator UI template missing"}, status_code=500)
try: try:
body = template_path.read_text(encoding="utf-8") body = version_html(template_path.read_text(encoding="utf-8"))
except OSError: except OSError:
return JSONResponse({"error": "failed to read coordinator UI template"}, status_code=500) return JSONResponse({"error": "failed to read coordinator UI template"}, status_code=500)
# Inject the ws_id as an HTML attribute. ws_id passed the # Inject the ws_id as an HTML attribute. ws_id passed the
# ``_VALID_WS_ID_RE`` gate above (hex only) so there's nothing # ``_VALID_WS_ID_RE`` gate above (hex only) so there's nothing
# to HTML-escape; leave the replacement simple. # to HTML-escape; leave the replacement simple.
body = body.replace("{{WS_ID}}", ws_id) body = body.replace("{{WS_ID}}", ws_id)
return Response(body, media_type="text/html; charset=utf-8") etag = '"' + hashlib.md5(body.encode()).hexdigest()[:16] + '"' # noqa: S324
headers = {"Cache-Control": "no-cache", "ETag": etag}
if request.headers.get("If-None-Match") == etag:
return Response(status_code=304, headers=headers)
return HTMLResponse(body, headers=headers)
_CHILDREN_PAGE_LIMIT = 200 _CHILDREN_PAGE_LIMIT = 200
@@ -16471,8 +16489,16 @@ def create_app(
Route("/metrics", console_metrics_endpoint), Route("/metrics", console_metrics_endpoint),
Route("/openapi.json", _openapi_handler), Route("/openapi.json", _openapi_handler),
Route("/docs", _docs_handler), Route("/docs", _docs_handler),
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"), Mount(
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"), "/static",
app=RevalidatingStaticFiles(directory=str(_STATIC_DIR)),
name="static",
),
Mount(
"/shared",
app=RevalidatingStaticFiles(directory=str(_SHARED_DIR)),
name="shared",
),
# Coordinator one-pane UI — the route serves a single # Coordinator one-pane UI — the route serves a single
# index.html template with the ws_id injected via data-ws-id # index.html template with the ws_id injected via data-ws-id
# so coordinator.js can pull it without an extra round-trip. # so coordinator.js can pull it without an extra round-trip.
+107 -2
View File
@@ -3,15 +3,23 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import hashlib
import json import json
import os import os
import re import re
from functools import cached_property
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from starlette.datastructures import Headers
from starlette.exceptions import HTTPException
from starlette.responses import FileResponse, PlainTextResponse, Response
from starlette.staticfiles import StaticFiles
if TYPE_CHECKING: if TYPE_CHECKING:
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import Scope
def latin1_safe_filename(name: str, *, fallback: str = "attachment") -> str: def latin1_safe_filename(name: str, *, fallback: str = "attachment") -> str:
@@ -463,6 +471,103 @@ def cors_middleware(origins: list[str]) -> Middleware:
# Static asset cache-busting # Static asset cache-busting
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Keep this directory definition shared by the HTML rewriter and static response
# policy. These directories already carry their library version in the URL,
# so their contents can be cached immutably; every other first-party asset must
# revalidate because ES-module imports do not inherit the entry module's ?v=.
_VERSIONED_VENDOR_DIR = r"(?:katex|hljs|hls|mermaid)-\d+(?:\.\d+)+"
_VERSIONED_VENDOR_PATH_RE = re.compile(rf"^{_VERSIONED_VENDOR_DIR}/")
def is_safe_static_asset_path(path: str) -> bool:
"""Return whether *path* is a canonical mount-relative asset path.
Starlette decodes percent escapes before populating ``{path:path}``, and
HTTPX normalizes dot segments when it builds an outbound URL. Reject the
ambiguous forms before a console proxy can leave its static mount.
"""
return (
bool(path)
and "\\" not in path
and all(segment not in {"", ".", ".."} for segment in path.split("/"))
)
def static_asset_cache_control(path: str) -> str:
"""Return the cache policy for an asset path relative to its mount."""
if not is_safe_static_asset_path(path):
return "no-store"
if _VERSIONED_VENDOR_PATH_RE.match(path):
return "public, max-age=31536000, immutable"
return "no-cache"
class RevalidatingStaticFiles(StaticFiles):
"""StaticFiles with correctness-first caching for first-party assets.
First-party files use a content-derived ETag and no Last-Modified header.
``no-cache`` therefore revalidates by content on reload, even when two
builds share a package version, byte length, and filesystem timestamp.
Version-named vendor directories retain immutable caching.
"""
@cached_property
def _content_etags(self) -> dict[str, tuple[tuple[int, int, int], str]]:
"""Lazily allocate the bounded per-static-tree validator cache."""
return {}
def _content_etag(
self,
full_path: str | os.PathLike[str],
stat_result: os.stat_result,
) -> str:
path = os.fspath(full_path)
signature = (stat_result.st_mtime_ns, stat_result.st_ctime_ns, stat_result.st_size)
cached = self._content_etags.get(path)
if cached is not None and cached[0] == signature:
return cached[1]
with open(path, "rb") as asset:
digest = hashlib.file_digest(asset, "sha256").hexdigest()
etag = f'"sha256-{digest}"'
self._content_etags[path] = (signature, etag)
return etag
def file_response(
self,
full_path: str | os.PathLike[str],
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
asset_path = self.get_path(scope)
if static_asset_cache_control(asset_path) != "no-cache":
return super().file_response(full_path, stat_result, scope, status_code)
request_headers = Headers(scope=scope)
response = FileResponse(full_path, status_code=status_code, stat_result=stat_result)
etag = self._content_etag(full_path, stat_result)
response.headers["ETag"] = etag
del response.headers["Last-Modified"]
if self.is_not_modified(response.headers, request_headers):
return Response(status_code=304, headers={"ETag": etag})
return response
async def get_response(self, path: str, scope: Scope) -> Response:
try:
response = await super().get_response(path, scope)
except HTTPException as exc:
if exc.status_code != 404:
raise
return PlainTextResponse(
"Not Found", status_code=404, headers={"Cache-Control": "no-store"}
)
cache_control = (
"no-store" if response.status_code >= 400 else static_asset_cache_control(path)
)
response.headers["Cache-Control"] = cache_control
return response
# Matches src="/static/..." and href="/shared/..." (and vice-versa) but skips # Matches src="/static/..." and href="/shared/..." (and vice-versa) but skips
# vendored libraries whose directory names already contain a version number # vendored libraries whose directory names already contain a version number
# (e.g. katex-0.16.44/, hljs-11.11.1/) and URLs that already have a query # (e.g. katex-0.16.44/, hljs-11.11.1/) and URLs that already have a query
@@ -470,7 +575,7 @@ def cors_middleware(origins: list[str]) -> Middleware:
_ASSET_RE = re.compile( _ASSET_RE = re.compile(
r'(?P<attr>(?:src|href)=")' r'(?P<attr>(?:src|href)=")'
r"(?P<path>/(?:static|shared)/)" r"(?P<path>/(?:static|shared)/)"
r"(?!(?:katex|hljs|hls|mermaid)-\d)" rf"(?!{_VERSIONED_VENDOR_DIR}/)"
r'(?P<file>[^"?]+)"' r'(?P<file>[^"?]+)"'
) )
@@ -480,7 +585,7 @@ def version_html(html: str) -> str:
Vendored libraries with version-bearing directory names are skipped. Vendored libraries with version-bearing directory names are skipped.
URLs that already contain a query string are left unchanged. URLs that already contain a query string are left unchanged.
Called once at startup when loading HTML into memory. Safe to call either at startup or while rendering a dynamic template.
""" """
from turnstone import __version__ from turnstone import __version__
+11 -3
View File
@@ -42,7 +42,6 @@ from starlette.middleware import Middleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from turnstone import __version__ from turnstone import __version__
from turnstone.api.docs import make_docs_handler, make_openapi_handler from turnstone.api.docs import make_docs_handler, make_openapi_handler
@@ -114,6 +113,7 @@ from turnstone.core.session_ui_base import (
) )
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
from turnstone.core.trajectory import final_assistant_text from turnstone.core.trajectory import final_assistant_text
from turnstone.core.web_helpers import RevalidatingStaticFiles
from turnstone.core.web_helpers import version_html as _version_html from turnstone.core.web_helpers import version_html as _version_html
from turnstone.core.workstream import ( from turnstone.core.workstream import (
Workstream, Workstream,
@@ -5802,8 +5802,16 @@ def create_app(
Route("/metrics", metrics_endpoint), Route("/metrics", metrics_endpoint),
Route("/openapi.json", _openapi_handler), Route("/openapi.json", _openapi_handler),
Route("/docs", _docs_handler), Route("/docs", _docs_handler),
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"), Mount(
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"), "/static",
app=RevalidatingStaticFiles(directory=str(_STATIC_DIR)),
name="static",
),
Mount(
"/shared",
app=RevalidatingStaticFiles(directory=str(_SHARED_DIR)),
name="shared",
),
], ],
middleware=_build_middleware(cors_origins), middleware=_build_middleware(cors_origins),
lifespan=_lifespan, lifespan=_lifespan,