Skip to content

Commit fc7221a

Browse files
feat: pre-compress frontend assets at build time (#6497)
* feat: pre-compress frontend assets at build time Generate gzip/brotli/zstd sidecars during `reflex export` and serve the best match via a new PrecompressedStaticFiles handler. Adds a configurable `frontend_compression_formats` (defaults to gzip) and switches the prod test harness off the stdlib http.server in favor of the same Starlette/Uvicorn stack used in production so the precompressed path is exercised under integration tests. * assert on content in test_precompressed_staticfiles --------- Co-authored-by: Masen Furer <m_github@0x26.net>
1 parent faec12b commit fc7221a

12 files changed

Lines changed: 970 additions & 119 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/* Generate pre-compressed sidecars for the exported static frontend so
2+
* production servers can serve them directly without on-the-fly compression. */
3+
4+
import * as zlib from "node:zlib";
5+
import { join } from "node:path";
6+
import { readdir, readFile, writeFile } from "node:fs/promises";
7+
import { promisify } from "node:util";
8+
9+
const gzipAsync = promisify(zlib.gzip);
10+
const brotliAsync =
11+
typeof zlib.brotliCompress === "function"
12+
? promisify(zlib.brotliCompress)
13+
: null;
14+
const zstdAsync =
15+
typeof zlib.zstdCompress === "function" ? promisify(zlib.zstdCompress) : null;
16+
17+
const COMPRESSIBLE_EXTENSIONS = /\.(js|css|html|json|svg|xml|txt|map|mjs)$/;
18+
const MIN_SIZE = 256;
19+
const CONCURRENCY = 16;
20+
21+
const COMPRESSORS = {
22+
gzip: {
23+
extension: ".gz",
24+
compress: (raw) => gzipAsync(raw, { level: 9 }),
25+
},
26+
brotli: brotliAsync && {
27+
extension: ".br",
28+
compress: (raw) =>
29+
brotliAsync(raw, {
30+
params: {
31+
[zlib.constants.BROTLI_PARAM_QUALITY]:
32+
zlib.constants.BROTLI_MAX_QUALITY,
33+
},
34+
}),
35+
},
36+
zstd: zstdAsync && {
37+
extension: ".zst",
38+
compress: (raw) => zstdAsync(raw),
39+
},
40+
};
41+
42+
const SIDECAR_SUFFIXES = Object.values(COMPRESSORS)
43+
.filter(Boolean)
44+
.map((c) => c.extension);
45+
46+
async function* walkFiles(directory) {
47+
for (const entry of await readdir(directory, { withFileTypes: true })) {
48+
const entryPath = join(directory, entry.name);
49+
if (entry.isDirectory()) {
50+
yield* walkFiles(entryPath);
51+
} else if (entry.isFile()) {
52+
yield entryPath;
53+
}
54+
}
55+
}
56+
57+
function ensureFormatsSupported(formats) {
58+
const unavailableFormats = formats.filter(
59+
(format) => !COMPRESSORS[format]?.compress,
60+
);
61+
if (unavailableFormats.length > 0) {
62+
throw new Error(
63+
`The configured frontend compression formats are not supported by this Node.js runtime: ${unavailableFormats.join(", ")}`,
64+
);
65+
}
66+
}
67+
68+
async function compressFile(filePath, formats) {
69+
const raw = await readFile(filePath);
70+
// Always compress HTML entrypoints so their negotiated sidecars exist.
71+
if (raw.length < MIN_SIZE && !filePath.endsWith(".html")) return;
72+
73+
await Promise.all(
74+
formats.map((format) => {
75+
const compressor = COMPRESSORS[format];
76+
return compressor
77+
.compress(raw)
78+
.then((compressed) =>
79+
writeFile(filePath + compressor.extension, compressed),
80+
);
81+
}),
82+
);
83+
}
84+
85+
async function compressDirectory(directory, formats) {
86+
ensureFormatsSupported(formats);
87+
88+
const pending = [];
89+
for await (const filePath of walkFiles(directory)) {
90+
if (
91+
COMPRESSIBLE_EXTENSIONS.test(filePath) &&
92+
!SIDECAR_SUFFIXES.some((suffix) => filePath.endsWith(suffix))
93+
) {
94+
pending.push(filePath);
95+
}
96+
}
97+
98+
for (let i = 0; i < pending.length; i += CONCURRENCY) {
99+
await Promise.all(
100+
pending
101+
.slice(i, i + CONCURRENCY)
102+
.map((file) => compressFile(file, formats)),
103+
);
104+
}
105+
}
106+
107+
const [directory, ...formats] = process.argv.slice(2);
108+
109+
if (!directory) {
110+
throw new Error("Missing static output directory for compression.");
111+
}
112+
113+
await compressDirectory(directory, formats);

packages/reflex-base/src/reflex_base/config.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ class BaseConfig:
168168
cors_allowed_origins: Comma separated list of origins that are allowed to connect to the backend API.
169169
vite_allowed_hosts: Allowed hosts for the Vite dev server. Set to True to allow all hosts, or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones. Prevents 403 errors in Docker, Codespaces, reverse proxies, etc.
170170
react_strict_mode: Whether to use React strict mode.
171+
frontend_compression_formats: Pre-compressed frontend asset formats to generate for production builds. Supported values are "gzip", "brotli", and "zstd". Use an empty list to disable build-time pre-compression.
171172
frontend_packages: Additional frontend packages to install.
172173
state_manager_mode: Indicate which type of state manager to use.
173174
redis_lock_expiration: Maximum expiration lock time for redis state manager.
@@ -224,6 +225,11 @@ class BaseConfig:
224225

225226
react_strict_mode: bool = True
226227

228+
frontend_compression_formats: Annotated[
229+
list[str],
230+
SequenceOptions(delimiter=",", strip=True),
231+
] = dataclasses.field(default_factory=lambda: ["gzip"])
232+
227233
frontend_packages: list[str] = dataclasses.field(default_factory=list)
228234

229235
state_manager_mode: constants.StateManagerMode = constants.StateManagerMode.DISK
@@ -308,7 +314,7 @@ class Config(BaseConfig):
308314
- **App Settings**: `app_name`, `loglevel`, `telemetry_enabled`
309315
- **Server**: `frontend_port`, `backend_port`, `api_url`, `cors_allowed_origins`
310316
- **Database**: `db_url`, `async_db_url`, `redis_url`
311-
- **Frontend**: `frontend_packages`, `react_strict_mode`
317+
- **Frontend**: `frontend_packages`, `react_strict_mode`, `frontend_compression_formats`
312318
- **State Management**: `state_manager_mode`, `state_auto_setters`
313319
- **Plugins**: `plugins`, `disable_plugins`
314320
@@ -348,6 +354,8 @@ def _post_init(self, **kwargs):
348354
for key, env_value in env_kwargs.items():
349355
setattr(self, key, env_value)
350356

357+
self._normalize_frontend_compression_formats()
358+
351359
# Normalize route prefixes to ensure they start with a slash.
352360
self._normalize_paths()
353361

@@ -457,6 +465,29 @@ def _normalize_disable_plugins(self):
457465
)
458466
self.disable_plugins = normalized
459467

