From 31dbaa85139208024b518f374cae3c7d963e602b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 17:09:53 +0000 Subject: [PATCH 1/3] fix(app): return correct HTTP status for SPA fallback routes Subclass Starlette's StaticFiles to consult app.router on a 404. When the path matches a defined dynamic route, rewrite the status to 200 so the SPA shell renders correctly with a proper status code. True misses still return 404. Asset extensions skip the route check to avoid CPU overhead. Refs reflex-dev/reflex#6463 --- reflex/app.py | 2 +- reflex/utils/exec.py | 140 +++++++++++++++++++++++++++++++-- tests/units/utils/test_exec.py | 124 +++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 tests/units/utils/test_exec.py diff --git a/reflex/app.py b/reflex/app.py index 65ae0a8235b..b9f77517cc3 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -681,7 +681,7 @@ def __call__(self) -> ASGIApp: if environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.get(): from reflex.utils.exec 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]] = ( diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 0889e9359e1..716a35f3c74 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -10,11 +10,15 @@ import re import subprocess import sys -from collections.abc import Sequence +from collections.abc import Callable, Sequence from pathlib import Path -from typing import Any, NamedTuple, TypedDict +from typing import TYPE_CHECKING, Any, NamedTuple, TypedDict from urllib.parse import urljoin +if TYPE_CHECKING: + from starlette.responses import Response + from starlette.types import Scope + from reflex_base import constants from reflex_base.config import get_config from reflex_base.constants.base import LogLevel @@ -272,26 +276,152 @@ def notify_app_running(): console.rule("[bold green]App Running") -def get_frontend_mount(): +# 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 + + +def _build_reflex_static_files_class(): + """Build the ReflexStaticFiles class lazily to defer the starlette import. + + Returns: + The ReflexStaticFiles class. + """ + from starlette.staticfiles import StaticFiles + + 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: + super().__init__(*args, **kwargs) + self._route_resolver = route_resolver + + async def get_response(self, path: str, scope: Scope) -> Response: + 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 + + return ReflexStaticFiles + + +def __getattr__(name: str) -> Any: + """Lazily resolve `ReflexStaticFiles` to defer the starlette import. + + Args: + name: The attribute name being accessed. + + Returns: + The resolved attribute value. + + Raises: + AttributeError: If the attribute does not exist on this module. + """ + if name == "ReflexStaticFiles": + cls = _build_reflex_static_files_class() + globals()[name] = cls + return cls + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + +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 provided, valid dynamic routes are served with HTTP + 200 (the SPA shell) and only true misses receive HTTP 404. + 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() + cls = _build_reflex_static_files_class() return Mount( config.prepend_frontend_path("/"), - app=StaticFiles( + app=cls( directory=prerequisites.get_web_dir() / constants.Dirs.STATIC / config.frontend_path.strip("/"), html=True, + route_resolver=route_resolver, ), name="frontend", ) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py new file mode 100644 index 00000000000..28124ab2863 --- /dev/null +++ b/tests/units/utils/test_exec.py @@ -0,0 +1,124 @@ +"""Tests for reflex.utils.exec.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from reflex.utils.exec import ReflexStaticFiles + + +@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" From e9393839a929ea55f3da83fb35abf41162f84b01 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 17:26:40 +0000 Subject: [PATCH 2/3] fix(app): write routes manifest so standalone frontend can resolve dynamic routes Move ReflexStaticFiles to module level (drop the lazy __getattr__ now that starlette is imported at the top of exec.py). Have the compiler emit a routes_manifest.json next to stateful_pages.json listing every defined route pattern; get_frontend_mount falls back to loading it when no resolver is passed in. This lets the standalone frontend prod app distinguish dynamic routes from true 404s without an App instance. Also add tests covering frontend_path prefix mounting and manifest load/missing/invalid paths. Refs reflex-dev/reflex#6463 --- .../src/reflex_base/constants/base.py | 4 + reflex/app.py | 14 ++ reflex/compiler/compiler.py | 2 + reflex/utils/exec.py | 131 ++++++++++-------- tests/units/utils/test_exec.py | 59 +++++++- 5 files changed, 149 insertions(+), 61 deletions(-) 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 b9f77517cc3..c0e4916ab50 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -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 716a35f3c74..1e4491b8af7 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -12,19 +12,18 @@ import sys from collections.abc import Callable, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple, TypedDict +from typing import Any, NamedTuple, TypedDict from urllib.parse import urljoin -if TYPE_CHECKING: - from starlette.responses import Response - from starlette.types import Scope - from reflex_base import constants from reflex_base.config import get_config from reflex_base.constants.base import LogLevel from reflex_base.environment import environment from reflex_base.utils import console from reflex_base.utils.decorator import once +from starlette.responses import Response +from starlette.staticfiles import StaticFiles +from starlette.types import Scope from reflex.utils import path_ops from reflex.utils.misc import get_module_path @@ -327,70 +326,80 @@ def _is_html_navigation(path: str) -> bool: return last.rsplit(".", 1)[-1].lower() not in _ASSET_EXTENSIONS -def _build_reflex_static_files_class(): - """Build the ReflexStaticFiles class lazily to defer the starlette import. +class ReflexStaticFiles(StaticFiles): + """StaticFiles that returns the right HTTP status for SPA routes. - Returns: - The ReflexStaticFiles class. + 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. """ - from starlette.staticfiles import StaticFiles - class ReflexStaticFiles(StaticFiles): - """StaticFiles that returns the right HTTP status for SPA routes. + def __init__( + self, + *args: Any, + route_resolver: Callable[[str], str | None] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the static files handler. - 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. + 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 - def __init__( - self, - *args: Any, - route_resolver: Callable[[str], str | None] | None = None, - **kwargs: Any, - ) -> None: - super().__init__(*args, **kwargs) - self._route_resolver = route_resolver - - async def get_response(self, path: str, scope: Scope) -> Response: - 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 + 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. - return ReflexStaticFiles + 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 __getattr__(name: str) -> Any: - """Lazily resolve `ReflexStaticFiles` to defer the starlette import. - Args: - name: The attribute name being accessed. +def _load_routes_manifest() -> Callable[[str], str | None] | None: + """Build a route resolver from the on-disk routes manifest, if present. Returns: - The resolved attribute value. - - Raises: - AttributeError: If the attribute does not exist on this module. + A resolver callable, or None if the manifest is missing or empty. """ - if name == "ReflexStaticFiles": - cls = _build_reflex_static_files_class() - globals()[name] = cls - return cls - msg = f"module {__name__!r} has no attribute {name!r}" - raise AttributeError(msg) + 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( @@ -401,8 +410,9 @@ def get_frontend_mount( 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 provided, valid dynamic routes are served with HTTP - 200 (the SPA shell) and only true misses receive HTTP 404. + 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. @@ -412,11 +422,12 @@ def get_frontend_mount( from reflex.utils import prerequisites config = get_config() - cls = _build_reflex_static_files_class() + if route_resolver is None: + route_resolver = _load_routes_manifest() return Mount( config.prepend_frontend_path("/"), - app=cls( + app=ReflexStaticFiles( directory=prerequisites.get_web_dir() / constants.Dirs.STATIC / config.frontend_path.strip("/"), diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 28124ab2863..d8f6298f876 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -9,7 +10,7 @@ from starlette.routing import Mount from starlette.testclient import TestClient -from reflex.utils.exec import ReflexStaticFiles +from reflex.utils.exec import ReflexStaticFiles, _load_routes_manifest @pytest.fixture @@ -122,3 +123,59 @@ def resolver(path: str) -> str | None: 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. + """ + from reflex.route import get_router + + # 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.""" + from reflex.utils import prerequisites + + 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): + from reflex.utils import prerequisites + + 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): + from reflex.utils import prerequisites + + 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 From 620f9c1bbf078b53bd6c051b1036ce1feee6b8e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 18:23:55 +0000 Subject: [PATCH 3/3] refactor: extract frontend mount into reflex/utils/frontend.py Move ReflexStaticFiles, _is_html_navigation, _load_routes_manifest, get_frontend_mount, and _frontend_prod_app out of reflex/utils/exec.py into a dedicated reflex/utils/frontend.py module. Update the runner target string and the call site in app.py. Hoist the prerequisites import in the renamed test_frontend.py instead of re-importing it inside each test. --- reflex/app.py | 2 +- reflex/utils/exec.py | 188 +----------------- reflex/utils/frontend.py | 187 +++++++++++++++++ .../utils/{test_exec.py => test_frontend.py} | 14 +- 4 files changed, 196 insertions(+), 195 deletions(-) create mode 100644 reflex/utils/frontend.py rename tests/units/utils/{test_exec.py => test_frontend.py} (95%) diff --git a/reflex/app.py b/reflex/app.py index c0e4916ab50..b9bf57874a0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -679,7 +679,7 @@ 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(route_resolver=self.router)) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 1e4491b8af7..ec77089172c 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -10,7 +10,7 @@ import re import subprocess import sys -from collections.abc import Callable, Sequence +from collections.abc import Sequence from pathlib import Path from typing import Any, NamedTuple, TypedDict from urllib.parse import urljoin @@ -21,9 +21,6 @@ from reflex_base.environment import environment from reflex_base.utils import console from reflex_base.utils.decorator import once -from starlette.responses import Response -from starlette.staticfiles import StaticFiles -from starlette.types import Scope from reflex.utils import path_ops from reflex.utils.misc import get_module_path @@ -275,180 +272,6 @@ def notify_app_running(): console.rule("[bold green]App Running") -# 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()]) - - def run_frontend_prod(host: str, port: int): """Run the frontend in production mode by serving compiled static files. @@ -459,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_exec.py b/tests/units/utils/test_frontend.py similarity index 95% rename from tests/units/utils/test_exec.py rename to tests/units/utils/test_frontend.py index d8f6298f876..ffaae788fa6 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_frontend.py @@ -1,4 +1,4 @@ -"""Tests for reflex.utils.exec.""" +"""Tests for reflex.utils.frontend.""" from __future__ import annotations @@ -10,7 +10,9 @@ from starlette.routing import Mount from starlette.testclient import TestClient -from reflex.utils.exec import ReflexStaticFiles, _load_routes_manifest +from reflex.route import get_router +from reflex.utils import prerequisites +from reflex.utils.frontend import ReflexStaticFiles, _load_routes_manifest @pytest.fixture @@ -132,8 +134,6 @@ def test_frontend_path_prefix_normalizes_for_resolver(static_dir: Path): static-files subclass just needs to feed the post-mount path through cleanly. """ - from reflex.route import get_router - # Routes are stored without leading slashes (see format.format_route). resolver = get_router(["index", "blog/[slug]"]) app = Starlette( @@ -154,8 +154,6 @@ def test_frontend_path_prefix_normalizes_for_resolver(static_dir: Path): def test_load_routes_manifest_round_trip(tmp_path: Path, monkeypatch): """Manifest written at compile time is read back by the standalone server.""" - from reflex.utils import prerequisites - monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) manifest = tmp_path / "routes_manifest.json" manifest.write_text(json.dumps(["index", "blog/[slug]"])) @@ -167,15 +165,11 @@ def test_load_routes_manifest_round_trip(tmp_path: Path, monkeypatch): def test_load_routes_manifest_missing_returns_none(tmp_path: Path, monkeypatch): - from reflex.utils import prerequisites - 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): - from reflex.utils import prerequisites - 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