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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user