diff --git a/cista/app.py b/cista/app.py index 2af913b..6df14c1 100755 --- a/cista/app.py +++ b/cista/app.py @@ -2,15 +2,21 @@ import mimetypes from importlib.resources import files from urllib.parse import unquote +import sanic.helpers import asyncio import brotli -from sanic import Blueprint, Sanic, raw +from blake3 import blake3 +from sanic import Blueprint, Sanic, raw, empty from sanic.exceptions import Forbidden, NotFound +from wsgiref.handlers import format_date_time from cista import auth, config, session, watching from cista.api import bp from cista.util.apphelpers import handle_sanic_exception +# Workaround until Sanic PR #2824 is merged +sanic.helpers._ENTITY_HEADERS = frozenset() + app = Sanic("cista", strict_slashes=True) app.blueprint(auth.bp) app.blueprint(bp) @@ -63,8 +69,12 @@ www = {} @app.before_server_start -def load_wwwroot(app): +async def load_wwwroot(*_ignored): global www + www = await asyncio.get_event_loop().run_in_executor(None, _load_wwwroot, www) + + +def _load_wwwroot(www): wwwnew = {} base = files("cista") / "wwwroot" paths = ["."] @@ -77,36 +87,71 @@ def load_wwwroot(app): continue name = p.relative_to(base).as_posix() mime = mimetypes.guess_type(name)[0] or "application/octet-stream" + mtime = p.stat().st_mtime data = p.read_bytes() + etag = blake3(data).hexdigest(length=8) + if name == "index.html": + name = "" # Use old data if not changed - if name in www and www[name][0] == data: + if name in www and www[name][2]["etag"] == etag: wwwnew[name] = www[name] continue + cached = name.startswith("assets/") + headers = { + "etag": etag, + "last-modified": format_date_time(mtime), + "cache-control": "max-age=31536000, immutable" + if cached + else "no-cache", + "content-type": mime, + } # Precompress with Brotli br = brotli.compress(data) if len(br) >= len(data): br = False - wwwnew[name] = data, br, mime - www = wwwnew + wwwnew[name] = data, br, headers + return wwwnew @app.add_task async def refresh_wwwroot(): - while app.debug: + while True: + try: + wwwold = www + await load_wwwroot() + changes = "" + for name in sorted(www): + attr = www[name] + if wwwold.get(name) == attr: + continue + headers = attr[2] + changes += f"{headers['last-modified']} {headers['etag']} /{name}\n" + for name in sorted(set(wwwold) - set(www)): + changes += f"Deleted /{name}\n" + if changes: + print(f"Updated wwwroot:\n{changes}", end="", flush=True) + except Exception as e: + print("Error loading wwwroot", e) + if not app.debug: + return await asyncio.sleep(0.5) - load_wwwroot(app) -@app.get("/", static=True) +@app.route("/", methods=["GET", "HEAD"]) async def wwwroot(req, path=""): """Frontend files only""" - name = unquote(path) or "index.html" + name = unquote(path) if name not in www: raise NotFound(f"File not found: /{path}", extra={"name": name}) - data, br, mime = www[name] - headers = {} + data, br, headers = www[name] + if req.headers.if_none_match == headers["etag"]: + # The client has it cached, respond 304 Not Modified + return empty(304, headers=headers) # Brotli compressed? if br and "br" in req.headers.accept_encoding.split(", "): - headers["content-encoding"] = "br" + headers = { + **headers, + "content-encoding": "br", + } data = br - return raw(data, content_type=mime, headers=headers) + return raw(data, headers=headers) diff --git a/pyproject.toml b/pyproject.toml index 78b7351..51ba9df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ ] dependencies = [ "argon2-cffi", + "blake3", "brotli", "docopt", "inotify",