Skip to content
149 changes: 146 additions & 3 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
"""

import asyncio
import gzip
import hmac
import importlib.util
import json
import logging
import os
import re
import secrets
import subprocess
import sys
Expand Down Expand Up @@ -3647,6 +3649,65 @@ def _normalise_prefix(raw: Optional[str]) -> str:
return p


# Compression level for gzip: 6 balances speed (higher is slower) vs ratio.
_GZIP_COMPRESS_LEVEL = 6
_GZIP_Q_RE = re.compile(r"(?:^|;)\s*q=([0-9]+(?:\.[0-9]+)?)(?:\s|;|$)")
_GZIP_MALFORMED_Q_RE = re.compile(r"(?:^|;)\s*q\b")


def _parse_q_value(params: str) -> float | None:
"""Extract a valid q-value from content-coding parameters.

Returns None when no q-value is present. A malformed q (e.g. ``q=``
or ``q=abc``) is treated as 0.0 so the encoding is rejected.
"""
m = _GZIP_Q_RE.search(params)
if m:
return float(m.group(1))
if _GZIP_MALFORMED_Q_RE.search(params):
return 0.0
return None


def _accepts_gzip_static(accept_encoding: str) -> bool:
"""Parse Accept-Encoding header and return True if gzip is accepted (q > 0).

Handles:
- Basic encodings: gzip, x-gzip (RFC 2616 alias)
- Quality values: gzip;q=0.5, gzip;q=0 (including spaced: gzip; q=0)
- Multiple parameters: gzip;q=0.5;ext=foo
- Wildcard with q-values: *;q=1, *;q=0 (RFC 7231 §5.3.4)
- Explicit gzip/x-gzip always overrides wildcard, regardless of order
- Case insensitive matching

Shared between serve_css and _OptimizedStaticFiles.
"""
if not accept_encoding:
return False
wildcard_q: float | None = None
for encoding in accept_encoding.split(","):
encoding = encoding.strip()
if not encoding:
continue
name, _, params = encoding.partition(";")
name = name.strip().lower()
q = _parse_q_value(params)
if name in ("gzip", "x-gzip"):
# Explicit coding: its q is decisive, even if a wildcard
# elsewhere in the header says otherwise.
if q is None:
return True
return q > 0.0
if name == "*":
if q is None:
wildcard_q = 1.0
else:
wildcard_q = q
if wildcard_q is not None:
return wildcard_q > 0.0
return False


def mount_spa(application: FastAPI):
"""Mount the built SPA. Falls back to index.html for client-side routing.

Expand Down Expand Up @@ -3721,9 +3782,91 @@ async def serve_css(filename: str, request: Request):
css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
return Response(content=css, media_type="text/css")

application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
# Apply gzip compression + cache headers for CSS assets
accept_encoding = request.headers.get("accept-encoding", "")
# Always vary on x-forwarded-prefix: CSS representation depends on this
# header regardless of whether it's present in the current request.
# Without this, a shared cache could serve a no-prefix cached variant
# to a prefixed deployment, breaking url() path rewriting.
vary_values = ["x-forwarded-prefix"]
css_bytes = css.encode("utf-8")
if _accepts_gzip_static(accept_encoding):
compressed = gzip.compress(css_bytes, compresslevel=_GZIP_COMPRESS_LEVEL)
if len(compressed) < len(css_bytes):
headers = {
"content-encoding": "gzip",
"vary": ", ".join(["accept-encoding"] + vary_values),
"content-length": str(len(compressed)),
"cache-control": "public, max-age=31536000, immutable",
}
if request.method == "HEAD":
return Response(headers=headers, media_type="text/css")
return Response(content=compressed, headers=headers, media_type="text/css")
# Fallback: uncompressed with cache header
headers = {
"cache-control": "public, max-age=31536000, immutable",
"vary": ", ".join(vary_values),
"content-length": str(len(css_bytes)),
}
if request.method == "HEAD":
return Response(headers=headers, media_type="text/css")
return Response(content=css, headers=headers, media_type="text/css")

# Serve gzip-compressed static files with long-term cache headers.
# Reduces bandwidth (1.5 MB -> 450 KB for main JS bundle) and allows
# browser caching since filenames contain content hashes.
class _OptimizedStaticFiles(StaticFiles):
def _accepts_gzip(self, accept_encoding: str) -> bool:
return _accepts_gzip_static(accept_encoding)

