diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index dc5d84bd788..4d6a991f4d1 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -60,6 +60,10 @@ class Dirs(SimpleNamespace): BACKEND = "backend" # JSON-encoded list of page routes that need to be evaluated on the backend. STATEFUL_PAGES = "stateful_pages.json" + # JSON-encoded list of all defined route patterns (static and dynamic). + # Used by the frontend mount to distinguish valid dynamic routes from + # true 404s when serving the SPA fallback. + ROUTES_MANIFEST = "routes_manifest.json" # Marker file indicating that upload component was used in the frontend. UPLOAD_IS_USED = "upload_is_used" diff --git a/reflex/app.py b/reflex/app.py index 65ae0a8235b..b9bf57874a0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -679,9 +679,9 @@ def __call__(self) -> ASGIApp: asgi_app = self._api if environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.get(): - from reflex.utils.exec import get_frontend_mount + from reflex.utils.frontend import get_frontend_mount - asgi_app.routes.append(get_frontend_mount()) + asgi_app.routes.append(get_frontend_mount(route_resolver=self.router)) if self.api_transformer is not None: api_transformers: Sequence[Starlette | Callable[[ASGIApp], ASGIApp]] = ( @@ -1196,6 +1196,20 @@ def _write_stateful_pages_marker(self): with stateful_pages_marker.open("w") as f: json.dump(list(self._stateful_pages), f) + def _write_routes_manifest(self): + """Write all defined route patterns so the frontend mount can resolve them. + + The standalone frontend prod server has no `App` instance and + therefore can't pass `app.router` directly; reading this manifest + from disk lets it return correct HTTP status codes for dynamic + routes vs. true 404s. + """ + manifest_path = prerequisites.get_web_dir() / constants.Dirs.ROUTES_MANIFEST + manifest_path.parent.mkdir(parents=True, exist_ok=True) + routes = list(dict.fromkeys([*self._unevaluated_pages, *self._pages])) + with manifest_path.open("w") as f: + json.dump(routes, f) + def add_all_routes_endpoint(self): """Add an endpoint to the app that returns all the routes.""" if not self._api: diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 39ff4931a9e..922481cf887 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1027,6 +1027,7 @@ def compile_app( app._compile_page(route, save_page=False) app._write_stateful_pages_marker() + app._write_routes_manifest() app._add_optional_endpoints() return @@ -1077,6 +1078,7 @@ def compile_app( app._stateful_pages.update(compile_ctx.stateful_routes) app._write_stateful_pages_marker() + app._write_routes_manifest() app._add_optional_endpoints() app._validate_var_dependencies() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 0889e9359e1..ec77089172c 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -272,42 +272,6 @@ def notify_app_running(): console.rule("[bold green]App Running") -def get_frontend_mount(): - """Get a Starlette Mount for the compiled frontend static files. - - Returns: - A Mount serving the compiled frontend static files. - """ - from starlette.routing import Mount - from starlette.staticfiles import StaticFiles - - from reflex.utils import prerequisites - - config = get_config() - - return Mount( - config.prepend_frontend_path("/"), - app=StaticFiles( - directory=prerequisites.get_web_dir() - / constants.Dirs.STATIC - / config.frontend_path.strip("/"), - html=True, - ), - name="frontend", - ) - - -def _frontend_prod_app(): - """Create a Starlette app that serves the compiled frontend static files. - - Returns: - A Starlette ASGI app serving static files. - """ - from starlette.applications import Starlette - - return Starlette(routes=[get_frontend_mount()]) - - def run_frontend_prod(host: str, port: int): """Run the frontend in production mode by serving compiled static files. @@ -318,15 +282,12 @@ def run_frontend_prod(host: str, port: int): port: The port to serve on. """ loglevel = get_config().loglevel.subprocess_level() + app_target = "reflex.utils.frontend:_frontend_prod_app" if should_use_granian(): - run_granian_backend_prod( - host, port, loglevel, app_target=f"{__name__}:_frontend_prod_app" - ) + run_granian_backend_prod(host, port, loglevel, app_target=app_target) else: - run_uvicorn_backend_prod( - host, port, loglevel, app_target=f"{__name__}:_frontend_prod_app" - ) + run_uvicorn_backend_prod(host, port, loglevel, app_target=app_target) @once diff --git a/reflex/utils/frontend.py b/reflex/utils/frontend.py new file mode 100644 index 00000000000..3b26966d351 --- /dev/null +++ b/reflex/utils/frontend.py @@ -0,0 +1,187 @@ +"""Frontend serving: SPA-aware static-files mount and routes manifest.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Callable +from typing import Any + +from reflex_base import constants +from reflex_base.config import get_config +from starlette.responses import Response +from starlette.staticfiles import StaticFiles +from starlette.types import Scope + +# File extensions that mark a request as a static asset rather than a page +# navigation. Asset misses are left as 404 instead of being routed through the +# SPA fallback machinery. +_ASSET_EXTENSIONS = frozenset({ + "js", + "mjs", + "cjs", + "css", + "map", + "json", + "ico", + "png", + "jpg", + "jpeg", + "gif", + "svg", + "webp", + "avif", + "woff", + "woff2", + "ttf", + "otf", + "eot", + "mp4", + "webm", + "mp3", + "wav", + "ogg", + "wasm", + "pdf", + "txt", + "xml", +}) + + +def _is_html_navigation(path: str) -> bool: + """Return True if path looks like a page navigation rather than an asset. + + Args: + path: The request path (without query string). + + Returns: + True for extension-less paths and `.html` paths; False for typical + static-asset extensions. + """ + last = path.replace(os.sep, "/").rsplit("/", 1)[-1] + if "." not in last: + return True + return last.rsplit(".", 1)[-1].lower() not in _ASSET_EXTENSIONS + + +class ReflexStaticFiles(StaticFiles): + """StaticFiles that returns the right HTTP status for SPA routes. + + Starlette's StaticFiles with html=True serves 404.html with status 404 + for any path that doesn't exist on disk. That's wrong for valid dynamic + routes (e.g. /blog/[slug]) where the SPA shell is the intended response. + This subclass consults a route resolver on a miss: if the path matches a + defined route, the response status is rewritten to 200 (the body is + already the SPA shell, since the build step copies __spa-fallback.html / + index.html to 404.html). Otherwise the 404 is preserved so true misses + surface as real 404s. + """ + + def __init__( + self, + *args: Any, + route_resolver: Callable[[str], str | None] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the static files handler. + + Args: + *args: Positional args forwarded to ``StaticFiles``. + route_resolver: Callable returning the matched route for a + given path, or None if the path doesn't match any defined + route. + **kwargs: Keyword args forwarded to ``StaticFiles``. + """ + super().__init__(*args, **kwargs) + self._route_resolver = route_resolver + + async def get_response(self, path: str, scope: Scope) -> Response: + """Serve a static file or fall back to the SPA shell with the right status. + + Args: + path: The relative path under the mount. + scope: The ASGI request scope. + + Returns: + The static-file response, with the status rewritten to 200 if + the path matches a defined dynamic route. + """ + response = await super().get_response(path, scope) + if ( + response.status_code != 404 + or self._route_resolver is None + or not _is_html_navigation(path) + ): + return response + normalized = "/" + path.replace(os.sep, "/").lstrip("/") + if self._route_resolver(normalized) is not None: + response.status_code = 200 + return response + + +def _load_routes_manifest() -> Callable[[str], str | None] | None: + """Build a route resolver from the on-disk routes manifest, if present. + + Returns: + A resolver callable, or None if the manifest is missing or empty. + """ + from reflex.route import get_router + from reflex.utils import prerequisites + + manifest_path = prerequisites.get_web_dir() / constants.Dirs.ROUTES_MANIFEST + if not manifest_path.exists(): + return None + try: + routes = json.loads(manifest_path.read_text()) + except (OSError, ValueError): + return None + if not routes: + return None + return get_router(list(routes)) + + +def get_frontend_mount( + route_resolver: Callable[[str], str | None] | None = None, +): + """Get a Starlette Mount for the compiled frontend static files. + + Args: + route_resolver: Optional callable that returns the matching route + for a given path, or None if the path doesn't match any defined + route. When omitted, falls back to the on-disk routes manifest + written at compile time. When neither is available, dynamic + routes that don't have a prerendered file will return 404. + + Returns: + A Mount serving the compiled frontend static files. + """ + from starlette.routing import Mount + + from reflex.utils import prerequisites + + config = get_config() + if route_resolver is None: + route_resolver = _load_routes_manifest() + + return Mount( + config.prepend_frontend_path("/"), + app=ReflexStaticFiles( + directory=prerequisites.get_web_dir() + / constants.Dirs.STATIC + / config.frontend_path.strip("/"), + html=True, + route_resolver=route_resolver, + ), + name="frontend", + ) + + +def _frontend_prod_app(): + """Create a Starlette app that serves the compiled frontend static files. + + Returns: + A Starlette ASGI app serving static files. + """ + from starlette.applications import Starlette + + return Starlette(routes=[get_frontend_mount()]) diff --git a/tests/units/utils/test_frontend.py b/tests/units/utils/test_frontend.py new file mode 100644 index 00000000000..ffaae788fa6 --- /dev/null +++ b/tests/units/utils/test_frontend.py @@ -0,0 +1,175 @@ +"""Tests for reflex.utils.frontend.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from reflex.route import get_router +from reflex.utils import prerequisites +from reflex.utils.frontend import ReflexStaticFiles, _load_routes_manifest + + +@pytest.fixture +def static_dir(tmp_path: Path) -> Path: + """Mimic the layout `reflex export` produces in static/. + + Args: + tmp_path: pytest-provided temporary directory. + + Returns: + Path to the populated static directory. + """ + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("index") + (static / "404.html").write_text("spa shell (served as 404.html)") + (static / "__spa-fallback.html").write_text("spa fallback") + about = static / "about" + about.mkdir() + (about / "index.html").write_text("about prerendered") + (static / "about.html").write_text("about prerendered") + (static / "assets").mkdir() + (static / "assets" / "main.js").write_text("console.log(1)") + return static + + +def _client(static_dir: Path, resolver=None) -> TestClient: + app = Starlette( + routes=[ + Mount( + "/", + app=ReflexStaticFiles( + directory=static_dir, html=True, route_resolver=resolver + ), + name="frontend", + ) + ] + ) + return TestClient(app) + + +def test_known_static_route_returns_200(static_dir: Path): + client = _client(static_dir, resolver=lambda p: p if p in {"/", "/about"} else None) + assert client.get("/about/").status_code == 200 + assert client.get("/").status_code == 200 + + +def test_real_asset_returns_200(static_dir: Path): + client = _client(static_dir, resolver=lambda p: None) + assert client.get("/assets/main.js").status_code == 200 + + +def test_dynamic_route_returns_200_with_spa_shell(static_dir: Path): + """A path matching a dynamic route should be served as 200, not 404.""" + + def resolver(path: str) -> str | None: + return "/blog/[slug]" if path.startswith("/blog/") else None + + client = _client(static_dir, resolver=resolver) + response = client.get("/blog/hello-world") + assert response.status_code == 200 + assert b"spa shell" in response.content + + +def test_unknown_route_returns_404(static_dir: Path): + client = _client(static_dir, resolver=lambda p: None) + response = client.get("/this-does-not-exist") + assert response.status_code == 404 + assert b"spa shell" in response.content + + +def test_missing_asset_returns_404_without_consulting_resolver(static_dir: Path): + """Asset misses must not be rewritten to 200 even if a resolver is wired.""" + seen = [] + + def resolver(path: str) -> str | None: + seen.append(path) + return "/anything" + + client = _client(static_dir, resolver=resolver) + assert client.get("/assets/missing.js").status_code == 404 + assert client.get("/missing.css").status_code == 404 + assert client.get("/favicon.ico").status_code == 404 + assert seen == [] + + +def test_no_resolver_preserves_starlette_behavior(static_dir: Path): + client = _client(static_dir, resolver=None) + assert client.get("/").status_code == 200 + assert client.get("/this-does-not-exist").status_code == 404 + + +def test_html_extension_is_treated_as_navigation(static_dir: Path): + def resolver(path: str) -> str | None: + return "/dyn" if path.startswith("/dyn") else None + + client = _client(static_dir, resolver=resolver) + assert client.get("/dyn/x.html").status_code == 200 + assert client.get("/missing.html").status_code == 404 + + +def test_resolver_receives_leading_slash_path(static_dir: Path): + seen: list[str] = [] + + def resolver(path: str) -> str | None: + seen.append(path) + return None + + client = _client(static_dir, resolver=resolver) + client.get("/some/nested/path") + assert seen + assert seen[0] == "/some/nested/path" + + +def test_frontend_path_prefix_normalizes_for_resolver(static_dir: Path): + """When mounted under a prefix, the resolver receives the post-mount path. + + `route.get_router` itself strips the configured `frontend_path`, so the + static-files subclass just needs to feed the post-mount path through + cleanly. + """ + # Routes are stored without leading slashes (see format.format_route). + resolver = get_router(["index", "blog/[slug]"]) + app = Starlette( + routes=[ + Mount( + "/app", + app=ReflexStaticFiles( + directory=static_dir, html=True, route_resolver=resolver + ), + name="frontend", + ) + ] + ) + client = TestClient(app) + assert client.get("/app/blog/hello").status_code == 200 + assert client.get("/app/no-such-route").status_code == 404 + + +def test_load_routes_manifest_round_trip(tmp_path: Path, monkeypatch): + """Manifest written at compile time is read back by the standalone server.""" + monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) + manifest = tmp_path / "routes_manifest.json" + manifest.write_text(json.dumps(["index", "blog/[slug]"])) + + resolver = _load_routes_manifest() + assert resolver is not None + assert resolver("/blog/anything") == "blog/[slug]" + assert resolver("/no-such-route") is None + + +def test_load_routes_manifest_missing_returns_none(tmp_path: Path, monkeypatch): + monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) + assert _load_routes_manifest() is None + + +def test_load_routes_manifest_invalid_returns_none(tmp_path: Path, monkeypatch): + monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) + (tmp_path / "routes_manifest.json").write_text("not valid json{") + assert _load_routes_manifest() is None