Make the frontend an installable PWA

Add a web app manifest and an install-only service worker (no offline
caching: live state always comes from the server and hashed asset names
change every build), registered from the WASM bundle. The worker must
live at the site root for its scope to cover the app, and Granian only
serves /static and /assets, so tavolo/static.py answers
/manifest.webmanifest and /sw.js from STATIC_DIR with the content types
browsers require; index.html gains the manifest link, icons, theme-color
and Apple standalone meta tags.

Icons are committed under web/assets/icons/ with vector sources and a
make-icons.sh regeneration script, mirroring the card art workflow.
This commit is contained in:
2026-10-14 22:14:43 +00:00
parent 532cb16f94
commit 8c6ad10bf6
17 changed files with 271 additions and 19 deletions
+3
View File
@@ -30,6 +30,9 @@ WORKDIR /web
# Card images are committed in the repository (CC0 woodcut napoletane deck);
# web/fetch-cards.sh can regenerate them.
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/theme.css web/Trunk.toml ./
# PWA manifest and service worker: trunk's copy-file entries need them at
# the build root, and the runtime serves both from the site root (static.py).
COPY web/manifest.webmanifest web/sw.js ./
COPY web/assets ./assets
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
# image builds and runs without network access.
+57 -14
View File
@@ -1,19 +1,27 @@
"""SPA shell hosting for the compiled single-page application.
Static assets (wasm, js, css, card images) are served by Granian itself
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
Dockerfile) and never reach Python. This module only serves ``index.html``
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes.
Static assets (wasm, js, css, card images, PWA icons) are served by
Granian itself under the ``/static`` and ``/assets`` prefixes
(``GRANIAN_STATIC_PATH_*`` env vars; see the Dockerfile) and never reach
Python. This module serves the few files that must live at the site root
from ``STATIC_DIR``:
kaya-openapi deliberately skips glob routes, so this handler never appears
in the API specification.
- ``index.html`` at the site root and — via the glob catch-all — for
every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes;
- the web app manifest, whose ``start_url``/``scope`` cover the app;
- the service worker, whose default scope is its own directory: served
from ``/static`` it could only control ``/static``, so it is served at
``/sw.js`` instead.
kaya-openapi deliberately skips glob routes, so the fallback handler
never appears in the API specification.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
from kaya.core import HttpContext
@@ -21,14 +29,30 @@ from .app import app
from .config import settings
async def _send_shell(ctx: HttpContext) -> None:
"""Serve the SPA shell, or 404 when the frontend build is missing."""
index = Path(settings.static_dir) / "index.html"
if not index.is_file():
async def _send_from_dist(ctx: HttpContext,
filename: str,
content_type: str,
cache_control: Optional[str] = None) -> None:
"""Serve one file from ``STATIC_DIR``, or 404 when the build lacks it.
Unlike the SPA fallback, a missing file is not silently replaced by
the app shell: a manifest or worker answer with the wrong content
type would only confuse the browser.
"""
file = Path(settings.static_dir) / filename
if not file.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(index.read_bytes)
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
body = await asyncio.to_thread(file.read_bytes)
headers = {"content-type": (content_type,)}
if cache_control is not None:
headers["cache-control"] = (cache_control,)
await ctx.send_bytes(200, body, headers)
async def _send_shell(ctx: HttpContext) -> None:
"""Serve the SPA shell, or 404 when the frontend build is missing."""
await _send_from_dist(ctx, "index.html", "text/html; charset=utf-8")
@app.GET("/")
@@ -38,6 +62,25 @@ async def index(ctx: HttpContext) -> None:
await _send_shell(ctx)
@app.GET("/manifest.webmanifest")
async def manifest(ctx: HttpContext) -> None:
"""Serve the web app manifest (linked from index.html)."""
await _send_from_dist(ctx, "manifest.webmanifest", "application/manifest+json")
@app.GET("/sw.js")
async def service_worker(ctx: HttpContext) -> None:
"""Serve the service worker from the site root so its scope is the
whole app. The script is revalidated on every load (no-cache) so a new
build's worker takes over promptly."""
await _send_from_dist(
ctx,
"sw.js",
"text/javascript; charset=utf-8",
cache_control="no-cache",
)
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
"""SPA fallback: any path that matched no other route renders the app
+40
View File
@@ -81,6 +81,46 @@ class StaticRouteTest(unittest.TestCase):
self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content)
@async_test
async def test_serves_pwa_manifest_and_worker(self) -> None:
"""The manifest and the service worker are served from the site
root, with the content types browsers require, instead of falling
through to the app shell."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
(Path(dist) / "manifest.webmanifest").write_text('{"name": "Tavolo"}')
(Path(dist) / "sw.js").write_text("self.addEventListener('fetch', () => {});")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
manifest = await client.get("/manifest.webmanifest")
self.assertEqual(200, manifest.status_code)
self.assertEqual("application/manifest+json", manifest.headers["content-type"])
self.assertEqual('{"name": "Tavolo"}', manifest.text)
worker = await client.get("/sw.js")
self.assertEqual(200, worker.status_code)
self.assertEqual("text/javascript; charset=utf-8", worker.headers["content-type"])
# The worker script itself must never be cached stale.
self.assertEqual("no-cache", worker.headers["cache-control"])
self.assertIn(b"addEventListener", worker.content)
@async_test
async def test_missing_pwa_files_return_404(self) -> None:
"""A build without the PWA files must 404 those exact paths rather
than answer them with the shell (the wrong content type)."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
self.assertEqual(404, (await client.get("/manifest.webmanifest")).status_code)
self.assertEqual(404, (await client.get("/sw.js")).status_code)
@async_test
async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")