Add first-class env var classification: bundle vs server
## Problem
Reflex compiles Python page code into a static JS bundle at `reflex export`. Any `os.environ` read (or `pydantic_settings.BaseSettings()` instantiation) that happens during page evaluation gets its return value **baked into the static bundle** — the value the env had at build time becomes a string literal in `.web/build/client/*.js`.
This is structurally inherent to the architecture: the same Python module runs at both build (export) and runtime (server), and there's nothing in the framework that distinguishes "values safe to embed in the browser bundle" from "values that must stay backend-only."
### Minimal repro
```python
# config.py
class Settings(BaseSettings):
public_api_url: str
db_password: str
# pages/some_page.py
def _endpoint_card() -> rx.Component:
return rx.code(_api_url()) # called during page evaluation
def _api_url() -> str:
return get_settings().public_api_url
```
`get_settings()` reads env vars during page evaluation. In a typical build pipeline, the real per-deployment values aren't set at build time (they come from runtime container env or secret manager), so a placeholder/dummy gets baked into the bundle. The wrong value silently ships to every browser, with no warning, no compile error, no test failure.
Worse, the same `Settings()` call validates **all** fields — so a build that has no business reading `db_password` is forced to provide it just to instantiate the class. Teams end up sprinkling dummy `db_password=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` values into Dockerfiles, which is a code smell pointing at exactly this design gap.
### Why it's a class of bug, not a one-off
- Any third-party library that reads env at import time has the same exposure.
- Any helper that lazily reads config and gets called during page evaluation has the same exposure.
- Refactors that move a backend-only call into a render path silently introduce leaks.
- Adding new env vars is a judgment call ("will this end up in the bundle?") that's invisible to the type system.
The mental model "I'm just reading an env var" doesn't match the reality "this might end up in a CDN-cached JS file forever."
## Proposed solution
Make the bundle/backend split a first-class concept, the way Next.js, Nuxt, and SvelteKit do.
### Sketch
```python
import reflex as rx
class FrontendBundle(rx.FrontendBundleEnv):
"""Bundle-bound. Baked into the JS at `reflex export`.
Browser-visible — no secrets."""
api_url: str
feature_flag_x: bool = False
class Backend(rx.BackendEnv):
"""Backend-only. Reading these during `reflex export` raises."""
db_url: str
third_party_secret_key: str
```
Behavior:
- `rx.BackendEnv` subclasses raise `RuntimeError` on instantiation when invoked inside `reflex export` (Reflex sets an internal flag for its own export step — no Dockerfile plumbing needed by the user).
- The traceback points at the exact instantiation call site, so the fix is obvious.
- `rx.FrontendBundleEnv` instantiation is free at any time; values are read once at export and baked.
- Optional: a `reflex export` post-step that grep-checks the bundle for any value matching a `BackendEnv` field — defense in depth against third-party code.
### Why types are the right surface
The choice between "frontend bundle" and "backend" is a deployment-level decision that doesn't belong in scattered helper logic — it belongs on the env var itself. Putting it in the type lets every reader see, at the call site, which side of the split they're on.
## Prior art
- **Next.js**: `NEXT_PUBLIC_*` prefix → bundled; everything else → server-only. Compile-time check.
- **Nuxt**: `runtimeConfig.public` vs `runtimeConfig`.
- **SvelteKit**: `$env/static/public` / `$env/static/private` / `$env/dynamic/public` / `$env/dynamic/private` — the import path itself encodes the classification.
All three solve the same structural problem the same way: make "is this safe to ship to the browser?" a property of the variable, not a judgment call at every read site.
## Workaround until upstream support exists
The same shape can be built manually today:
- Two `BaseSettings` subclasses (e.g. `FrontendBundleEnv`, `BackendEnv`)
- A `BUILDING_FRONTEND_BUNDLE=1` env set in the Dockerfile for the `reflex export` step
- `get_backend_env()` raises if that flag is set
- View code reads `get_frontend_bundle_env()` directly at module load (no state var) since values are constant per-bundle
It works, but every Reflex user with a real deployment will hit this same shape eventually. Built-in support would catch the bug at framework level rather than relying on each team to reinvent it.
2 条评论