async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
if path.endswith(".js") or path.endswith(".css"):
headers = dict(scope.get("headers", []))
accept_encoding = headers.get(b"accept-encoding", b"").decode("latin-1", "replace")
# Add long-term cache header for hashed filenames (e.g. index-XXXX.js)
if "content-type" in response.headers:
response.headers["cache-control"] = "public, max-age=31536000, immutable"
# Gzip compress if client supports it (honors q=0)
if self._accepts_gzip(accept_encoding) and isinstance(response, FileResponse):
file_path = response.path
try:
file_size = os.path.getsize(file_path)
if file_size > 1024:
with open(file_path, "rb") as f:
content = f.read()
compressed = gzip.compress(content, compresslevel=_GZIP_COMPRESS_LEVEL)
if len(compressed) < len(content):
# Merge Vary header if already present
vary_values = ["accept-encoding"]
original_vary = response.headers.get("vary")
if original_vary:
vary_values.insert(0, original_vary)
# For HEAD requests, preserve original FileResponse semantics
# (no body) while still setting gzip-related headers
if scope.get("method") == "HEAD":
response.headers["content-encoding"] = "gzip"
response.headers["vary"] = ", ".join(vary_values)
response.headers["content-length"] = str(len(compressed))
return response
return Response(
content=compressed,
headers={
"content-type": response.headers.get("content-type", "application/octet-stream"),
"content-encoding": "gzip",
"vary": ", ".join(vary_values),
"content-length": str(len(compressed)),
"last-modified": response.headers.get("last-modified", ""),
"etag": response.headers.get("etag", ""),
"cache-control": "public, max-age=31536000, immutable",
},
)
except OSError:
# File removed or permission issue — fall back to uncompressed
pass
return response

application.mount("/assets", _OptimizedStaticFiles(directory=WEB_DIST / "assets"), name="assets")

@application.get("/{full_path:path}")
async def serve_spa(full_path: str, request: Request):
Expand Down
223 changes: 223 additions & 0 deletions tests/hermes_cli/test_web_static.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Tests for the dashboard static-file gzip/cache behaviour.

