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
109 changes: 109 additions & 0 deletions packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* ISR render tier for Reflex.
*
* A small HTTP service that renders the react-router **server bundle** to HTML
* for a given route, so the Python ISR page-server (reflex.isr.HttpRenderer)
* can cache and serve it. This is the one component that must run JavaScript;
* it is deployed as its own service (off the request hot path, scale-to-zero
* friendly) rather than in every frontend pod.
*
* Contract (matches reflex.isr.HttpRenderer):
* POST / { "path": "/blog/hello" }
* 200 -> { "html": "<!DOCTYPE html>…", "revalidate"?: number, "tags"?: string[] }
* non-200 -> ISR treats it as a render failure (serves stale/shell)
*
* Requirements:
* - An SSR build of the app (react-router build with `ssr: true`) producing
* `build/server/index.js`. The app root `loader()` fetches page state from
* the Python `/_ssr_data` endpoint, so this service needs network access to
* the backend (set SSR_DATA / the backend URL in the app env).
* - Node deps: `react-router`, `express`.
*
* Run: node isr-render.mjs (listens on $PORT, default 8600)
*
* Optional per-page cache hints: the app may set response headers
* `X-Reflex-Revalidate: <seconds>` and `X-Reflex-Tags: a,b,c`
* which are forwarded to the ISR cache; otherwise Python applies its defaults.
*/
import { createRequestHandler } from "react-router";
import express from "express";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname =
import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));

const serverBuild = join(__dirname, "build", "server", "index.js");
const build = await import(serverBuild);
const handler = createRequestHandler(build, "production");

const app = express();
app.use(express.json({ limit: "1mb" }));

// Health endpoint for k8s probes.
app.get("/_health", (_req, res) => res.status(200).send("OK"));

app.post("/", async (req, res) => {
const path =
typeof req.body?.path === "string" && req.body.path ? req.body.path : "/";

try {
// Render the target route through react-router as a normal GET document
// request. The app's loader runs here and pulls state from /_ssr_data.
const url = new URL(path, "http://reflex-isr-render.local");
const request = new Request(url, {
method: "GET",
headers: {
"User-Agent": "ReflexISRRenderer/1.0",
Accept: "text/html",
// Forward the caller's cookies so authenticated/personalized loaders
// resolve the same way they would for the original visitor, if the
// page-server chooses to pass them along.
...(req.body?.cookie ? { Cookie: String(req.body.cookie) } : {}),
},
});

const response = await handler(request);
const html = await response.text();

if (!response.ok || !html) {
return res
.status(502)
.json({ error: "render_failed", status: response.status });
}

const revalidate = headerNumber(response, "x-reflex-revalidate");
const tags = headerList(response, "x-reflex-tags");

return res.json({
html,
...(revalidate != null ? { revalidate } : {}),
...(tags.length ? { tags } : {}),
});
} catch (err) {
console.error("[isr-render] render error for", path, err);
return res.status(500).json({ error: String(err) });
}
});

function headerNumber(response, name) {
const raw = response.headers.get(name);
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}

function headerList(response, name) {
const raw = response.headers.get(name);
return raw
? raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
}

const port = parseInt(process.env.PORT || "8600", 10);
app.listen(port, () => {
console.log(`[isr-render] listening on http://localhost:${port}`);
});
68 changes: 62 additions & 6 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,62 @@ def app_root_template(
f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries
])

from reflex_base import ssr

if ssr.is_enabled():
ssr_imports = (
'import { useLoaderData } from "react-router";\n'
'import { getBackendURL } from "$/utils/state";\n'
'import { SSRStateContext } from "$/utils/context";\n'
'import env from "$/env.json";'
)
ssr_loader = """
export async function loader({ request }) {
// Fetch on_load-hydrated state from the Python /_ssr_data endpoint so the
// server-rendered HTML already contains real data.
try {
const res = await fetch(getBackendURL(env.SSR_DATA), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(request.headers.get("cookie") ? { Cookie: request.headers.get("cookie") } : {}),
},
body: JSON.stringify({
path: new URL(request.url).pathname,
headers: Object.fromEntries(request.headers),
}),
});
if (res.ok) return res.json();
} catch (e) {
console.error("SSR data fetch failed:", e);
}
return { state: null };
}
"""
layout_body = (
"const loaderData = useLoaderData();\n"
" const ssrState = loaderData?.state ?? null;\n"
" return jsx(AppLayout, {}, "
"jsx(SSRStateContext.Provider, {value: ssrState}, "
"jsx(ReflexProviders, {}, children)));"
)
else:
ssr_imports = ""
ssr_loader = ""
layout_body = "return jsx(AppLayout, {}, jsx(ReflexProviders, {}, children));"

