From 036a91a4fbe3578e6988ee60124fb44f393788ef Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 17 Apr 2026 20:26:42 +0500 Subject: [PATCH 1/3] feat: add backend_path config to prefix backend routes Mirrors frontend_path: sets a URL prefix (e.g. "/api") applied to every backend endpoint (event websocket, /ping, /_upload, /_health, /_all_routes) and automatically included in URLs baked into the frontend via Endpoint.get_url(). Enables proxying Reflex behind a subpath without request rewriting or baking the prefix into api_url. --- AGENTS.md | 11 +- README.md | 13 +- docs/hosting/self-hosting.md | 24 +++ .../reflex-base/src/reflex_base/config.py | 33 +++- .../src/reflex_base/constants/event.py | 2 +- reflex/app.py | 21 ++- tests/integration/test_upload.py | 3 +- .../tests_playwright/test_backend_path.py | 151 ++++++++++++++++++ .../tests_playwright/test_stateless_app.py | 8 +- tests/units/test_config.py | 83 ++++++++++ 10 files changed, 324 insertions(+), 25 deletions(-) create mode 100644 tests/integration/tests_playwright/test_backend_path.py diff --git a/AGENTS.md b/AGENTS.md index 6e08801e145..6bc496013ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,16 +62,22 @@ Apps as factory functions, run via `AppHarness`: ```python def SomeApp(): import reflex as rx + class State(rx.State): value: str = "" + def index(): return rx.box(rx.text(State.value)) + app = rx.App() app.add_page(index) + @pytest.fixture(scope="module") def some_app(tmp_path_factory) -> Generator[AppHarness, None, None]: - with AppHarness.create(root=tmp_path_factory.mktemp("some_app"), app_source=SomeApp) as harness: + with AppHarness.create( + root=tmp_path_factory.mktemp("some_app"), app_source=SomeApp + ) as harness: yield harness ``` @@ -88,6 +94,7 @@ Reflex has downstream users — don't break them. Provide a fallback path during **Runtime warning** via `console.deprecate()`: ```python from reflex_base.utils import console + console.deprecate( feature_name="OldFeature", reason="Use NewFeature instead.", @@ -101,8 +108,10 @@ Set `deprecation_version` to the next dot version of the latest tag (`git fetch ```python from __future__ import annotations from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing_extensions import deprecated + @deprecated("Use new_method() instead") def old_method(self) -> str: ... ``` diff --git a/README.md b/README.md index 5ab35796a25..2287bea84c5 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ def index(): "Generate Image", on_click=State.get_image, width="25em", - loading=State.processing + loading=State.processing, ), rx.cond( State.complete, @@ -175,6 +175,7 @@ def index(): height="100vh", ) + # Add state and page to the app. app = rx.App() app.add_page(index, title="Reflex:DALL-E") @@ -192,9 +193,7 @@ Let's start with the UI. ```python def index(): - return rx.center( - ... - ) + return rx.center(...) ``` This `index` function defines the frontend of the app. @@ -211,11 +210,11 @@ Reflex represents your UI as a function of your state. ```python class State(rx.State): """The app state.""" + prompt = "" image_url = "" processing = False complete = False - ``` The state defines all the variables (called vars) in an app that can change and the functions that change them. @@ -232,9 +231,7 @@ def get_image(self): self.processing, self.complete = True, False yield - response = openai_client.images.generate( - prompt=self.prompt, n=1, size="1024x1024" - ) + response = openai_client.images.generate(prompt=self.prompt, n=1, size="1024x1024") self.image_url = response.data[0].url self.processing, self.complete = False, True ``` diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index cd793f30ade..aa985b235cd 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -26,6 +26,30 @@ config = rx.Config( It is also possible to set the environment variable `API_URL` at run time or export time to retain the default for local development. +## Proxying to a Subpath + +If you want to serve the backend behind a reverse proxy at a subpath (e.g. +nginx routing `/api/*` to Reflex), set `backend_path` on the config instead of +baking the prefix into `api_url`. Every backend endpoint (event websocket, +`/ping`, `/_upload`, `/_health`, `/_all_routes`) is mounted under that prefix, +and the frontend baked into the export automatically calls the backend at the +prefixed URLs — no request rewriting in the proxy is required. + +```python +config = rx.Config( + app_name="your_app_name", + api_url="http://app.example.com:8000", + backend_path="/api", +) +``` + +`frontend_path` plays the analogous role for the frontend and the two are +independent. + +Note: changing `backend_path` (or `frontend_path`) requires a full restart of +`reflex run` — routes and mount points are registered at startup, so hot +reload alone will not move them. + ## Production Mode Then run your app in production mode: diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index a3838af9b22..e44aacc7de6 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -155,6 +155,7 @@ class BaseConfig: frontend_port: The port to run the frontend on. NOTE: When running in dev mode, the next available port will be used if this is taken. frontend_path: The path to run the frontend on. For example, "/app" will run the frontend on http://localhost:3000/app backend_port: The port to run the backend on. NOTE: When running in dev mode, the next available port will be used if this is taken. + backend_path: The path prefix for backend routes. For example, "/api" mounts the event websocket, /ping, /_upload, /_health, and /_all_routes under /api, and is automatically included in URLs baked into the frontend. Changing this requires a full `reflex run` restart — routes are registered at startup. api_url: The backend url the frontend will connect to. This must be updated if the backend is hosted elsewhere, or in production. deploy_url: The url the frontend will be hosted on. backend_host: The url the backend will be hosted on. @@ -194,6 +195,8 @@ class BaseConfig: backend_port: int | None = None + backend_path: str = "" + api_url: str = f"http://localhost:{constants.DefaultPorts.BACKEND_PORT}" deploy_url: str | None = f"http://localhost:{constants.DefaultPorts.FRONTEND_PORT}" @@ -476,6 +479,21 @@ def json(self) -> str: return json.dumps(self, default=serialize) + @staticmethod + def _prepend_path(path: str, prefix: str) -> str: + """Prepend ``prefix`` (normalized to ``/prefix``) to ``path`` when both are non-empty. + + Args: + path: The path to prepend the prefix to. + prefix: The configured prefix (e.g. ``frontend_path`` or ``backend_path``). + + Returns: + The path with the prefix prepended if it begins with a slash, otherwise the original path. + """ + if prefix and path.startswith("/"): + return f"/{prefix.strip('/')}{path}" + return path + def prepend_frontend_path(self, path: str) -> str: """Prepend the frontend path to a given path. @@ -485,9 +503,18 @@ def prepend_frontend_path(self, path: str) -> str: Returns: The path with the frontend path prepended if it begins with a slash, otherwise the original path. """ - if self.frontend_path and path.startswith("/"): - return f"/{self.frontend_path.strip('/')}{path}" - return path + return self._prepend_path(path, self.frontend_path) + + def prepend_backend_path(self, path: str) -> str: + """Prepend the backend path to a given path. + + Args: + path: The path to prepend the backend path to. + + Returns: + The path with the backend path prepended if it begins with a slash, otherwise the original path. + """ + return self._prepend_path(path, self.backend_path) @property def app_module(self) -> ModuleType | None: diff --git a/packages/reflex-base/src/reflex_base/constants/event.py b/packages/reflex-base/src/reflex_base/constants/event.py index 502127de738..515b34574e9 100644 --- a/packages/reflex-base/src/reflex_base/constants/event.py +++ b/packages/reflex-base/src/reflex_base/constants/event.py @@ -33,7 +33,7 @@ def get_url(self) -> str: # Get the API URL from the config. config = get_config() - url = "".join([config.api_url, str(self)]) + url = "".join([config.api_url, config.prepend_backend_path(str(self))]) # The event endpoint is a websocket. if self == Endpoint.EVENT: diff --git a/reflex/app.py b/reflex/app.py index bdbb90bfe40..bd5d40597ba 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -566,7 +566,10 @@ async def modified_send(message: Message): return await self.app(scope, receive, modified_send) socket_app_with_headers = HeaderMiddleware(socket_app) - self._api.mount(str(constants.Endpoint.EVENT), socket_app_with_headers) + self._api.mount( + config.prepend_backend_path(str(constants.Endpoint.EVENT)), + socket_app_with_headers, + ) # Check the exception handlers self._validate_exception_handlers() @@ -683,13 +686,14 @@ def _add_default_endpoints(self): if not self._api: return + config = get_config() self._api.add_route( - str(constants.Endpoint.PING), + config.prepend_backend_path(str(constants.Endpoint.PING)), ping, methods=["GET"], ) self._api.add_route( - str(constants.Endpoint.HEALTH), + config.prepend_backend_path(str(constants.Endpoint.HEALTH)), health, methods=["GET"], ) @@ -700,20 +704,21 @@ def _add_optional_endpoints(self): if not self._api: return + config = get_config() upload_is_used_marker = ( prerequisites.get_backend_dir() / constants.Dirs.UPLOAD_IS_USED ) if Upload.is_used or upload_is_used_marker.exists(): # To upload files. self._api.add_route( - str(constants.Endpoint.UPLOAD), + config.prepend_backend_path(str(constants.Endpoint.UPLOAD)), upload(self), methods=["POST"], ) # To access uploaded files. self._api.mount( - str(constants.Endpoint.UPLOAD), + config.prepend_backend_path(str(constants.Endpoint.UPLOAD)), UploadedFilesHeadersMiddleware(StaticFiles(directory=get_upload_dir())), name="uploaded_files", ) @@ -722,7 +727,7 @@ def _add_optional_endpoints(self): upload_is_used_marker.touch() if codespaces.is_running_in_codespaces(): self._api.add_route( - str(constants.Endpoint.AUTH_CODESPACE), + config.prepend_backend_path(str(constants.Endpoint.AUTH_CODESPACE)), codespaces.auth_codespace, methods=["GET"], ) @@ -1559,7 +1564,9 @@ def all_routes(_request: Request) -> Response: return JSONResponse(list(self._unevaluated_pages.keys())) self._api.add_route( - str(constants.Endpoint.ALL_ROUTES), all_routes, methods=["GET"] + get_config().prepend_backend_path(str(constants.Endpoint.ALL_ROUTES)), + all_routes, + methods=["GET"], ) @overload diff --git a/tests/integration/test_upload.py b/tests/integration/test_upload.py index 2c9b71ecd77..ddddd0ad445 100644 --- a/tests/integration/test_upload.py +++ b/tests/integration/test_upload.py @@ -789,7 +789,6 @@ def test_uploaded_file_security_headers( expected_mime_type: expected Content-Type mime type. """ import httpx - from reflex_base.config import get_config assert upload_file.app_instance is not None poll_for_token(driver, upload_file) @@ -809,7 +808,7 @@ def test_uploaded_file_security_headers( assert upload_file.poll_for_value(upload_done, exp_not_equal="false") == "true" # Fetch the uploaded file directly via httpx and check security headers. - upload_url = f"{get_config().api_url}/{Endpoint.UPLOAD.value}/{exp_name}" + upload_url = f"{Endpoint.UPLOAD.get_url()}/{exp_name}" resp = httpx.get(upload_url) assert resp.status_code == 200 assert resp.text == exp_contents diff --git a/tests/integration/tests_playwright/test_backend_path.py b/tests/integration/tests_playwright/test_backend_path.py new file mode 100644 index 00000000000..3fe02e39cf9 --- /dev/null +++ b/tests/integration/tests_playwright/test_backend_path.py @@ -0,0 +1,151 @@ +"""Integration tests for the backend_path config option. + +Tests that backend endpoints mount at the configured prefix and that the +frontend baked with ``backend_path`` can still reach the backend for state +events, uploads, and health checks. Covers the no-prefix baseline and the +prefixed case for both dev and prod modes via ``app_harness_env``. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import httpx +import pytest +from playwright.sync_api import Page, expect +from reflex_base.config import get_config + +from reflex.testing import AppHarness + + +def BackendPathApp(): + """App exercising state events and uploads under backend_path.""" + import reflex as rx + + class BPState(rx.State): + counter: int = 0 + + @rx.event + def bump(self): + self.counter += 1 + + upload_dir = rx.get_upload_dir() + upload_dir.mkdir(parents=True, exist_ok=True) + (upload_dir / "hello.txt").write_text("hello from backend_path") + + @rx.page("/") + def index(): + return rx.box( + rx.text(BPState.counter, id="counter"), + rx.input( + value=BPState.router.session.client_token, + read_only=True, + id="token", + ), + rx.button("bump", on_click=BPState.bump, id="bump-btn"), + rx.el.a( + "download", + href=rx.get_upload_url("hello.txt"), + id="upload-link", + ), + ) + + rx.App() + + +@pytest.fixture( + scope="module", + params=["", "/api"], + ids=["no-prefix", "with-prefix"], +) +def backend_path(request: pytest.FixtureRequest) -> str: + """Parametrise over no-prefix and /api. + + Args: + request: pytest fixture for accessing the current parameter. + + Returns: + The backend_path value for this test instance. + """ + return request.param + + +@pytest.fixture(scope="module") +def backend_path_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, + backend_path: str, +) -> Generator[AppHarness, None, None]: + """Start the BackendPathApp in dev or prod mode, with or without backend_path. + + Args: + app_harness_env: AppHarness (dev) or AppHarnessProd (prod). + tmp_path_factory: pytest fixture for creating temporary directories. + backend_path: "" or "/api". + + Yields: + Running AppHarness instance. + """ + suffix = backend_path.strip("/") or "root" + name = f"backendpath_{suffix}_{app_harness_env.__name__.lower()}" + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("REFLEX_UPLOADED_FILES_DIR", str(tmp_path_factory.mktemp("uploads"))) + if backend_path: + mp.setenv("REFLEX_BACKEND_PATH", backend_path) + else: + mp.delenv("REFLEX_BACKEND_PATH", raising=False) + + with app_harness_env.create( + root=tmp_path_factory.mktemp(name), + app_name=name, + app_source=BackendPathApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def test_ping_reachable_at_prefix(backend_path_app: AppHarness, backend_path: str): + """``/ping`` is served under backend_path (and not at the root when a prefix is set).""" + base = get_config().api_url.rstrip("/") + prefix = f"/{backend_path.strip('/')}" if backend_path.strip("/") else "" + + resp = httpx.get(f"{base}{prefix}/ping") + assert resp.status_code == 200 + + if prefix: + stray = httpx.get(f"{base}/ping") + assert stray.status_code == 404 + + +def test_state_event_roundtrip(backend_path_app: AppHarness, page: Page): + """Clicking a button triggers a state event through the websocket at the prefixed path.""" + assert backend_path_app.frontend_url is not None + page.goto(backend_path_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + expect(page.locator("#counter")).to_have_text("0") + page.click("#bump-btn") + expect(page.locator("#counter")).to_have_text("1") + page.click("#bump-btn") + expect(page.locator("#counter")).to_have_text("2") + + +def test_uploaded_file_download( + backend_path_app: AppHarness, backend_path: str, page: Page +): + """``get_upload_url`` emits a URL under backend_path and the file is served from it.""" + assert backend_path_app.frontend_url is not None + page.goto(backend_path_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + href = page.locator("#upload-link").get_attribute("href") + assert href is not None + + prefix = f"/{backend_path.strip('/')}" if backend_path.strip("/") else "" + if prefix: + assert prefix in href, f"upload URL {href} missing backend_path prefix {prefix}" + + resp = httpx.get(href, follow_redirects=True) + assert resp.status_code == 200 + assert resp.text == "hello from backend_path" diff --git a/tests/integration/tests_playwright/test_stateless_app.py b/tests/integration/tests_playwright/test_stateless_app.py index 9d3942fccc7..0451230a556 100644 --- a/tests/integration/tests_playwright/test_stateless_app.py +++ b/tests/integration/tests_playwright/test_stateless_app.py @@ -5,8 +5,9 @@ import httpx import pytest from playwright.sync_api import Page, expect +from reflex_base.config import get_config +from reflex_base.constants import Endpoint -import reflex as rx from reflex.testing import AppHarness @@ -49,10 +50,11 @@ def test_statelessness(stateless_app: AppHarness, page: Page): assert stateless_app.backend is not None assert stateless_app.backend.started - res = httpx.get(rx.config.get_config().api_url + "/_event") + config = get_config() + res = httpx.get(config.api_url + config.prepend_backend_path(str(Endpoint.EVENT))) assert res.status_code == 404 - res2 = httpx.get(rx.config.get_config().api_url + "/ping") + res2 = httpx.get(Endpoint.PING.get_url()) assert res2.status_code == 200 page.goto(stateless_app.frontend_url) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 38267c9c53b..266d7600c42 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -44,6 +44,7 @@ def test_set_app_name(base_config_values): ("REFLEX_FRONTEND_PORT", 3001), ("REFLEX_FRONTEND_PATH", "/test"), ("REFLEX_BACKEND_PORT", 8001), + ("REFLEX_BACKEND_PATH", "/api"), ("REFLEX_API_URL", "https://mybackend.com:8000"), ("REFLEX_DEPLOY_URL", "https://myfrontend.com"), ("REFLEX_BACKEND_HOST", "127.0.0.1"), @@ -144,6 +145,22 @@ def test_update_from_env_cors( {"app_name": "test_app", "api_url": "http://example.com/api"}, f"/api{Endpoint.EVENT}", ), + ( + { + "app_name": "test_app", + "api_url": "http://example.com", + "backend_path": "/api", + }, + f"/api{Endpoint.EVENT}", + ), + ( + { + "app_name": "test_app", + "api_url": "http://example.com", + "backend_path": "api/", + }, + f"/api{Endpoint.EVENT}", + ), ], ) def test_event_namespace(mocker: MockerFixture, kwargs, expected): @@ -162,6 +179,72 @@ def test_event_namespace(mocker: MockerFixture, kwargs, expected): assert config.get_event_namespace() == expected +@pytest.mark.parametrize( + ("backend_path", "path", "expected"), + [ + ("", "/ping", "/ping"), + ("/api", "/ping", "/api/ping"), + ("api", "/ping", "/api/ping"), + ("/api/", "/ping", "/api/ping"), + ("/api", "", ""), + ("/api", "relative/path", "relative/path"), + ], +) +def test_prepend_backend_path(backend_path: str, path: str, expected: str): + """Test that prepend_backend_path normalizes and prefixes paths correctly. + + Args: + backend_path: The configured backend_path. + path: The input path to prefix. + expected: The expected output. + """ + config = rx.Config(app_name="test_app", backend_path=backend_path) + assert config.prepend_backend_path(path) == expected + + +@pytest.mark.parametrize("backend_path", ["", "/api", "api/"]) +@pytest.mark.parametrize("endpoint", list(Endpoint)) +def test_endpoint_get_url_with_backend_path( + mocker: MockerFixture, backend_path: str, endpoint: Endpoint +): + """Endpoint.get_url() includes backend_path; WS protocol swap still works for EVENT. + + Args: + mocker: The pytest mock object. + backend_path: The configured backend_path. + endpoint: The endpoint to generate a URL for. + """ + conf = rx.Config( + app_name="test_app", + api_url="http://example.com", + backend_path=backend_path, + ) + mocker.patch("reflex_base.config.get_config", return_value=conf) + + url = endpoint.get_url() + prefix = f"/{backend_path.strip('/')}" if backend_path.strip("/") else "" + if endpoint is Endpoint.EVENT: + assert url == f"ws://example.com{prefix}{endpoint}" + else: + assert url == f"http://example.com{prefix}{endpoint}" + + +def test_get_event_namespace_matches_mount_path(mocker: MockerFixture): + """Socket.IO namespace must equal the HTTP mount path for EVENT. + + Args: + mocker: The pytest mock object. + """ + conf = rx.Config( + app_name="test_app", + api_url="http://example.com", + backend_path="/api", + ) + mocker.patch("reflex_base.config.get_config", return_value=conf) + + assert conf.get_event_namespace() == conf.prepend_backend_path(str(Endpoint.EVENT)) + + DEFAULT_CONFIG = rx.Config(app_name="a") From 533dea4e3346fe495574ae2090ecaefc6a78f1c6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 17 Apr 2026 20:45:14 +0500 Subject: [PATCH 2/3] test: bind BackendPathApp result to `app` so harness can load it --- tests/integration/tests_playwright/test_backend_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/tests_playwright/test_backend_path.py b/tests/integration/tests_playwright/test_backend_path.py index 3fe02e39cf9..03bfcfb369b 100644 --- a/tests/integration/tests_playwright/test_backend_path.py +++ b/tests/integration/tests_playwright/test_backend_path.py @@ -50,7 +50,7 @@ def index(): ), ) - rx.App() + app = rx.App() # noqa: F841 @pytest.fixture( From bc80862248a0f33f26a0e53b6e050f8c78d5127f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 17 Apr 2026 20:55:01 +0500 Subject: [PATCH 3/3] greptile maxxing --- reflex/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reflex/app.py b/reflex/app.py index bd5d40597ba..514a7a894a8 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1563,8 +1563,9 @@ def add_all_routes_endpoint(self): def all_routes(_request: Request) -> Response: return JSONResponse(list(self._unevaluated_pages.keys())) + config = get_config() self._api.add_route( - get_config().prepend_backend_path(str(constants.Endpoint.ALL_ROUTES)), + config.prepend_backend_path(str(constants.Endpoint.ALL_ROUTES)), all_routes, methods=["GET"], )