Covers:
- Accept-Encoding parser compliance (RFC 7231 §5.3.4)
- CSS endpoint gzip + prefix rewriting + Vary
- Hashed asset gzip via _OptimizedStaticFiles
"""

import gzip
from pathlib import Path

import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient

from hermes_cli.web_server import (
_accepts_gzip_static,
_GZIP_COMPRESS_LEVEL,
mount_spa,
)


# ---------------------------------------------------------------------------
# _accepts_gzip_static parser
# ---------------------------------------------------------------------------


class TestAcceptsGzipStatic:
"""Unit tests for the shared Accept-Encoding parser."""

@pytest.mark.parametrize(
"header, expected",
[
("", False),
("gzip", True),
("x-gzip", True),
("GZIP", True),
("X-GZip", True),
("deflate", False),
("gzip;q=0", False),
("gzip;q=0.0", False),
("gzip;q=0.5", True),
("gzip;q=1", True),
("gzip;q=1.0", True),
("gzip; q=0", False),
("deflate, gzip;q=0.5", True),
("*", True),
("*;q=0", False),
("*;q=1", True),
("*;q=0.5", True),
# Explicit gzip always overrides wildcard, regardless of order.
("*;q=0, gzip;q=1", True),
("gzip;q=0, *;q=1", False),
# Malformed q-values reject the encoding.
("gzip;q=abc", False),
("gzip;q", False),
("gzip;q=", False),
# Other encodings with wildcard but no explicit gzip follow wildcard q.
("*;q=0, deflate", False),
("deflate, *;q=0", False),
# Multi-parameter tokens.
("gzip;q=0.5;ext=foo", True),
("gzip;ext=foo;q=0", False),
],
)
def test_parser(self, header, expected):
assert _accepts_gzip_static(header) is expected


# ---------------------------------------------------------------------------
# mount_spa static endpoints
# ---------------------------------------------------------------------------


@pytest.fixture
def static_app(tmp_path, monkeypatch):
"""Build a FastAPI app with mount_spa pointed at a temp WEB_DIST."""
web_dist = tmp_path / "web_dist"
web_dist.mkdir()
(web_dist / "index.html").write_text("<!doctype html><html></html>")
assets = web_dist / "assets"
assets.mkdir()

monkeypatch.setattr("hermes_cli.web_server.WEB_DIST", web_dist)

app = FastAPI()
mount_spa(app)
return app, web_dist


class TestServeCss:
"""Tests for /assets/{filename}.css endpoint."""

def test_gzip_response_when_accepted(self, static_app):
app, web_dist = static_app
css = "body { background: red; }\n" + "/* padding */\n" * 50
(web_dist / "assets" / "theme.css").write_text(css)

client = TestClient(app)
resp = client.get("/assets/theme.css", headers={"accept-encoding": "gzip"})

assert resp.status_code == 200
assert resp.headers["content-encoding"] == "gzip"
assert resp.headers["content-type"].startswith("text/css")
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
assert "accept-encoding" in resp.headers["vary"]
assert "x-forwarded-prefix" in resp.headers["vary"]
assert int(resp.headers["content-length"]) < len(css.encode("utf-8"))
assert resp.text == css

def test_uncompressed_response_without_accept_encoding(self, static_app):
app, web_dist = static_app
css = "body { color: blue; }"
(web_dist / "assets" / "theme.css").write_text(css)

client = TestClient(app)
resp = client.get("/assets/theme.css")

assert resp.status_code == 200
assert "content-encoding" not in resp.headers
assert resp.text == css
assert int(resp.headers["content-length"]) == len(css.encode("utf-8"))
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"

def test_head_returns_no_body_with_content_length(self, static_app):
app, web_dist = static_app
css = "body { color: blue; }\n" + "/* pad */\n" * 200
(web_dist / "assets" / "theme.css").write_text(css)

client = TestClient(app)
resp = client.head("/assets/theme.css", headers={"accept-encoding": "gzip"})

assert resp.status_code == 200
assert resp.headers["content-encoding"] == "gzip"
assert int(resp.headers["content-length"]) > 0
assert resp.content == b""

def test_prefix_rewrites_absolute_urls(self, static_app):
app, web_dist = static_app
css = "@font-face { src: url(/fonts/foo.woff2); }\n" + "/* pad */\n" * 200
(web_dist / "assets" / "theme.css").write_text(css)

client = TestClient(app)
resp = client.get(
"/assets/theme.css",
headers={
"x-forwarded-prefix": "/hermes",
"accept-encoding": "gzip",
},
)

assert resp.status_code == 200
assert resp.headers["content-encoding"] == "gzip"
assert "url(/hermes/fonts/foo.woff2)" in resp.text
assert "x-forwarded-prefix" in resp.headers["vary"]

def test_404_for_missing_css(self, static_app):
app, _ = static_app
client = TestClient(app)
resp = client.get("/assets/missing.css")
assert resp.status_code == 404


class TestOptimizedStaticFiles:
"""Tests for _OptimizedStaticFiles hashed asset serving."""

def _make_large_js(self, assets_dir: Path, name: str) -> bytes:
"""Create a JS file large enough to trigger the >1024 byte gzip threshold."""
content = f"console.log('{name}');\n" + "// padding\n" * 200
data = content.encode("utf-8")
assets_dir.joinpath(name).write_bytes(data)
return data

def test_gzip_for_hashed_js(self, static_app):
app, web_dist = static_app
original = self._make_large_js(web_dist / "assets", "index-abc123.js")

client = TestClient(app)
resp = client.get("/assets/index-abc123.js", headers={"accept-encoding": "gzip"})

assert resp.status_code == 200
assert resp.headers["content-encoding"] == "gzip"
assert resp.headers["content-type"].startswith("text/javascript")
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
assert "accept-encoding" in resp.headers["vary"]
assert resp.content == original

def test_head_hashed_js(self, static_app):
app, web_dist = static_app
self._make_large_js(web_dist / "assets", "index-abc123.js")

client = TestClient(app)
resp = client.head("/assets/index-abc123.js", headers={"accept-encoding": "gzip"})

assert resp.status_code == 200
assert resp.headers["content-encoding"] == "gzip"
assert int(resp.headers["content-length"]) > 0
assert resp.content == b""

def test_no_gzip_when_client_rejects(self, static_app):
app, web_dist = static_app
original = self._make_large_js(web_dist / "assets", "index-abc123.js")

client = TestClient(app)
resp = client.get("/assets/index-abc123.js", headers={"accept-encoding": "gzip;q=0"})

assert resp.status_code == 200
assert "content-encoding" not in resp.headers
assert resp.content == original

def test_wildcard_override_explicit_gzip(self, static_app):
"""`*;q=0, gzip;q=1` must still gzip because explicit gzip wins."""
app, web_dist = static_app
self._make_large_js(web_dist / "assets", "index-abc123.js")

client = TestClient(app)
resp = client.get(
"/assets/index-abc123.js",
headers={"accept-encoding": "*;q=0, gzip;q=1"},
)

assert resp.status_code == 200
assert resp.headers["content-encoding"] == "gzip"