Skip to content

Commit 7be5f6a

Browse files
Merge pull request #115 from ipnet-mesh/chore/http-caching
Add HTTP caching for web dashboard resources
2 parents 96ca619 + 54695ab commit 7be5f6a

File tree

5 files changed

+313
-3
lines changed

5 files changed

+313
-3
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dev = [
5252
"flake8>=6.1.0",
5353
"mypy>=1.5.0",
5454
"pre-commit>=3.4.0",
55+
"beautifulsoup4>=4.12.0",
5556
"types-paho-mqtt>=1.6.0",
5657
"types-PyYAML>=6.0.0",
5758
]

src/meshcore_hub/web/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from meshcore_hub import __version__
1919
from meshcore_hub.common.i18n import load_locale, t
2020
from meshcore_hub.common.schemas import RadioConfig
21+
from meshcore_hub.web.middleware import CacheControlMiddleware
2122
from meshcore_hub.web.pages import PageLoader
2223

2324
logger = logging.getLogger(__name__)
@@ -176,6 +177,9 @@ def create_app(
176177
# Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection
177178
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
178179

180+
# Add cache control headers based on resource type
181+
app.add_middleware(CacheControlMiddleware)
182+
179183
# Load i18n translations
180184
app.state.web_locale = settings.web_locale or "en"
181185
load_locale(app.state.web_locale)

src/meshcore_hub/web/middleware.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""HTTP caching middleware for the web component."""
2+
3+
from collections.abc import Awaitable, Callable
4+
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
from starlette.requests import Request
7+
from starlette.responses import Response
8+
from starlette.types import ASGIApp
9+
10+
11+
class CacheControlMiddleware(BaseHTTPMiddleware):
12+
"""Middleware to set appropriate Cache-Control headers based on resource type."""
13+
14+
def __init__(self, app: ASGIApp) -> None:
15+
"""Initialize the middleware.
16+
17+
Args:
18+
app: The ASGI application to wrap.
19+
"""
20+
super().__init__(app)
21+
22+
async def dispatch(
23+
self,
24+
request: Request,
25+
call_next: Callable[[Request], Awaitable[Response]],
26+
) -> Response:
27+
"""Process the request and add appropriate caching headers.
28+
29+
Args:
30+
request: The incoming HTTP request.
31+
call_next: The next middleware or route handler.
32+
33+
Returns:
34+
The response with cache headers added.
35+
"""
36+
response: Response = await call_next(request)
37+
38+
# Skip if Cache-Control already set (explicit override)
39+
if "cache-control" in response.headers:
40+
return response
41+
42+
path = request.url.path
43+
query_params = request.url.query
44+
45+
# Health endpoints - never cache
46+
if path.startswith("/health"):
47+
response.headers["cache-control"] = "no-cache, no-store, must-revalidate"
48+
49+
# Static files with version parameter - long-term cache
50+
elif path.startswith("/static/") and "v=" in query_params:
51+
response.headers["cache-control"] = "public, max-age=31536000, immutable"
52+
53+
# Static files without version - short cache as fallback
54+
elif path.startswith("/static/"):
55+
response.headers["cache-control"] = "public, max-age=3600"
56+
57+
# Media files with version parameter - long-term cache
58+
elif path.startswith("/media/") and "v=" in query_params:
59+
response.headers["cache-control"] = "public, max-age=31536000, immutable"
60+
61+
# Media files without version - short cache (user may update)
62+
elif path.startswith("/media/"):
63+
response.headers["cache-control"] = "public, max-age=3600"
64+
65+
# Map data - short cache (5 minutes)
66+
elif path == "/map/data":
67+
response.headers["cache-control"] = "public, max-age=300"
68+
69+
# Custom pages - moderate cache (1 hour)
70+
elif path.startswith("/spa/pages/"):
71+
response.headers["cache-control"] = "public, max-age=3600"
72+
73+
# SEO files - moderate cache (1 hour)
74+
elif path in ("/robots.txt", "/sitemap.xml"):
75+
response.headers["cache-control"] = "public, max-age=3600"
76+
77+
# API proxy - don't add headers (pass through backend)
78+
elif path.startswith("/api/"):
79+
pass
80+
81+
# SPA shell HTML (catch-all for client-side routes) - no cache
82+
elif response.headers.get("content-type", "").startswith("text/html"):
83+
response.headers["cache-control"] = "no-cache, public"
84+
85+
return response

src/meshcore_hub/web/templates/spa.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
4040

4141
<!-- Custom application styles -->
42-
<link rel="stylesheet" href="/static/css/app.css">
42+
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
4343

4444
<!-- Import map for ES module dependencies -->
4545
<script type="importmap">
@@ -175,7 +175,7 @@
175175
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
176176

177177
<!-- Chart helper functions -->
178-
<script src="/static/js/charts.js"></script>
178+
<script src="/static/js/charts.js?v={{ version }}"></script>
179179

180180
<!-- Embedded app configuration -->
181181
<script>
@@ -199,6 +199,6 @@
199199
</script>
200200

201201
<!-- SPA Application (ES Module) -->
202-
<script type="module" src="/static/js/spa/app.js"></script>
202+
<script type="module" src="/static/js/spa/app.js?v={{ version }}"></script>
203203
</body>
204204
</html>

tests/test_web/test_caching.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Tests for HTTP caching middleware and version parameters."""
2+
3+
from bs4 import BeautifulSoup
4+
5+
from meshcore_hub import __version__
6+
7+
8+
class TestCacheControlHeaders:
9+
"""Test Cache-Control headers are correctly set for different resource types."""
10+
11+
def test_static_css_with_version(self, client):
12+
"""Static CSS with version parameter should have long-term cache."""
13+
response = client.get(f"/static/css/app.css?v={__version__}")
14+
assert response.status_code == 200
15+
assert "cache-control" in response.headers
16+
assert (
17+
response.headers["cache-control"] == "public, max-age=31536000, immutable"
18+
)
19+
20+
def test_static_js_with_version(self, client):
21+
"""Static JS with version parameter should have long-term cache."""
22+
response = client.get(f"/static/js/charts.js?v={__version__}")
23+
assert response.status_code == 200
24+
assert "cache-control" in response.headers
25+
assert (
26+
response.headers["cache-control"] == "public, max-age=31536000, immutable"
27+
)
28+
29+
def test_static_module_with_version(self, client):
30+
"""Static ES module with version parameter should have long-term cache."""
31+
response = client.get(f"/static/js/spa/app.js?v={__version__}")
32+
assert response.status_code == 200
33+
assert "cache-control" in response.headers
34+
assert (
35+
response.headers["cache-control"] == "public, max-age=31536000, immutable"
36+
)
37+
38+
def test_static_css_without_version(self, client):
39+
"""Static CSS without version should have short fallback cache."""
40+
response = client.get("/static/css/app.css")
41+
assert response.status_code == 200
42+
assert "cache-control" in response.headers
43+
assert response.headers["cache-control"] == "public, max-age=3600"
44+
45+
def test_static_js_without_version(self, client):
46+
"""Static JS without version should have short fallback cache."""
47+
response = client.get("/static/js/charts.js")
48+
assert response.status_code == 200
49+
assert "cache-control" in response.headers
50+
assert response.headers["cache-control"] == "public, max-age=3600"
51+
52+
def test_spa_shell_html(self, client):
53+
"""SPA shell HTML should not be cached."""
54+
response = client.get("/")
55+
assert response.status_code == 200
56+
assert "cache-control" in response.headers
57+
assert response.headers["cache-control"] == "no-cache, public"
58+
59+
def test_spa_route_html(self, client):
60+
"""Client-side route should not be cached."""
61+
response = client.get("/dashboard")
62+
assert response.status_code == 200
63+
assert "cache-control" in response.headers
64+
assert response.headers["cache-control"] == "no-cache, public"
65+
66+
def test_map_data_endpoint(self, client, mock_http_client):
67+
"""Map data endpoint should have short cache (5 minutes)."""
68+
# Mock the API response for map data
69+
mock_http_client.set_response(
70+
"GET",
71+
"/api/v1/nodes/map",
72+
200,
73+
{"nodes": []},
74+
)
75+
76+
response = client.get("/map/data")
77+
assert response.status_code == 200
78+
assert "cache-control" in response.headers
79+
assert response.headers["cache-control"] == "public, max-age=300"
80+
81+
def test_health_endpoint(self, client):
82+
"""Health endpoint should never be cached."""
83+
response = client.get("/health")
84+
assert response.status_code == 200
85+
assert "cache-control" in response.headers
86+
assert (
87+
response.headers["cache-control"] == "no-cache, no-store, must-revalidate"
88+
)
89+
90+
def test_healthz_endpoint(self, client):
91+
"""Healthz endpoint should never be cached."""
92+
response = client.get("/healthz")
93+
assert response.status_code == 200
94+
assert "cache-control" in response.headers
95+
assert (
96+
response.headers["cache-control"] == "no-cache, no-store, must-revalidate"
97+
)
98+
99+
def test_robots_txt(self, client):
100+
"""Robots.txt should have moderate cache (1 hour)."""
101+
response = client.get("/robots.txt")
102+
assert response.status_code == 200
103+
assert "cache-control" in response.headers
104+
assert response.headers["cache-control"] == "public, max-age=3600"
105+
106+
def test_sitemap_xml(self, client):
107+
"""Sitemap.xml should have moderate cache (1 hour)."""
108+
response = client.get("/sitemap.xml")
109+
assert response.status_code == 200
110+
assert "cache-control" in response.headers
111+
assert response.headers["cache-control"] == "public, max-age=3600"
112+
113+
def test_api_proxy_no_cache_header_added(self, client, mock_http_client):
114+
"""API proxy should not add cache headers (lets backend control caching)."""
115+
# The mock client doesn't add cache-control headers by default
116+
# Middleware should not add any either for /api/* paths
117+
response = client.get("/api/v1/nodes")
118+
assert response.status_code == 200
119+
# Cache-control should either not be present, or be from the backend
120+
# Since our mock doesn't add it, middleware shouldn't add it either
121+
# (In production, backend would set its own cache-control)
122+
123+
124+
class TestVersionParameterInHTML:
125+
"""Test that version parameters are correctly added to static file references."""
126+
127+
def test_css_link_has_version(self, client):
128+
"""CSS link should include version parameter."""
129+
response = client.get("/")
130+
assert response.status_code == 200
131+
132+
soup = BeautifulSoup(response.text, "html.parser")
133+
css_link = soup.find(
134+
"link", {"href": lambda x: x and "/static/css/app.css" in x}
135+
)
136+
137+
assert css_link is not None
138+
assert f"?v={__version__}" in css_link["href"]
139+
140+
def test_charts_js_has_version(self, client):
141+
"""Charts.js script should include version parameter."""
142+
response = client.get("/")
143+
assert response.status_code == 200
144+
145+
soup = BeautifulSoup(response.text, "html.parser")
146+
charts_script = soup.find(
147+
"script", {"src": lambda x: x and "/static/js/charts.js" in x}
148+
)
149+
150+
assert charts_script is not None
151+
assert f"?v={__version__}" in charts_script["src"]
152+
153+
def test_app_js_has_version(self, client):
154+
"""SPA app.js script should include version parameter."""
155+
response = client.get("/")
156+
assert response.status_code == 200
157+
158+
soup = BeautifulSoup(response.text, "html.parser")
159+
app_script = soup.find(
160+
"script", {"src": lambda x: x and "/static/js/spa/app.js" in x}
161+
)
162+
163+
assert app_script is not None
164+
assert f"?v={__version__}" in app_script["src"]
165+
166+
def test_cdn_resources_unchanged(self, client):
167+
"""CDN resources should not have version parameters."""
168+
response = client.get("/")
169+
assert response.status_code == 200
170+
171+
soup = BeautifulSoup(response.text, "html.parser")
172+
173+
# Check external CDN resources don't have our version param
174+
cdn_scripts = soup.find_all("script", {"src": lambda x: x and "cdn" in x})
175+
for script in cdn_scripts:
176+
assert f"?v={__version__}" not in script["src"]
177+
178+
cdn_links = soup.find_all("link", {"href": lambda x: x and "cdn" in x})
179+
for link in cdn_links:
180+
assert f"?v={__version__}" not in link["href"]
181+
182+
183+
class TestMediaFileCaching:
184+
"""Test caching behavior for custom media files."""
185+
186+
def test_media_file_with_version(self, client, tmp_path):
187+
"""Media files with version parameter should have long-term cache."""
188+
# Note: This test assumes media files are served via StaticFiles
189+
# In practice, you may need to create a test media file
190+
response = client.get(f"/media/test.png?v={__version__}")
191+
# May be 404 if no test media exists, but header should still be set
192+
if response.status_code == 200:
193+
assert "cache-control" in response.headers
194+
assert (
195+
response.headers["cache-control"]
196+
== "public, max-age=31536000, immutable"
197+
)
198+
199+
def test_media_file_without_version(self, client):
200+
"""Media files without version should have short cache."""
201+
response = client.get("/media/test.png")
202+
# May be 404 if no test media exists, but header should still be set
203+
if response.status_code == 200:
204+
assert "cache-control" in response.headers
205+
assert response.headers["cache-control"] == "public, max-age=3600"
206+
207+
208+
class TestCustomPageCaching:
209+
"""Test caching behavior for custom markdown pages."""
210+
211+
def test_custom_page_cache(self, client):
212+
"""Custom pages should have moderate cache (1 hour)."""
213+
# Custom pages are served by the web app (not API proxy)
214+
# They use the PageLoader which reads from CONTENT_HOME
215+
# For this test, we'll check that a 404 still gets cache headers
216+
# (In a real deployment with content files, this would return 200)
217+
response = client.get("/spa/pages/test")
218+
# May be 404 if no test page exists, but cache header should still be set
219+
assert "cache-control" in response.headers
220+
assert response.headers["cache-control"] == "public, max-age=3600"

0 commit comments

Comments
 (0)