From 008b3593dbad73eb058de8411c21271ae964e343 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 6 Feb 2026 17:21:40 +0000 Subject: [PATCH] [fastapi_vue] StaticFiles cleanup: use app.debug rather than env variables; default assets caching. --- fastapi-vue/fastapi_vue/staticfiles.py | 63 ++++++++++++++++---------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/fastapi-vue/fastapi_vue/staticfiles.py b/fastapi-vue/fastapi_vue/staticfiles.py index 86afa42..0c863c7 100644 --- a/fastapi-vue/fastapi_vue/staticfiles.py +++ b/fastapi-vue/fastapi_vue/staticfiles.py @@ -1,8 +1,9 @@ """FastAPI static file serving with zstd compression and SPA support.""" +from __future__ import annotations + import logging import mimetypes -import os import time from base64 import urlsafe_b64encode from functools import partial @@ -17,11 +18,26 @@ from starlette.exceptions import HTTPException from starlette.routing import Route from zstandard import ZstdCompressor -# Dev mode: index files but don't load content, return error responses -_DEVMODE = os.getenv("FASTAPI_VUE_FRONTEND_URL") - logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style +__all__ = ["Frontend"] + + +class Assets: + """Default cached value to /assets/""" + + @staticmethod + def parse(cached: str | list[str] | Assets) -> list[str]: + match cached: + case Assets(): + return ["/assets/"] + case str(): + return [cached] + case list(): + return cached + case _: + raise ValueError(f"Invalid cached value: {cached!r}") + class Frontend: """Static file server with automatic zstd compression and caching. @@ -37,9 +53,9 @@ class Frontend: directory: Path to the directory containing static files index: Name of the index file (default: "index.html") spa: Enable SPA mode - serve index.html for unknown routes (default: False) - cached: Path prefixes that should have immutable cache headers + cached: Path prefixes that are immutable (default: "/assets/") + favicon: E.g. /assets/logo.png will serve /assets/logo*.png as /favicon.ico zstdlevel: Zstd compression level (default: 18) - favicon: Path to favicon for automatic /favicon.ico handling """ def __init__( @@ -49,24 +65,18 @@ class Frontend: index: str = "index.html", spa: bool = False, catch_all: bool | None = None, - cached: str | list[str] | None = None, - zstdlevel: int = 18, + cached: str | list[str] | Assets = Assets(), favicon: str | None = None, + zstdlevel: int = 18, ) -> None: self.www: dict[str, tuple[bytes, bytes | None, dict]] = {} self.base: Path = Path(directory) self.index = index self.spa = spa self._catch_all = spa if catch_all is None else catch_all - if cached is None: - self.cached_paths = [] - elif isinstance(cached, str): - self.cached_paths = [cached] - else: - self.cached_paths = cached + self.cached_paths = Assets.parse(cached) self.zstdlevel = zstdlevel self.favicon = favicon - self.devmode = bool(_DEVMODE) self._app: FastAPI | None = None self._mount_path: str = "" self._ridx: int = 0 @@ -154,12 +164,15 @@ class Frontend: ) return www - async def load(self, *, log=True): + async def load(self, *, debug: bool | None = None, log: bool = True): """Load or reload static files from disk. - In dev mode (FASTAPI_VUE_FRONTEND_URL set), only indexes paths without loading content. + In debug mode, returns 409 instead of files (avoid accidental use of stale builds) + If debug is None, uses app.debug (app passed to frontend.route) """ - if self.devmode: + if debug is None: + debug = getattr(self._app, "debug", False) + if debug: # Dev mode: just index paths, no content loading self._devmode_paths = await run_in_threadpool(self._index_only) self._register_routes() @@ -214,13 +227,14 @@ class Frontend: self._routes.clear() # Get paths and select handler based on mode (checked once, not per request) - paths = self._devmode_paths if self.devmode else self.www.keys() - handler = _devmode_respond if self.devmode else self._respond + debug = getattr(self._app, "debug", False) + paths = self._devmode_paths if debug else self.www.keys() + handler = _devmode_respond if debug else self._respond # Insert at the position where route() was called self._app.routes[self._ridx : self._ridx] = self._routes = [ Route( self._mount_path + p, - endpoint=handler if self.devmode else partial(handler, name=p), + endpoint=handler if debug else partial(handler, name=p), methods=["GET", "HEAD"], name=f"frontend{p.replace('/', '_')}", ) @@ -241,7 +255,8 @@ class Frontend: def handle(self, request: Request, path: str): """SPA catch-all handler with directory redirects and fallback to index.""" name = path.removesuffix(self.index) - files = self._devmode_paths if self.devmode else self.www + debug = getattr(self._app, "debug", False) + files = self._devmode_paths if debug else self.www if name not in files: # Friendly redirect for directories missing trailing slash @@ -254,7 +269,7 @@ class Frontend: if name not in files: raise HTTPException(status_code=404) - return (_devmode_respond if self.devmode else self._respond)(request, name) + return (_devmode_respond if debug else self._respond)(request, name) def _devmode_respond(request: Request, name=""): @@ -262,6 +277,6 @@ def _devmode_respond(request: Request, name=""): return JSONResponse( status_code=409, content={ - "detail": f"Frontend assets served by Vite in dev mode. Connect via {_DEVMODE} instead." + "detail": "Frontend assets served by Vite in debug mode. You are on backend, connect to frontend instead." }, )