Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
18 changes: 16 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = (
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down
45 changes: 3 additions & 42 deletions reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
187 changes: 187 additions & 0 deletions reflex/utils/frontend.py
Original file line number Diff line number Diff line change
@@ -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()])
Loading
Loading