return f"""
{imports_str}
{dynamic_imports_str}
import {{ defaultColorMode }} from "$/utils/context";
import {{ ThemeProvider }} from '$/utils/react-theme';
import {{ Layout as AppLayout }} from './_document';
import {{ Outlet }} from 'react-router';
{ssr_imports}
{import_window_libraries}

{custom_code_str}

{ssr_loader}
function ReflexProviders({{children}}) {{
useEffect(() => {{
// Make contexts and state objects available globally for dynamic eval'd components
Expand All @@ -240,7 +285,7 @@ def app_root_template(
}}

export function Layout({{children}}) {{
return jsx(AppLayout, {{}}, jsx(ReflexProviders, {{}}, children));
{layout_body}
}}

// Used by entry.client.js when mount_target is configured: skips the document
Expand Down Expand Up @@ -289,6 +334,9 @@ def context_template(
Returns:
Rendered context file content as string.
"""
from reflex_base import ssr

ssr_enabled = ssr.is_enabled()
initial_state = initial_state or {}
state_contexts_str = "".join([
f"{format_state_name(state_name)}: createContext(null),"
Expand Down Expand Up @@ -342,10 +390,16 @@ def context_template(
"""
)

state_reducer_str = "\n".join(
rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])'
for state_name in initial_state
)
if ssr_enabled:
state_reducer_str = "\n".join(
rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, (ssrState && ssrState["{state_name}"] != null) ? ssrState["{state_name}"] : initialState["{state_name}"])'
for state_name in initial_state
)
else:
state_reducer_str = "\n".join(
rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])'
for state_name in initial_state
)

create_state_contexts_str = "\n".join(
rf"createElement(StateContexts.{format_state_name(state_name)},{{value: {format_state_name(state_name)}}},"
Expand Down Expand Up @@ -374,6 +428,7 @@ def context_template(
export const DispatchContext = createContext(null);
export const StateContexts = {{{state_contexts_str}}};
export const EventLoopContext = createContext(null);
{"export const SSRStateContext = createContext(null);" if ssr_enabled else ""}
export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)}

{state_str}
Expand Down Expand Up @@ -446,6 +501,7 @@ def context_template(
}}

export function StateProvider({{ children }}) {{
{"const ssrState = useContext(SSRStateContext);" if ssr_enabled else ""}
{state_reducer_str}
const dispatchers = useMemo(() => {{
return {{
Expand Down
18 changes: 18 additions & 0 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ class BaseConfig:
plugins: List of plugins to use in the app.
disable_plugins: List of plugin types to disable in the app.
transport: The transport method for client-server communication.
ssr_mode: Server-side rendering mode ("off", "bot_only", or "always").
isr_render_url: ISR render-tier URL; when set, enables ISR page serving.
isr_revalidate: Default seconds until an ISR-cached page goes stale.
isr_build_id: Identifier that rotates the ISR cache on each deploy.
"""

app_name: str
Expand Down Expand Up @@ -271,6 +275,20 @@ class BaseConfig:

transport: Literal["websocket", "polling"] = "websocket"

# Server-side rendering mode: "off" (default), "bot_only", or "always".
ssr_mode: constants.SsrMode = constants.SsrMode.OFF

# ISR render-tier URL. When set, the ISR page-server regenerates pages by
# POSTing paths here; when None, ISR is disabled.
isr_render_url: str | None = None

# Default seconds until an ISR-cached page is considered stale.
isr_revalidate: int = 60

# Identifier that rotates the ISR cache on each deploy. Defaults at runtime
# to the REFLEX_BUILD_ID env var (see reflex.isr.get_build_id).
isr_build_id: str | None = None

# Whether to skip plugin checks.
_skip_plugins_checks: bool = dataclasses.field(default=False, repr=False)

Expand Down
2 changes: 2 additions & 0 deletions packages/reflex-base/src/reflex_base/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Reflex,
ReflexHostingCLI,
RunningMode,
SsrMode,
Templates,
)
from .compiler import (
Expand Down Expand Up @@ -129,6 +130,7 @@
"RouteVar",
"RunningMode",
"SocketEvent",
"SsrMode",
"StateManagerMode",
"Templates",
"UvLock",
Expand Down
17 changes: 15 additions & 2 deletions packages/reflex-base/src/reflex_base/constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ class ReactRouter(Javascript):
DEV_FRONTEND_LISTENING_REGEX = r"Local:[\s]+"

# Regex to pattern the route path in the config file
# INFO Accepting connections at http://localhost:3000
PROD_FRONTEND_LISTENING_REGEX = r"Accepting connections at[\s]+"
# Matches output from sirv ("Accepting connections at") or ssr-serve ("[ssr-serve]").
PROD_FRONTEND_LISTENING_REGEX = r"(?:Accepting connections at|\[ssr-serve\])[\s]+"

FRONTEND_LISTENING_REGEX = (
rf"(?:{DEV_FRONTEND_LISTENING_REGEX}|{PROD_FRONTEND_LISTENING_REGEX})(.*)"
Expand Down Expand Up @@ -203,6 +203,19 @@ class Env(str, Enum):
PROD = "prod"


class SsrMode(str, Enum):
"""Server-side rendering modes.

OFF: No SSR — static SPA (default, same as before).
BOT_ONLY: SSR for crawlers/bots only — regular users get the SPA shell.
ALWAYS: SSR for all users — better FCP/LCP, higher server load.
"""

OFF = "off"
BOT_ONLY = "bot_only"
ALWAYS = "always"


class RunningMode(str, Enum):
"""The running modes."""

Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/src/reflex_base/constants/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Endpoint(Enum):
AUTH_CODESPACE = "auth-codespace"
HEALTH = "_health"
ALL_ROUTES = "_all_routes"
SSR_DATA = "_ssr_data"

def __str__(self) -> str:
"""Get the string representation of the endpoint.
Expand Down
46 changes: 46 additions & 0 deletions packages/reflex-base/src/reflex_base/ssr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Server-side rendering (SSR) config gate for the base package.
The request-time backend (the ``/_ssr_data`` endpoint) lives in ``reflex.ssr``
because it depends on the state/app machinery that is not part of
``reflex_base``. Only the config gate lives here so ``reflex_base`` code can
check whether SSR is enabled without importing the top-level ``reflex`` package.
SSR is a no-op unless ``config.ssr_mode`` is not ``OFF``.
"""

from __future__ import annotations

import os
from typing import TYPE_CHECKING

from reflex_base import constants
from reflex_base.config import get_config

if TYPE_CHECKING:
from reflex_base.config import Config


def is_enabled(config: Config | None = None) -> bool:
"""Whether SSR is enabled.
Args:
config: The config to read from, or the global config when omitted.
Returns:
True when ``config.ssr_mode`` is not ``OFF``.
"""
config = config if config is not None else get_config()
return config.ssr_mode != constants.SsrMode.OFF


def ssr_build_enabled() -> bool:
"""Whether to emit an ``ssr: true`` react-router build.
Controlled by the ``REFLEX_SSR_BUILD`` env var so the same app can produce
both the default static SPA build (served by nginx) and a server-render
build (consumed by the ISR render tier) from separate build invocations.
Returns:
True when ``REFLEX_SSR_BUILD`` is set to ``1``.
"""
return os.environ.get("REFLEX_SSR_BUILD") == "1"
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
"packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f",
"packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a",
"packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "58521fcd1b514804f534d97624e82c9a",
"reflex/__init__.pyi": "56385a4f0d9431eb0056dbc5553a58f9",
"reflex/__init__.pyi": "5f82b639e054023bc5e23a867c0f98ff",
"reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388",
"reflex/experimental/memo.pyi": "36e5d5f97eb64e94c0974e909e7e2952"
}
2 changes: 1 addition & 1 deletion reflex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
"app": ["App", "UploadFile"],
"assets": ["asset"],
"config": ["Config", "DBConfig"],
"constants": ["Env"],
"constants": ["Env", "SsrMode"],
"constants.colors": ["Color"],
"_upload": [
"UploadChunk",
Expand Down
Loading
Loading