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
47 changes: 47 additions & 0 deletions docs/hosting/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,53 @@ this, use the `--no-zip` parameter. This provides the frontend in the
`.web/build/client/` directory and the backend can be found in the root directory of
the project.

## Reusing a Production Frontend Build

For repeated deployments with deterministic frontend builds, you can opt in to a
local build cache:

```bash
REFLEX_FRONTEND_BUILD_CACHE=true reflex deploy
```

The same option works with `reflex export` and production-mode `reflex run`.
It is disabled by default. On a cache hit, Reflex restores the pristine JavaScript
build, then runs post-build plugins, fallback generation, compression, and frontend
path processing again. Python compilation and the normal dependency checks still run.

On macOS, Linux, and Windows, production exports sharing `.web` wait for one
another from compilation through ZIP creation. Production and preview startup
also hold this lock while compiling and building, even when caching is disabled,
and release it before serving. The lock file `.web/.reflex-build.lock` remains in
place between commands; do not remove it while a command is running.
Initialization, development hot reload, and unrelated tools writing to `.web` are
outside this lock, so avoid running them during an export.

Enable this option only when build output is determined by the tracked local inputs.
Prerendering, Vite plugins, and custom export scripts can read remote data, the clock,
or files outside `.web`; changes to those inputs require a fresh build. Run with
`REFLEX_FRONTEND_BUILD_CACHE=false` to force one and discard the previous snapshot,
or remove `.web/reflex.build-cache`. Re-enabling the option then populates a new cache.

The cache checks generated frontend source, assets and configuration within `.web`,
the build environment, runtime identity, and installed dependency file metadata.
It also verifies snapshot file contents before restoring them. Use it on a local
macOS or Linux filesystem that reports file modification and change timestamps
reliably; the cache is bypassed on Windows and when links lead outside tracked inputs.

Generated build output, `.react-router`, the build lock, the dependency-install cache marker, and
the top-level `node_modules/.vite`, `.vite-temp`, and `.cache` directories are
excluded from the input fingerprint. The private `last_reflex_run_datetime`,
`last_version_check_datetime`, and `last_version_check_attempt_datetime` fields in
`reflex.json`, including their per-package timestamp variants, are also excluded. Custom code
that uses these excluded values or files to determine build output should keep the
cache disabled. Cache hits can retain the earlier private timestamps embedded in
bundles; the client framework uses the separately checked Reflex version value.

Cache misses perform a normal build and have extra fingerprinting and snapshot-copy
work, so this option is most useful when the same frontend is deployed repeatedly.
Deleting `.web` also deletes its cached build.

The export also writes a pre-compressed `.gz` copy of the compressible text
assets (JS, CSS, HTML, JSON, SVG, and similar), so configure the static host
to serve those directly where it supports it. Set the
Expand Down
1 change: 1 addition & 0 deletions news/+cache-production-frontend.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add an opt-in local production frontend build cache with `REFLEX_FRONTEND_BUILD_CACHE=true` for deterministic repeat builds on macOS and Linux. Production exports sharing a frontend directory are serialized; post-build hooks and compression still run, and setting the variable to `false` forces a fresh build and discards cached output.
1 change: 1 addition & 0 deletions news/+faster-deploy-archives.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Speed up deployment archive creation by reducing filesystem checks for excluded files and avoiding recompression of precompressed frontend assets.
1 change: 1 addition & 0 deletions news/+preserve-formatted-package-cache.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Avoid reinstalling frontend dependencies solely because the package manager reformatted `package.json`. Actual changes to dependency versions, scripts, and other manifest values still invalidate the install cache.
1 change: 1 addition & 0 deletions news/+reuse-installed-framework-pins.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Speed up frontend setup during deploys by reusing matching framework dependencies after installing from a persisted lockfile. Repair npm development dependency placement and keep build tools installed when `NODE_ENV=production`.
1 change: 1 addition & 0 deletions news/+skip-cached-version-check.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Handle timezone-aware version-check timestamps while preserving per-package caching and failure cooldowns. Failed HTTP responses are not recorded as successful checks.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the `REFLEX_FRONTEND_BUILD_CACHE` environment option for opting into local reuse of deterministic production frontend builds. It is disabled by default.
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,10 @@ class EnvironmentVariables:
# Whether to use npm over bun to install and run the frontend.
REFLEX_USE_NPM: EnvVar[bool] = env_var(False)