468+
def _normalize_frontend_compression_formats(self):
469+
"""Normalize and validate configured frontend compression formats.
470+
471+
Raises:
472+
ConfigError: If an unsupported format name is configured.
473+
"""
474+
supported = {"brotli", "gzip", "zstd"}
475+
normalized: list[str] = []
476+
seen: set[str] = set()
477+
for format_name in self.frontend_compression_formats:
478+
name = format_name.strip().lower()
479+
if not name or name in seen:
480+
continue
481+
if name not in supported:
482+
msg = (
483+
f"frontend_compression_formats contains unsupported format "
484+
f"{format_name!r}. Expected one of: {', '.join(sorted(supported))}."
485+
)
486+
raise ConfigError(msg)
487+
normalized.append(name)
488+
seen.add(name)
489+
self.frontend_compression_formats = normalized
490+
460491
def _normalize_paths(self):
461492
"""Ensure frontend and backend paths start with a slash if provided."""
462493
if self.frontend_path and not self.frontend_path.startswith("/"):

reflex/testing.py

Lines changed: 34 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import re
1414
import signal
1515
import socket
16-
import socketserver
1716
import subprocess
1817
import sys
1918
import textwrap
@@ -22,7 +21,6 @@
2221
import types
2322
from collections.abc import Callable, Coroutine, Sequence
2423
from copy import deepcopy
25-
from http.server import SimpleHTTPRequestHandler
2624
from importlib.util import find_spec
2725
from pathlib import Path
2826
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
@@ -38,6 +36,7 @@
3836
import reflex
3937
import reflex.reflex
4038
import reflex.utils.build
39+
import reflex.utils.exec
4140
import reflex.utils.format
4241
import reflex.utils.prerequisites
4342
import reflex.utils.processes
@@ -801,115 +800,28 @@ def expect(
801800
)
802801

803802

