Skip to content
Open
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
1 change: 1 addition & 0 deletions news/6996.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Serve valid dynamic-route URLs (e.g. `/articles/7`) with HTTP 200 instead of 404 when loaded directly in self-hosted prod static serving, reserving 404 for genuinely unknown paths.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6996.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the `routes.json` manifest name constant, written at compile time so the prod static file server can tell routable SPA paths from unknown ones.
3 changes: 3 additions & 0 deletions packages/reflex-base/src/reflex_base/constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ class Dirs(SimpleNamespace):
ENV_JSON = "env.json"
# The name of the reflex json file.
REFLEX_JSON = "reflex.json"
# JSON-encoded list of the app's page routes, written at compile time so the
# prod static file server can tell routable SPA paths from unknown ones.
ROUTES_MANIFEST = "routes.json"
Comment thread
FarhanAliRaza marked this conversation as resolved.
# The name of the postcss config file.
POSTCSS_JS = "postcss.config.js"
# The name of the states directory.
Expand Down
13 changes: 11 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,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(router=self.router))

if self.api_transformer is not None:
api_transformers: Sequence[Starlette | Callable[[ASGIApp], ASGIApp]] = (
Expand Down Expand Up @@ -1314,6 +1314,15 @@ def _compile_page(self, route: str, save_page: bool = True):
if save_page:
self._pages[route] = component

@property
def _page_routes(self) -> list[str]:
"""All registered page routes in registration order.

Returns:
The deduplicated list of page routes.
"""
return list(dict.fromkeys([*self._unevaluated_pages, *self._pages]))

@functools.cached_property
def router(self) -> Callable[[str], str | None]:
"""The route computer function.
Expand All @@ -1323,7 +1332,7 @@ def router(self) -> Callable[[str], str | None]:
"""
from reflex.route import get_router

return get_router(list(dict.fromkeys([*self._unevaluated_pages, *self._pages])))
return get_router(self._page_routes)

def get_load_events(self, path: str) -> list[IndividualEventType[()]]:
"""Get the load events for a route.
Expand Down
7 changes: 7 additions & 0 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,13 @@ def add_save_task(
prerender_routes=prerender_routes,
)

# Persist the route table so the standalone prod static server can serve
# routable SPA paths with 200 and reserve 404 for unknown ones.
compile_results.append((
constants.Dirs.ROUTES_MANIFEST,
json.dumps(app._page_routes),
))

if is_prod_mode():
purge_web_pages_dir()
else:
Expand Down
69 changes: 67 additions & 2 deletions reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import contextlib
import functools
import hashlib
import importlib.util
import json
Expand All @@ -12,7 +13,7 @@
import re
import subprocess
import sys
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any, NamedTuple, TypedDict

Expand Down Expand Up @@ -340,9 +341,63 @@ def notify_app_running():
console.rule("[bold green]App Running")


def get_frontend_mount():
def _match_routable_page(router: Callable[[str], str | None], path: str) -> str | None:
"""Match a path against the app routes, treating the 404 page as unroutable.

The compiler registers a synthetic ``404`` page, so a literal ``/404``
request would otherwise count as routable and lose its 404 status.

Args:
router: The app route matcher.
path: The request path.

Returns:
The matching route, or None when the path matches no route or only the
404 page.
"""
route = router(path)
return route if route != constants.Page404.SLUG else None


def _match_with_frontend_path(
router: Callable[[str], str | None], path: str
) -> str | None:
"""Match a mount-relative path against a route matcher expecting the frontend path.

Args:
router: The app route matcher.
path: The request path with the frontend path prefix already stripped.

Returns:
The matching route, or None if no route matches.
"""
return router(get_config().prepend_frontend_path(path))


def get_routes_manifest_router() -> Callable[[str], str | None] | None:
"""Build a route matcher from the routes manifest written at compile time.

Returns:
A route matcher, or None when no manifest exists.
"""
from reflex.route import get_router

manifest = get_web_dir() / constants.Dirs.ROUTES_MANIFEST
try:
routes = json.loads(manifest.read_text())
except (OSError, ValueError):
return None
return get_router(routes)


def get_frontend_mount(router: Callable[[str], str | None] | None = None):
"""Get a Starlette Mount for the compiled frontend static files.

Args:
router: Optional route matcher (e.g. ``app.router``) used to serve
routable SPA paths with status 200 instead of 404. When None, a
matcher is built from the compiled routes manifest if present.

Returns:
A Mount serving the compiled frontend static files.
"""
Expand All @@ -353,6 +408,15 @@ def get_frontend_mount():

config = get_config()

if router is None:
router = get_routes_manifest_router()
if router is not None:
router = functools.partial(_match_routable_page, router)
if config.frontend_path:
# The mount strips the frontend path from request paths, but the
# route matcher expects it present (it strips the prefix itself).
router = functools.partial(_match_with_frontend_path, router)

static_dir = (
prerequisites.get_web_dir()
/ constants.Dirs.STATIC
Expand All @@ -365,6 +429,7 @@ def get_frontend_mount():
directory=static_dir,
html=True,
encodings=config.frontend_compression_formats,
router=router,
),
name="frontend",
)
Expand Down
30 changes: 22 additions & 8 deletions reflex/utils/precompressed_staticfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import os
import stat
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from functools import lru_cache
from mimetypes import guess_type
Expand Down Expand Up @@ -72,17 +72,23 @@ def __init__(
self,
*args,
encodings: Sequence[str] = (),
router: Callable[[str], str | None] | None = None,
**kwargs,
):
"""Initialize the static file server.

Args:
*args: Passed through to ``StaticFiles``.
encodings: Ordered list of supported precompressed formats.
router: Optional route matcher taking the request path (with leading
slash) and returning the matching app route, or ``None``. Paths
with no on-disk file that match a route are served the SPA
fallback with status 200 instead of 404.
**kwargs: Passed through to ``StaticFiles``.
"""
super().__init__(*args, **kwargs)
self._encodings = tuple(_SUPPORTED_ENCODINGS[name] for name in encodings)
self._router = router

def _select_sidecar(
self, full_path: str | PathLike[str], scope: Scope
Expand Down Expand Up @@ -178,16 +184,24 @@ async def get_response(self, path: str, scope: Scope) -> Response:
The resolved static response for the request.
"""
response = await super().get_response(path, scope)
# Starlette's get_response builds the 404.html fallback with bare FileResponse,
# bypassing file_response. Re-route it so the sidecar/Vary handling applies.
if (
self._encodings
and self.html
self.html
and isinstance(response, FileResponse)
and response.status_code == 404
and response.stat_result is not None
):
return self.file_response(
response.path, response.stat_result, scope, status_code=404
)
# SPA fallback: a path with no prerendered file that still matches
# the app's route table is a valid page, so serve it with 200 and
# reserve 404 for genuinely unknown paths.
if self._router is not None and self._router("/" + path) is not None:
Comment thread
FarhanAliRaza marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking on Windows: path here comes from StaticFiles.get_path, which normalizes with os.path.normpath, so on Windows it arrives as articles\7 (nested\deep\page, files\a\b, ...). The route regexes only understand /, so every multi-segment routable path keeps its 404 status there; only single-segment routes like /about benefit. The PR's unit tests pass on Windows CI because they call get_response("articles/7", ...) directly and never go through get_path.

Restoring the URL form before matching fixes it:

Suggested change
if self._router is not None and self._router("/" + path) is not None:
if (
self._router is not None
and self._router("/" + path.replace(os.sep, "/")) is not None
):

Regression tests (one driving the ASGI callable so Windows CI exercises the real get_path, one simulating the separator on any OS) are in 47b9992 on claude/pr-6996-review-97oxj9, ready to cherry-pick.


Generated by Claude Code

return self.file_response(
response.path, response.stat_result, scope, status_code=200
)
# Starlette's get_response builds the 404.html fallback with bare
# FileResponse, bypassing file_response. Re-route it so the
# sidecar/Vary handling applies.
if self._encodings:
return self.file_response(
response.path, response.stat_result, scope, status_code=404
)
return response
27 changes: 27 additions & 0 deletions tests/integration/test_precompressed_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def index():
rx.text("Hello from Reflex"),
)