# Opt in to local reuse of deterministic production frontend builds.
# External/time-dependent build inputs require a forced fresh build.
REFLEX_FRONTEND_BUILD_CACHE: EnvVar[bool] = env_var(False)

# The npm registry to use.
NPM_CONFIG_REGISTRY: EnvVar[str | None] = env_var(None)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Speed up non-interactive deploys by skipping the provider availability request used only for the interactive provider prompt.
4 changes: 2 additions & 2 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ def _resolve_deploy_provider(
# Explicit --provider always wins; validated by the caller already.
target = hosting.normalize_provider(provider_arg)
else:
gcp_status = hosting.gcp_deploy_available(client)
if not gcp_status or not interactive:
gcp_status = hosting.gcp_deploy_available(client) if interactive else None
if not gcp_status:
# No GCP connected, or non-interactive with no explicit choice: keep
# whatever the app already targets.
target = current
Expand Down
57 changes: 41 additions & 16 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
from collections.abc import Callable
from contextlib import nullcontext
from importlib import import_module
from importlib.util import find_spec
from pathlib import Path
Expand Down Expand Up @@ -463,21 +464,33 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str):
"""
import atexit

from reflex.utils import build, exec, processes, telemetry
from reflex.utils import (
build,
build_cache,
exec,
prerequisites,
processes,
telemetry,
)

config = get_config()

config._set_persistent(frontend_port=port, backend_port=port)
with (
build_cache.frontend_build_lock(prerequisites.get_web_dir())
if running_mode.has_frontend()
else nullcontext()
):
config._set_persistent(frontend_port=port, backend_port=port)

# Mount the compiled frontend into the dev backend so no Vite server is needed.
environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(
running_mode.has_frontend() and running_mode.has_backend()
)
# Mount the compiled frontend into the dev backend so no Vite server is needed.
environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(
running_mode.has_frontend() and running_mode.has_backend()
)

if running_mode.has_frontend():
# Compile the app and produce the initial frontend build.
_compile_app()
build.setup_frontend_prod(Path.cwd())
if running_mode.has_frontend():
# Compile the app and produce the initial frontend build.
_compile_app()
build.setup_frontend_prod(Path.cwd())

# Post a telemetry event.
telemetry.send("run-preview")
Expand All @@ -502,16 +515,28 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str):
def _run_prod(running_mode: constants.RunningMode, port: int, host: str):
import atexit

from reflex.utils import build, exec, processes, telemetry
from reflex.utils import (
build,
build_cache,
exec,
prerequisites,
processes,
telemetry,
)

config = get_config()

config._set_persistent(frontend_port=port, backend_port=port)
with (
build_cache.frontend_build_lock(prerequisites.get_web_dir())
if running_mode.has_frontend()
else nullcontext()
):
config._set_persistent(frontend_port=port, backend_port=port)

if running_mode.has_frontend():
# Get the app module.
_compile_app(avoid_dirty_check=False)
build.setup_frontend_prod(Path.cwd())
if running_mode.has_frontend():
# Get the app module.
_compile_app(avoid_dirty_check=False)
build.setup_frontend_prod(Path.cwd())

_skip_compile()

Expand Down
110 changes: 95 additions & 15 deletions reflex/utils/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
from reflex_base import constants
from reflex_base.config import get_config

from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes
from reflex.utils import (
build_cache,
console,
js_runtimes,
path_ops,
prerequisites,
processes,
)
from reflex.utils.exec import frontend_env, is_in_app_harness

logger = logging.getLogger(__name__)
Expand All @@ -34,6 +41,41 @@ def set_env_json():
)


def _zip_compress_type(component_name: constants.ComponentName, file: Path) -> int:
"""Select compression suitable for one archive entry.

Args:
component_name: The archive being created.
file: The source file being archived.

Returns:
The ZIP compression type for the file.
"""
if component_name == constants.ComponentName.FRONTEND and file.suffix in {
".gz",
".br",
".zst",
}:
return zipfile.ZIP_STORED
return zipfile.ZIP_DEFLATED


def _is_excluded_archive_path(
path: Path, excluded_file_ids: set[tuple[int, int]]
) -> bool:
"""Check whether an archive path has an excluded file identity.

