Add cache-busted static asset helper (#2804)

* Add cache-busted static asset helper

Add a static() helper for Datasette, plugin, and mounted static assets that appends content-based hashes, caches hashes in production, and serves matching hashed asset URLs with immutable far-future cache headers.

Closes #2800
This commit is contained in:
Simon Willison 2026-06-23 13:44:58 -07:00 • committed by GitHub
commit 5eca46a4bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 361 additions and 65 deletions

View file

@ -4,6 +4,7 @@ from datasette.utils import allowed_pragmas
from .fixtures import make_app_client
from .utils import assert_footer_links, inner_html
import copy
import hashlib
import json
import pathlib
import pytest
@ -83,7 +84,7 @@ async def test_homepage_options(ds_client):
async def test_favicon(ds_client):
response = await ds_client.get("/favicon.ico")
assert response.status_code == 200
assert response.headers["cache-control"] == "max-age=3600, immutable, public"
assert response.headers["cache-control"] == "max-age=3600, public"
assert int(response.headers["content-length"]) > 100
assert response.headers["content-type"] == "image/png"
@ -101,6 +102,24 @@ async def test_static(ds_client):
assert response.status_code == 304
@pytest.mark.asyncio
async def test_static_hash_cache_control(ds_client):
hashed_url = ds_client.ds.static("app.css")
response = await ds_client.get(hashed_url)
assert response.status_code == 200
assert response.headers["cache-control"] == "max-age=31536000, immutable, public"
response = await ds_client.get(
hashed_url, headers={"if-none-match": response.headers["etag"]}
)
assert response.status_code == 304
assert response.headers["cache-control"] == "max-age=31536000, immutable, public"
response = await ds_client.get("/-/static/app.css?_hash=incorrect")
assert response.status_code == 200
assert "cache-control" not in response.headers
def test_static_mounts():
with make_app_client(
static_mounts=[("custom-static", str(pathlib.Path(__file__).parent))]
@ -113,6 +132,23 @@ def test_static_mounts():
assert response.status_code == 404
def test_static_mounts_hash_cache_control():
mount_path = pathlib.Path(__file__).parent
with make_app_client(static_mounts=[("custom-static", str(mount_path))]) as client:
response = client.get(client.ds.static("test_html.py", mount="custom-static"))
assert response.status_code == 200
assert (
response.headers["cache-control"] == "max-age=31536000, immutable, public"
)
incorrect_hash = hashlib.sha256(b"incorrect").hexdigest()[:12]
response = client.get(
"/custom-static/test_html.py?_hash={}".format(incorrect_hash)
)
assert response.status_code == 200
assert "cache-control" not in response.headers
def test_memory_database_page():
with make_app_client(memory=True) as client:
response = client.get("/_memory")
@ -609,7 +645,7 @@ async def test_404(ds_client, path):
response = await ds_client.get(path)
assert response.status_code == 404
assert (
f'<link rel="stylesheet" href="/-/static/app.css?{ds_client.ds.app_css_hash()}'
'<link rel="stylesheet" href="{}"'.format(ds_client.ds.static("app.css"))
in response.text
)
@ -1082,7 +1118,9 @@ async def test_navigation_menu_links(
navigation_search_script = soup.find(
"script", {"src": re.compile(r"navigation-search\.js")}
)
assert navigation_search_script["src"] == "/-/static/navigation-search.js"
assert navigation_search_script["src"] == ds_client.ds.static(
"navigation-search.js"
)
assert details.find("li").find("button") == search_button
if not actor_id:
# The app menu is always visible, but anonymous users do not see logout

View file

@ -4,6 +4,8 @@ Tests for the datasette.app.Datasette class
import asyncio
import dataclasses
import hashlib
import importlib
import os
import sqlite3
import time
@ -11,6 +13,7 @@ from datasette import Context
from datasette.app import Datasette, Database, ResourcesSQL
from datasette.database import DatasetteClosedError
from datasette.resources import DatabaseResource
from datasette.utils import PrefixedUrlString
from itsdangerous import BadSignature
import pytest
@ -56,6 +59,111 @@ def test_datasette_setting(datasette, setting, expected):
assert datasette.setting(setting) == expected
def _setup_static_app_root(tmp_path, monkeypatch, filename, content):
app_module = importlib.import_module("datasette.app")
app_root = tmp_path / "app-root"
static_path = app_root / "datasette" / "static"
static_path.mkdir(parents=True)
asset_path = static_path / filename
asset_path.write_bytes(content)
monkeypatch.setattr(app_module, "app_root", app_root)
return asset_path
@pytest.mark.asyncio
async def test_static_template_function_hashes_core_asset(tmp_path, monkeypatch):
_setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"const demo = true;")
ds = Datasette()
template = ds.get_jinja_environment().from_string("{{ static('demo.js') }}")
expected_hash = hashlib.sha256(b"const demo = true;").hexdigest()[:12]
assert await template.render_async() == "/-/static/demo.js?_hash={}".format(
expected_hash
)
assert isinstance(ds.static("demo.js"), PrefixedUrlString)
def test_static_hash_cached_when_cache_headers_enabled(tmp_path, monkeypatch):
asset_path = _setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"let a = 1;")
ds = Datasette(cache_headers=True)
first_url = ds.static("demo.js")
asset_path.write_bytes(b"let a = 2;")
assert ds.static("demo.js") == first_url
def test_static_hash_recalculated_when_cache_headers_disabled(tmp_path, monkeypatch):
asset_path = _setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"let a = 1;")
ds = Datasette(cache_headers=False)
first_url = ds.static("demo.js")
asset_path.write_bytes(b"let a = 2;")
expected_hash = hashlib.sha256(b"let a = 2;").hexdigest()[:12]
assert ds.static("demo.js") == "/-/static/demo.js?_hash={}".format(expected_hash)
assert ds.static("demo.js") != first_url
def test_static_hashes_mounted_static_file(tmp_path):
static_path = tmp_path / "static-files"
static_path.mkdir()
asset_path = static_path / "styles.css"
asset_path.write_bytes(b"body { color: black; }")
ds = Datasette(static_mounts=[("assets", str(static_path))])
expected_hash = hashlib.sha256(b"body { color: black; }").hexdigest()[:12]
assert ds.static("styles.css", mount="assets") == (
"/assets/styles.css?_hash={}".format(expected_hash)
)
ds._settings["base_url"] = "/prefix/"
assert ds.static("styles.css", mount="assets") == (
"/prefix/assets/styles.css?_hash={}".format(expected_hash)
)
def test_static_hashes_plugin_static_file(tmp_path, monkeypatch):
plugin_static_path = tmp_path / "plugin-static"
plugin_static_path.mkdir()
asset_path = plugin_static_path / "plugin.js"
asset_path.write_bytes(b"console.log('plugin');")
app_module = importlib.import_module("datasette.app")
monkeypatch.setattr(
app_module,
"get_plugins",
lambda: [
{
"name": "datasette-cluster-map",
"static_path": str(plugin_static_path),
"templates_path": None,
}
],
)
ds = Datasette()
expected_hash = hashlib.sha256(b"console.log('plugin');").hexdigest()[:12]
assert ds.static("plugin.js", plugin="datasette_cluster_map") == (
"/-/static-plugins/datasette_cluster_map/plugin.js?_hash={}".format(
expected_hash
)
)
def test_static_rejects_plugin_and_mount():
ds = Datasette()
with pytest.raises(ValueError):
ds.static("styles.css", plugin="datasette_cluster_map", mount="assets")
def test_static_rejects_path_traversal(tmp_path, monkeypatch):
_setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"")
ds = Datasette()
with pytest.raises(ValueError, match="cannot escape static root"):
ds.static("../secret.js")
@pytest.mark.asyncio
async def test_datasette_constructor():
ds = Datasette()

View file

@ -11,6 +11,7 @@ from datasette.utils.sqlite import (
sqlite_table_type,
supports_returning,
)
import hashlib
import json
import os
import pathlib
@ -835,6 +836,12 @@ def test_pairs_to_nested_config(pairs, expected):
assert actual == expected
def test_sha256_file(tmp_path):
path = tmp_path / "test.txt"
path.write_text("hello")
assert utils.sha256_file(path, chunk_size=2) == hashlib.sha256(b"hello").hexdigest()
@pytest.mark.asyncio
async def test_calculate_etag(tmp_path):
path = tmp_path / "test.txt"