Serve static assets with Granian and add YAML-configurable logging

Granian serves the compiled SPA assets directly in Rust: hashed js/wasm/css
under /static (the release build uses --public-url /static/) and the card
images under /assets, configured with the GRANIAN_STATIC_PATH_ROUTE/MOUNT/
DIR_TO_FILE env vars in the Dockerfile. The Python catch-all now only serves
the SPA shell (index.html) at / and for client-side routes.

Every module logs through getLogger(__name__): lifecycle and business events
at INFO, per-move and store detail at DEBUG. The built-in default writes
DEBUG to the console; LOGGING_CONFIG points at a YAML file in the
logging.config.dictConfig schema to take over the configuration. PyYAML
becomes a direct dependency.
This commit is contained in:
2026-09-17 08:26:45 +08:00
parent f6239d2637
commit 876b4abd8b
17 changed files with 336 additions and 79 deletions
+6 -21
View File
@@ -34,15 +34,13 @@ class MeRouteTest(unittest.TestCase):
class StaticRouteTest(unittest.TestCase):
"""The app only serves the SPA shell; asset files under /static are
served by Granian and are not reachable through the ASGI transport."""
@async_test
async def test_serves_files_and_spa_fallback(self) -> None:
async def test_serves_shell_and_spa_fallback(self) -> None:
with tempfile.TemporaryDirectory() as dist:
root = Path(dist)
(root / "index.html").write_text("<html>spa</html>")
(root / "app.js").write_text("console.log(1)")
cards = root / "assets" / "cards"
cards.mkdir(parents=True)
(cards / "07D.svg").write_text("<svg/>")
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.routes.static.settings", patched):
@@ -50,27 +48,14 @@ class StaticRouteTest(unittest.TestCase):
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
index = await client.get("/")
self.assertEqual(200, index.status_code)
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
self.assertIn(b"spa", index.content)
js = await client.get("/app.js")
self.assertEqual(200, js.status_code)
self.assertEqual("text/javascript; charset=utf-8", js.headers["content-type"])
svg = await client.get("/assets/cards/07D.svg")
self.assertEqual(200, svg.status_code)
self.assertEqual("image/svg+xml", svg.headers["content-type"])
# Unknown client-side route falls back to the app shell.
fallback = await client.get("/game/some-id")
self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content)
# Traversal attempts never escape the dist directory.
traversal = await client.get("/..%2F..%2Fetc%2Fpasswd")
self.assertIn(traversal.status_code, (200, 404))
if traversal.status_code == 200:
self.assertIn(b"spa", traversal.content)
@async_test
async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")