804-
class SimpleHTTPRequestHandlerCustomErrors(SimpleHTTPRequestHandler):
805-
"""SimpleHTTPRequestHandler with custom error page handling."""
806-
807-
def __init__(self, *args, error_page_map: dict[int, Path], **kwargs):
808-
"""Initialize the handler.
809-
810-
Args:
811-
error_page_map: map of error code to error page path
812-
*args: passed through to superclass
813-
**kwargs: passed through to superclass
814-
"""
815-
self.error_page_map = error_page_map
816-
super().__init__(*args, **kwargs)
817-
818-
def send_error(
819-
self, code: int, message: str | None = None, explain: str | None = None
820-
) -> None:
821-
"""Send the error page for the given error code.
822-
823-
If the code matches a custom error page, then message and explain are
824-
ignored.
825-
826-
Args:
827-
code: the error code
828-
message: the error message
829-
explain: the error explanation
830-
"""
831-
error_page = self.error_page_map.get(code)
832-
if error_page:
833-
self.send_response(code, message)
834-
self.send_header("Connection", "close")
835-
body = error_page.read_bytes()
836-
self.send_header("Content-Type", self.error_content_type)
837-
self.send_header("Content-Length", str(len(body)))
838-
self.end_headers()
839-
self.wfile.write(body)
840-
else:
841-
super().send_error(code, message, explain)
842-
843-
844-
class Subdir404TCPServer(socketserver.TCPServer):
845-
"""TCPServer for SimpleHTTPRequestHandlerCustomErrors that serves from a subdir."""
846-
847-
def __init__(
848-
self,
849-
*args,
850-
root: Path,
851-
error_page_map: dict[int, Path] | None,
852-
**kwargs,
853-
):
854-
"""Initialize the server.
855-
856-
Args:
857-
root: the root directory to serve from
858-
error_page_map: map of error code to error page path
859-
*args: passed through to superclass
860-
**kwargs: passed through to superclass
861-
"""
862-
self.root = root
863-
self.error_page_map = error_page_map or {}
864-
super().__init__(*args, **kwargs)
865-
866-
def finish_request(self, request: socket.socket, client_address: tuple[str, int]):
867-
"""Finish one request by instantiating RequestHandlerClass.
868-
869-
Args:
870-
request: the requesting socket
871-
client_address: (host, port) referring to the client's address.
872-
"""
873-
self.RequestHandlerClass(
874-
request,
875-
client_address,
876-
self,
877-
directory=str(self.root), # pyright: ignore [reportCallIssue]
878-
error_page_map=self.error_page_map, # pyright: ignore [reportCallIssue]
879-
)
880-
881-
882803
class AppHarnessProd(AppHarness):
883804
"""AppHarnessProd executes a reflex app in-process for testing.
884805
885806
In prod mode, instead of running `react-router dev` the app is exported as static
886-
files and served via the builtin python http.server with custom 404 redirect
887-
handling. Additionally, the backend runs in multi-worker mode.
807+
files and served via Starlette StaticFiles in a dedicated Uvicorn server.
808+
Additionally, the backend runs in multi-worker mode.
888809
"""
889810

890811
frontend_thread: threading.Thread | None = None
891-
frontend_server: Subdir404TCPServer | None = None
812+
frontend_server: uvicorn.Server | None = None
892813

893814
def _run_frontend(self):
894-
web_root = (
895-
self.app_path
896-
/ reflex.utils.prerequisites.get_web_dir()
897-
/ reflex.constants.Dirs.STATIC
815+
with chdir(self.app_path):
816+
frontend_app = reflex.utils.exec._frontend_prod_app()
817+
self.frontend_server = uvicorn.Server(
818+
uvicorn.Config(
819+
app=frontend_app,
820+
host="127.0.0.1",
821+
port=0,
822+
)
898823
)
899-
config = reflex.config.get_config()
900-
with Subdir404TCPServer(
901-
("", 0),
902-
SimpleHTTPRequestHandlerCustomErrors,
903-
root=web_root,
904-
error_page_map={
905-
404: web_root / config.prepend_frontend_path("/404.html").lstrip("/"),
906-
},
907-
) as self.frontend_server:
908-
frontend_path = config.frontend_path.strip("/")
909-
self.frontend_url = "http://localhost:{1}".format(
910-
*self.frontend_server.socket.getsockname()
911-
) + (f"/{frontend_path}/" if frontend_path else "/")
912-
self.frontend_server.serve_forever()
824+
self.frontend_server.run()
913825

914826
def _start_frontend(self):
915827
# Set up the frontend.
@@ -942,10 +854,26 @@ def _start_frontend(self):
942854
self.frontend_thread.start()
943855

944856
def _wait_frontend(self):
945-
self._poll_for(lambda: self.frontend_server is not None)
946-
if self.frontend_server is None or not self.frontend_server.socket.fileno():
857+
self._poll_for(
858+
lambda: (
859+
self.frontend_server is not None
860+
and getattr(self.frontend_server, "servers", [])
861+
and self.frontend_server.servers[0].sockets
862+
)
863+
)
864+
if (
865+
self.frontend_server is None
866+
or not self.frontend_server.servers[0].sockets
867+
or not self.frontend_server.servers[0].sockets[0].fileno()
868+
):
947869
msg = "Frontend did not start"
948870
raise RuntimeError(msg)
871+
frontend_socket = self.frontend_server.servers[0].sockets[0]
872+
config = get_config()
873+
self.frontend_url = "http://{}:{}".format(
874+
*frontend_socket.getsockname()
875+
) + config.prepend_frontend_path("/")
876+
config.deploy_url = self.frontend_url
949877

950878
def _start_backend(self):
951879
if self.app_asgi is None:
@@ -982,9 +910,9 @@ def _poll_for_servers(self, timeout: TimeoutType = None) -> socket.socket:
982910
environment.REFLEX_SKIP_COMPILE.set(None)
983911

984912
def stop(self):
985-
"""Stop the frontend python webserver."""
986-
super().stop()
913+
"""Stop the frontend and backend servers."""
987914
if self.frontend_server is not None:
988-
self.frontend_server.shutdown()
915+
self.frontend_server.should_exit = True
916+
super().stop()
989917
if self.frontend_thread is not None:
990918
self.frontend_thread.join()

0 commit comments

Comments
 (0)