def article():
return rx.el.main(rx.heading("Article"))

app.add_page(article, route="articles/[id]")


@pytest.fixture(scope="module")
def all_compression_formats_env() -> Generator[None, None, None]:
Expand Down Expand Up @@ -114,3 +119,25 @@ def test_prod_frontend_serves_precompressed_404_fallback(
assert headers["content-encoding"] == accept_encoding
if magic is not None:
assert body[: len(magic)] == magic


@pytest.mark.parametrize("accept_encoding", ["identity", "gzip"])
def test_prod_frontend_serves_dynamic_route_with_200(
prod_test_app: AppHarnessProd,
accept_encoding: str,
):
"""Direct loads of valid dynamic-route URLs get the SPA fallback with 200."""
assert prod_test_app.frontend_url is not None

status, headers, body = request_raw(
prod_test_app.frontend_url,
"/articles/7",
headers={"Accept-Encoding": accept_encoding},
)

assert status == 200
if accept_encoding == "gzip":
assert headers["content-encoding"] == "gzip"
assert body[:2] == b"\x1f\x8b"
else:
assert b"<html" in body.lower()
13 changes: 13 additions & 0 deletions tests/units/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,19 @@ def test_add_page_default_route(
assert app._pages.keys() == {"index", "about"}


def test_page_routes(app: App, index_page: ComponentCallable):
"""Test that _page_routes lists registered routes without duplicates.

Args:
app: The app to test.
index_page: The index page.
"""
app.add_page(index_page)
app.add_page(index_page, route="articles")
app._compile_page("index")
assert app._page_routes == ["index", "articles"]


def test_add_page_set_route(app: App, index_page: ComponentCallable):
"""Test adding a page to an app.

Expand Down
Loading
Loading