Args:
path: The path being considered for the archive.
excluded_file_ids: Device and inode pairs that must be excluded.

Returns:
Whether the path refers to an excluded file or directory.
"""
stat = path.stat()
return (stat.st_dev, stat.st_ino) in excluded_file_ids


def _zip(
*,
component_name: constants.ComponentName,
Expand Down Expand Up @@ -62,6 +104,12 @@ def _zip(
root_directory = Path(root_directory).resolve()
directory_names_to_exclude = directory_names_to_exclude or set()
files_to_exclude = files_to_exclude or set()
excluded_file_ids = set()
for excluded_file in files_to_exclude:
if excluded_file.exists():
stat = excluded_file.stat()
excluded_file_ids.add((stat.st_dev, stat.st_ino))

files_to_zip: list[Path] = []
# Traverse the root directory in a top-down manner. In this traversal order,
# we can modify the dirs list in-place to remove directories we don't want to include.
Expand All @@ -74,10 +122,11 @@ def _zip(
subdirectory_name
for subdirectory_name in subdirectories_names
if subdirectory_name not in directory_names_to_exclude
and not any(
(directory_path / subdirectory_name).samefile(exclude)
for exclude in files_to_exclude
if exclude.exists()
and (
not excluded_file_ids
or not _is_excluded_archive_path(
directory_path / subdirectory_name, excluded_file_ids
)
)
and not subdirectory_name.startswith(".")
and (
Expand All @@ -95,10 +144,9 @@ def _zip(
files_to_zip += [
directory_path / subfile_name
for subfile_name in subfiles_names
if not any(
(directory_path / subfile_name).samefile(excluded_file)
for excluded_file in files_to_exclude
if excluded_file.exists()
if not excluded_file_ids
or not _is_excluded_archive_path(
directory_path / subfile_name, excluded_file_ids
)
]
if globs_to_include:
Expand All @@ -118,7 +166,11 @@ def _zip(
for file in files_to_zip:
logger.debug(f"{target}: {file}", extra={"progress": progress})
progress.advance(task)
zipf.write(file, Path(file).relative_to(root_directory))
zipf.write(
file,
file.relative_to(root_directory),
compress_type=_zip_compress_type(component_name, file),
)


def zip_app(
Expand Down Expand Up @@ -253,7 +305,31 @@ def build():
SystemExit: If the build process fails.
"""
wdir = prerequisites.get_web_dir()
command = [
*js_runtimes.get_js_package_executor(raise_on_none=True)[0],
"run",
"export",
]
with build_cache.frontend_build_cache(wdir, command) as cache:
Comment thread
Alek99 marked this conversation as resolved.
if cache is None or not cache.restore():
_build_frontend(wdir, command)
if cache is not None:
cache.capture()
_postprocess_frontend(wdir)
if cache is not None:
cache.commit()
Comment thread
Alek99 marked this conversation as resolved.


def _build_frontend(wdir: Path, command: list[str]) -> None:
"""Run a fresh production JavaScript build.

Args:
wdir: Frontend working directory.
command: Package manager export command.

Raises:
SystemExit: The frontend build failed.
"""
# Clean the static directory if it exists.
path_ops.rm(str(wdir / constants.Dirs.BUILD_DIR))

Expand All @@ -266,11 +342,7 @@ def build():

# Start the subprocess with the progress bar.
process = processes.new_process(
[
*js_runtimes.get_js_package_executor(raise_on_none=True)[0],
"run",
"export",
],
command,
cwd=wdir,
shell=constants.IS_WINDOWS,
env=frontend_env(os.environ),
Expand All @@ -282,6 +354,14 @@ def build():
"Failed to build the frontend. Please run with --loglevel debug for more information.",
)
raise SystemExit(1)


def _postprocess_frontend(wdir: Path) -> None:
"""Apply build hooks and serving transformations to pristine frontend output.

Args:
wdir: Frontend working directory.
"""
config = get_config()
static_dir = wdir / constants.Dirs.STATIC

Expand Down
Loading
Loading