Add cache-busted static asset URLs
This commit is contained in:
parent
99fd33f770
commit
df68aa95e9
2 changed files with 78 additions and 1 deletions
39
repub/web.py
39
repub/web.py
|
|
@ -90,6 +90,9 @@ DEFAULT_PANGEA_CONTENT_FORMAT = "MOBILE_3"
|
||||||
DEFAULT_PANGEA_CONTENT_TYPE = "articles"
|
DEFAULT_PANGEA_CONTENT_TYPE = "articles"
|
||||||
DEFAULT_PANGEA_MAX_ARTICLES = "10"
|
DEFAULT_PANGEA_MAX_ARTICLES = "10"
|
||||||
DEFAULT_PANGEA_OLDEST_ARTICLE = "3"
|
DEFAULT_PANGEA_OLDEST_ARTICLE = "3"
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
|
CACHE_BUSTED_STATIC_ASSETS = frozenset({"app.css"})
|
||||||
|
CACHE_BUSTED_HASH_LENGTH = 12
|
||||||
|
|
||||||
|
|
||||||
def _render_shim_page(
|
def _render_shim_page(
|
||||||
|
|
@ -106,6 +109,24 @@ def _render_shim_page(
|
||||||
return body, etag
|
return body, etag
|
||||||
|
|
||||||
|
|
||||||
|
def versioned_static_asset_filename(filename: str) -> str:
|
||||||
|
_require_cache_busted_static_asset(filename)
|
||||||
|
asset_path = STATIC_DIR / filename
|
||||||
|
truncated_hash = hashlib.sha256(asset_path.read_bytes()).hexdigest()[
|
||||||
|
:CACHE_BUSTED_HASH_LENGTH
|
||||||
|
]
|
||||||
|
return f"{asset_path.stem}-{truncated_hash}{asset_path.suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def versioned_static_asset_href(filename: str) -> str:
|
||||||
|
return f"/static/{versioned_static_asset_filename(filename)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_cache_busted_static_asset(filename: str) -> None:
|
||||||
|
if filename not in CACHE_BUSTED_STATIC_ASSETS:
|
||||||
|
raise ValueError(f"Unsupported cache-busted static asset: {filename}")
|
||||||
|
|
||||||
|
|
||||||
def create_app(*, dev_mode: bool = False) -> Quart:
|
def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
app.config["REPUB_DB_PATH"] = str(initialize_database())
|
app.config["REPUB_DB_PATH"] = str(initialize_database())
|
||||||
|
|
@ -127,6 +148,22 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
response.mimetype = "application/rss+xml"
|
response.mimetype = "application/rss+xml"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.get("/static/<string:asset_name>-<string:asset_hash>.<string:extension>")
|
||||||
|
async def versioned_static_asset(
|
||||||
|
asset_name: str, asset_hash: str, extension: str
|
||||||
|
) -> Response:
|
||||||
|
logical_filename = f"{asset_name}.{extension}"
|
||||||
|
requested_filename = f"{asset_name}-{asset_hash}.{extension}"
|
||||||
|
if logical_filename in CACHE_BUSTED_STATIC_ASSETS:
|
||||||
|
response = await send_from_directory(str(STATIC_DIR), logical_filename)
|
||||||
|
response.cache_control.public = True
|
||||||
|
response.cache_control.max_age = 31536000
|
||||||
|
response.cache_control.immutable = True
|
||||||
|
return response
|
||||||
|
|
||||||
|
response = await send_from_directory(str(STATIC_DIR), requested_filename)
|
||||||
|
return response
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@app.get("/sources")
|
@app.get("/sources")
|
||||||
@app.get("/sources/create")
|
@app.get("/sources/create")
|
||||||
|
|
@ -141,7 +178,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
) -> Response:
|
) -> Response:
|
||||||
del slug, job_id, execution_id
|
del slug, job_id, execution_id
|
||||||
body, etag = _render_shim_page(
|
body, etag = _render_shim_page(
|
||||||
stylesheet_href=url_for("static", filename="app.css"),
|
stylesheet_href=versioned_static_asset_href("app.css"),
|
||||||
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
|
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
|
||||||
current_path=request.path,
|
current_path=request.path,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from repub.web import (
|
||||||
render_runs,
|
render_runs,
|
||||||
render_settings,
|
render_settings,
|
||||||
render_sources,
|
render_sources,
|
||||||
|
versioned_static_asset_href,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -177,10 +178,12 @@ def test_root_get_serves_datastar_shim() -> None:
|
||||||
|
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
body = await response.get_data(as_text=True)
|
body = await response.get_data(as_text=True)
|
||||||
|
stylesheet_href = versioned_static_asset_href("app.css")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["ETag"]
|
assert response.headers["ETag"]
|
||||||
assert body.startswith("<!doctype html>")
|
assert body.startswith("<!doctype html>")
|
||||||
|
assert f'<link rel="stylesheet" href="{stylesheet_href}">' in body
|
||||||
assert (
|
assert (
|
||||||
'<script id="js" defer type="module" src="/static/datastar@1.0.0-RC.8.js"></script>'
|
'<script id="js" defer type="module" src="/static/datastar@1.0.0-RC.8.js"></script>'
|
||||||
in body
|
in body
|
||||||
|
|
@ -200,6 +203,43 @@ def test_root_get_serves_datastar_shim() -> None:
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_versioned_static_asset_href_uses_truncated_file_hash() -> None:
|
||||||
|
href = versioned_static_asset_href("app.css")
|
||||||
|
|
||||||
|
assert re.fullmatch(r"/static/app-[0-9a-f]{12}\.css", href)
|
||||||
|
|
||||||
|
|
||||||
|
def test_versioned_static_asset_route_serves_registered_css_file() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
expected = (
|
||||||
|
Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
response = await client.get("/static/app-deadbeefcafe.css")
|
||||||
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == "text/css"
|
||||||
|
assert body == expected
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_versioned_static_asset_route_preserves_existing_hyphenated_files() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
client = create_app().test_client()
|
||||||
|
|
||||||
|
response = await client.get("/static/datastar@1.0.0-RC.8.js")
|
||||||
|
body = await response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == "text/javascript"
|
||||||
|
assert body.startswith("// Datastar v1.0.0-RC.8")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_create_app_bootstraps_default_database_path(
|
def test_create_app_bootstraps_default_database_path(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue