Source code for litestar_vite.config._runtime

"""Runtime execution settings."""

import os
import warnings
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Literal

from litestar_vite.config._constants import TRUE_VALUES

__all__ = ("ExternalDevServer", "RuntimeConfig", "resolve_trusted_proxies")

_EXECUTOR_COMMANDS: dict[str, dict[str, tuple[str, ...]]] = {
    "node": {
        "run": ("npm", "run", "dev"),
        "build": ("npm", "run", "build"),
        "build_watch": ("npm", "run", "watch"),
        "serve": ("npm", "run", "serve"),
        "install": ("npm", "install"),
    },
    "bun": {
        "run": ("bun", "run", "dev"),
        "build": ("bun", "run", "build"),
        "build_watch": ("bun", "run", "watch"),
        "serve": ("bun", "run", "serve"),
        "install": ("bun", "install"),
    },
    "deno": {
        "run": ("deno", "task", "dev"),
        "build": ("deno", "task", "build"),
        "build_watch": ("deno", "task", "watch"),
        "serve": ("deno", "task", "serve"),
        "install": ("deno", "install"),
    },
    "yarn": {
        "run": ("yarn", "dev"),
        "build": ("yarn", "build"),
        "build_watch": ("yarn", "watch"),
        "serve": ("yarn", "serve"),
        "install": ("yarn", "install"),
    },
    "pnpm": {
        "run": ("pnpm", "dev"),
        "build": ("pnpm", "build"),
        "build_watch": ("pnpm", "watch"),
        "serve": ("pnpm", "serve"),
        "install": ("pnpm", "install"),
    },
}


@lru_cache(maxsize=32)
def _cached_resolve_trusted_proxies(env_value: str | None) -> "tuple[str, ...] | Literal['*'] | None":
    """Resolve trusted proxies from env input with caching.

    Returns a tuple for cached list values to preserve immutability across cache entries.
    """
    if env_value is None:
        return None

    env_value = env_value.strip()
    if env_value == "*":
        return ("*",)
    if not env_value:
        return None

    return tuple(h.strip() for h in env_value.split(",") if h.strip())


@lru_cache(maxsize=16)
def _cached_resolve_proxy_mode(env_value: str | None) -> "Literal['vite', 'proxy', '__none__'] | None":
    """Resolve proxy mode from env input with caching.

    "__none__" preserves an explicit "none" value while still allowing ``None``
    for missing values. ``None`` (unset) defers to ``ViteConfig._apply_proxy_mode_defaults``
    which auto-derives from ``mode``.
    """
    match env_value.strip().lower() if env_value is not None else None:
        case None:
            return None
        case "none":
            return "__none__"
        case "direct":
            warnings.warn(
                "VITE_PROXY_MODE='direct' is deprecated and now coerced to 'vite'. "
                "Two-port dev mode (browser hits Vite directly) violates the single-port-via-ASGI "
                "contract and is no longer supported.",
                DeprecationWarning,
                stacklevel=4,
            )
            return "vite"
        case "proxy":
            return "proxy"
        case "vite":
            return "vite"
        case _:
            msg = f"Invalid VITE_PROXY_MODE: {env_value!r}. Expected one of: vite, proxy, none"
            raise ValueError(msg)


def resolve_trusted_proxies() -> "list[str] | str | None":
    """Resolve trusted_proxies from environment variable.

    Reads LITESTAR_TRUSTED_PROXIES env var. Examples:
        - "*" -> Trust all proxies (for container environments)
        - "127.0.0.1" -> Trust localhost only
        - "10.0.0.0/8,172.16.0.0/12" -> Trust private networks

    Returns:
        The trusted proxies configuration, or None if not set.
    """
    cached_value = _cached_resolve_trusted_proxies(os.getenv("LITESTAR_TRUSTED_PROXIES"))

    if cached_value is None:
        return None
    if cached_value == ("*",):
        return "*"
    return list(cached_value)


def resolve_proxy_mode() -> "Literal['vite', 'proxy'] | None":
    """Resolve proxy_mode from environment variable.

    Reads ``VITE_PROXY_MODE`` env var. Valid values:

    - unset (default): auto-derive from ``ViteConfig.mode``
    - ``"vite"``: Proxy to internal Vite server (allow list - assets only)
    - ``"proxy"``: Proxy everything except Litestar routes (deny list); requires ``mode='framework'``
    - ``"none"``: Disable proxy (for production)
    - ``"direct"``: DEPRECATED. Auto-coerced to ``"vite"`` with ``DeprecationWarning``.

    Raises:
        ValueError: If an invalid value is provided.

    Returns:
        The resolved proxy mode, or None if disabled or unset (let ViteConfig auto-derive).
    """
    cached_mode = _cached_resolve_proxy_mode(os.getenv("VITE_PROXY_MODE"))
    if cached_mode == "__none__":
        return None
    return cached_mode


[docs] @dataclass class ExternalDevServer: """Configuration for external (non-Vite) dev servers. Use this when your frontend uses a framework with its own dev server (Angular CLI, Next.js, Create React App, etc.) instead of Vite. For SSR frameworks (Astro, Nuxt, SvelteKit) using Vite internally, leave target as None - the proxy will read the dynamic port from the hotfile. Attributes: target: The URL of the external dev server (e.g., "http://localhost:4200"). If None, the proxy reads the target URL from the Vite hotfile. command: Custom command to start the dev server (e.g., ["ng", "serve"]). If None and start_dev_server=True, uses executor's default start command. build_command: Custom command to build for production (e.g., ["ng", "build"]). If None, uses executor's default build command (e.g., "npm run build"). http2: Enable HTTP/2 for proxy connections. enabled: Whether the external proxy is enabled. """ target: "str | None" = None command: "list[str] | None" = None build_command: "list[str] | None" = None http2: bool = False enabled: bool = True
[docs] @dataclass class RuntimeConfig: """Runtime execution settings. Attributes: dev_mode: Enable development mode with HMR/watch. proxy_mode: Proxy handling mode (auto-derived from ``ViteConfig.mode`` when None): - "vite": Proxy Vite assets only (allow list - SPA / hybrid / template modes) - "proxy": Proxy everything except Litestar routes (deny list - framework mode) - None: No proxy (auto-derived for production) external_dev_server: Configuration for external dev server (used with proxy_mode="proxy"). host: Vite dev server host. port: Vite dev server port. protocol: Protocol for dev server (http/https). executor: JavaScript runtime executor (node, bun, deno). run_command: Custom command to run Vite dev server (auto-detect if None). build_command: Custom command to build with Vite (auto-detect if None). build_watch_command: Custom command for watch mode build. serve_command: Custom command to run production server (for SSR frameworks). install_command: Custom command to install dependencies. is_react: Enable React Fast Refresh support. health_check: Enable health check for dev server startup. detect_nodeenv: Detect and use nodeenv in virtualenv (opt-in). set_environment: Set Vite environment variables from config. set_static_folders: Automatically configure static file serving. csp_nonce: Content Security Policy nonce for inline scripts. spa_handler: Auto-register catch-all SPA route when mode="spa". http2: Enable HTTP/2 for proxy HTTP requests (better multiplexing). WebSocket traffic (HMR) uses a separate connection and is unaffected. extra_route_prefixes: Additional backend route prefixes excluded from SPA/proxy fallbacks. Registered routes, the OpenAPI path, and the AsyncAPI documentation path (when an AsyncAPI plugin is registered) are derived automatically. Use this only to reserve an otherwise unclaimed prefix such as ``"/api"`` or ``"/docs"``. """ dev_mode: bool = field(default_factory=lambda: os.getenv("VITE_DEV_MODE", "False") in TRUE_VALUES) proxy_mode: "Literal['vite', 'proxy'] | None" = field(default_factory=resolve_proxy_mode) external_dev_server: "ExternalDevServer | str | None" = None host: str = field(default_factory=lambda: os.getenv("VITE_HOST", "127.0.0.1")) port: int = field(default_factory=lambda: int(os.getenv("VITE_PORT", "5173"))) protocol: Literal["http", "https"] = "http" executor: "Literal['node', 'bun', 'deno', 'yarn', 'pnpm'] | None" = None run_command: "list[str] | None" = None build_command: "list[str] | None" = None build_watch_command: "list[str] | None" = None serve_command: "list[str] | None" = None install_command: "list[str] | None" = None is_react: bool = False health_check: bool = field(default_factory=lambda: os.getenv("VITE_HEALTH_CHECK", "False") in TRUE_VALUES) detect_nodeenv: bool = False set_environment: bool = True set_static_folders: bool = True csp_nonce: "str | None" = None spa_handler: bool = True http2: bool = True start_dev_server: bool = True trusted_proxies: "list[str] | str | None" = field(default_factory=resolve_trusted_proxies) """Trusted proxy hosts for X-Forwarded-* header processing. When set, the ProxyHeadersMiddleware will read and apply X-Forwarded-Proto, X-Forwarded-For, and X-Forwarded-Host headers only from requests originating from these hosts. Accepted values: - None (default): Disabled - do not trust any proxy headers - "*": Trust all proxies (use in controlled environments like Docker/Railway) - List of IP addresses: ["127.0.0.1", "10.0.0.0/8"] - Comma-separated string: "127.0.0.1, 10.0.0.0/8" Supports: - IPv4 addresses: "192.168.1.1" - IPv6 addresses: "::1" - CIDR notation: "10.0.0.0/8", "fd00::/8" - Unix socket literals (for advanced setups) Security Note: Only enable this when your application is behind a trusted reverse proxy. Never enable in environments where clients can directly connect to the app. Using "*" should only be done in controlled environments where direct client connections are blocked by network configuration. Environment Variable: LITESTAR_TRUSTED_PROXIES """ extra_route_prefixes: tuple[str, ...] = () """Additional backend route prefixes for dev proxy and SPA fallback exclusion. Registered routes, the OpenAPI path, and the AsyncAPI documentation path (when an AsyncAPI plugin is registered) are derived automatically. Use this deliberately to reserve custom backend paths such as ``"/admin"`` or to re-add ``"/docs"`` when your app serves documentation there. """
[docs] def __post_init__(self) -> None: """Normalize runtime settings and apply derived defaults.""" if isinstance(self.extra_route_prefixes, str): self.extra_route_prefixes = (self.extra_route_prefixes,) else: self.extra_route_prefixes = tuple(self.extra_route_prefixes) if isinstance(self.external_dev_server, str): self.external_dev_server = ExternalDevServer(target=self.external_dev_server) if self.external_dev_server is not None and self.proxy_mode in {None, "vite"}: self.proxy_mode = "proxy" if self.executor is None: self.executor = "node" if self.executor in _EXECUTOR_COMMANDS: cmds = _EXECUTOR_COMMANDS[self.executor] if self.run_command is None: self.run_command = list(cmds["run"]) if self.build_command is None: self.build_command = list(cmds["build"]) if self.build_watch_command is None: self.build_watch_command = list(cmds["build_watch"]) if self.serve_command is None: self.serve_command = list(cmds["serve"]) if self.install_command is None: self.install_command = list(cmds["install"])