"""Vite Plugin for Litestar.
This module provides the VitePlugin class for integrating Vite with Litestar.
The plugin handles:
- Static file serving configuration
- Jinja2 template callable registration
- Vite dev server process management
- Async asset loader initialization
- Development proxies for Vite HTTP and HMR WebSockets (with hop-by-hop header filtering)
Example::
from litestar import Litestar
from litestar_vite import VitePlugin, ViteConfig
app = Litestar(
plugins=[VitePlugin(config=ViteConfig(dev_mode=True))],
)
"""
import importlib.metadata
import logging
import os
import signal
import subprocess
import sys
import threading
from contextlib import asynccontextmanager, contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, cast
import anyio
import httpx
import websockets
from litestar import Response
from litestar.enums import ScopeType
from litestar.exceptions import NotFoundException, WebSocketDisconnect
from litestar.middleware import AbstractMiddleware, DefineMiddleware
from litestar.plugins import CLIPlugin, InitPluginProtocol
from litestar.static_files import create_static_files_router # pyright: ignore[reportUnknownVariableType]
from rich.console import Console
from websockets.typing import Subprotocol
from litestar_vite.config import JINJA_INSTALLED, TRUE_VALUES, ExternalDevServer, TypeGenConfig, ViteConfig
from litestar_vite.exceptions import ViteProcessError
from litestar_vite.loader import ViteAssetLoader
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from click import Group
from litestar import Litestar
from litestar.config.app import AppConfig
from litestar.connection import Request
from litestar.datastructures import CacheControlHeader
from litestar.openapi.spec import SecurityRequirement
from litestar.types import (
AfterRequestHookHandler, # pyright: ignore[reportUnknownVariableType]
AfterResponseHookHandler, # pyright: ignore[reportUnknownVariableType]
ASGIApp,
BeforeRequestHookHandler, # pyright: ignore[reportUnknownVariableType]
ExceptionHandlersMap,
Guard, # pyright: ignore[reportUnknownVariableType]
Middleware,
Receive,
Scope,
Send,
)
from websockets.typing import Subprotocol
from litestar_vite._handler import AppHandler
from litestar_vite.executor import JSExecutor
_DISCONNECT_EXCEPTIONS = (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed)
_TICK = "[bold green]✓[/]"
_INFO = "[cyan]•[/]"
_WARN = "[yellow]![/]"
_FAIL = "[red]x[/]"
console = Console()
def _fmt_path(path: Path) -> str:
"""Return a path relative to CWD when possible to keep logs short.
Returns:
The relative path string when possible, otherwise the absolute path string.
"""
try:
return str(path.relative_to(Path.cwd()))
except ValueError:
return str(path)
def _write_if_changed(path: Path, content: bytes | str, encoding: str = "utf-8") -> bool:
"""Write content to file only if it differs from the existing content.
Uses hash comparison to avoid unnecessary writes that would trigger
file watchers and unnecessary rebuilds.
Args:
path: The file path to write to.
content: The content to write (bytes or str).
encoding: Encoding for string content.
Returns:
True if file was written (content changed), False if skipped (unchanged).
"""
import hashlib
content_bytes = content.encode(encoding) if isinstance(content, str) else content
if path.exists():
try:
existing_hash = hashlib.md5(path.read_bytes()).hexdigest() # noqa: S324
new_hash = hashlib.md5(content_bytes).hexdigest() # noqa: S324
if existing_hash == new_hash:
return False
except OSError:
pass
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, str):
path.write_text(content, encoding=encoding)
else:
path.write_bytes(content)
return True
_vite_proxy_debug: bool | None = None
def _is_proxy_debug() -> bool:
"""Check if VITE_PROXY_DEBUG is enabled (cached).
Returns:
True if VITE_PROXY_DEBUG is set to a truthy value, else False.
"""
global _vite_proxy_debug # noqa: PLW0603
if _vite_proxy_debug is None:
_vite_proxy_debug = os.environ.get("VITE_PROXY_DEBUG", "").lower() in {"1", "true", "yes"}
return _vite_proxy_debug
def _configure_proxy_logging() -> None:
"""Suppress verbose proxy-related logging unless debug is enabled.
Suppresses INFO-level logs from:
- httpx: logs every HTTP request
- websockets: logs connection events
- uvicorn.protocols.websockets: logs "connection open/closed"
Only show these logs when VITE_PROXY_DEBUG is enabled.
"""
if not _is_proxy_debug():
for logger_name in ("httpx", "websockets", "uvicorn.protocols.websockets"):
logging.getLogger(logger_name).setLevel(logging.WARNING)
_configure_proxy_logging()
def _infer_port_from_argv() -> str | None:
"""Best-effort extraction of `--port/-p` from process argv.
Returns:
The port as a string if found, else None.
"""
argv = sys.argv[1:]
for i, arg in enumerate(argv):
if arg in {"-p", "--port"} and i + 1 < len(argv) and argv[i + 1].isdigit():
return argv[i + 1]
if arg.startswith("--port="):
_, _, value = arg.partition("=")
if value.isdigit():
return value
return None
def _is_non_serving_assets_cli() -> bool:
"""Return True when running CLI assets commands that don't start a server.
This suppresses dev-proxy setup/logging for commands like `assets build`
where only a Vite build is performed and no proxy should be initialized.
Returns:
True when the current process is running a non-serving `litestar assets ...` command, otherwise False.
"""
argv_str = " ".join(sys.argv)
non_serving_commands = (
" assets build",
" assets install",
" assets deploy",
" assets doctor",
" assets generate-types",
" assets export-routes",
" assets status",
" assets init",
)
return any(cmd in argv_str for cmd in non_serving_commands)
def _log_success(message: str) -> None:
"""Print a success message with consistent styling."""
console.print(f"{_TICK} {message}")
def _log_info(message: str) -> None:
"""Print an informational message with consistent styling."""
console.print(f"{_INFO} {message}")
def _log_warn(message: str) -> None:
"""Print a warning message with consistent styling."""
console.print(f"{_WARN} {message}")
def _log_fail(message: str) -> None:
"""Print an error message with consistent styling."""
console.print(f"{_FAIL} {message}")
def _write_runtime_config_file(config: ViteConfig, *, asset_url_override: str | None = None) -> str:
"""Write a JSON handoff file for the Vite plugin and return its path.
The runtime config file is read by the JS plugin. We serialize with Litestar's JSON encoder for
consistency and format output deterministically for easier debugging.
Returns:
The path to the written config file.
"""
root = config.root_dir or Path.cwd()
path = Path(root) / ".litestar.json"
types = config.types if isinstance(config.types, TypeGenConfig) else None
resource_dir = config.resource_dir
resource_dir_value = str(resource_dir)
bundle_dir_value = str(config.bundle_dir)
ssr_out_dir_value = str(config.ssr_output_dir) if config.ssr_output_dir else None
litestar_version = os.environ.get("LITESTAR_VERSION") or resolve_litestar_version()
deploy_asset_url = None
deploy = config.deploy_config
if deploy is not None and deploy.asset_url:
deploy_asset_url = deploy.asset_url
payload = {
"assetUrl": config.asset_url,
"deployAssetUrl": deploy_asset_url,
"bundleDir": bundle_dir_value,
"hotFile": config.hot_file,
"resourceDir": resource_dir_value,
"staticDir": str(config.static_dir),
"manifest": config.manifest_name,
"mode": config.mode,
"proxyMode": config.proxy_mode,
"port": config.port,
"host": config.host,
"ssrOutDir": ssr_out_dir_value,
"types": {
"enabled": True,
"output": str(types.output),
"openapiPath": str(types.openapi_path),
"routesPath": str(types.routes_path),
"pagePropsPath": str(types.page_props_path),
"generateZod": types.generate_zod,
"generateSdk": types.generate_sdk,
"generateRoutes": types.generate_routes,
"generatePageProps": types.generate_page_props,
"globalRoute": types.global_route,
}
if types
else None,
"logging": {
"level": config.logging_config.level,
"showPathsAbsolute": config.logging_config.show_paths_absolute,
"suppressNpmOutput": config.logging_config.suppress_npm_output,
"suppressViteBanner": config.logging_config.suppress_vite_banner,
"timestamps": config.logging_config.timestamps,
},
"executor": config.runtime.executor,
"litestarVersion": litestar_version,
}
import msgspec
from litestar.serialization import encode_json
from litestar_vite._codegen.utils import write_if_changed
content = msgspec.json.format(encode_json(payload), indent=2)
write_if_changed(path, content)
return str(path)
[docs]
def set_environment(config: ViteConfig, asset_url_override: str | None = None) -> None:
"""Configure environment variables for Vite integration.
Sets environment variables that can be used by both the Python backend
and the Vite frontend during development.
Args:
config: The Vite configuration.
asset_url_override: Optional asset URL to force (e.g., CDN base during build).
"""
litestar_version = os.environ.get("LITESTAR_VERSION") or resolve_litestar_version()
asset_url = asset_url_override or config.asset_url
if asset_url:
os.environ.setdefault("ASSET_URL", asset_url)
if config.base_url:
os.environ.setdefault("VITE_BASE_URL", config.base_url)
os.environ.setdefault("VITE_ALLOW_REMOTE", str(True))
backend_host = os.environ.get("LITESTAR_HOST") or "127.0.0.1"
backend_port = os.environ.get("LITESTAR_PORT") or os.environ.get("PORT") or _infer_port_from_argv() or "8000"
os.environ["LITESTAR_HOST"] = backend_host
os.environ["LITESTAR_PORT"] = str(backend_port)
os.environ.setdefault("APP_URL", f"http://{backend_host}:{backend_port}")
os.environ.setdefault("VITE_PROTOCOL", config.protocol)
if config.proxy_mode is not None:
os.environ.setdefault("VITE_PROXY_MODE", config.proxy_mode)
os.environ.setdefault("VITE_HOST", config.host)
os.environ.setdefault("VITE_PORT", str(config.port))
os.environ.setdefault("NUXT_HOST", config.host)
os.environ.setdefault("NUXT_PORT", str(config.port))
os.environ.setdefault("NITRO_HOST", config.host)
os.environ.setdefault("NITRO_PORT", str(config.port))
os.environ.setdefault("HOST", config.host)
os.environ.setdefault("PORT", str(config.port))
os.environ["LITESTAR_VERSION"] = litestar_version
os.environ.setdefault("LITESTAR_VITE_RUNTIME", config.runtime.executor or "node")
os.environ.setdefault("LITESTAR_VITE_INSTALL_CMD", " ".join(config.install_command))
if config.is_dev_mode:
os.environ.setdefault("VITE_DEV_MODE", str(config.is_dev_mode))
config_path = _write_runtime_config_file(config, asset_url_override=asset_url_override)
os.environ["LITESTAR_VITE_CONFIG_PATH"] = config_path
[docs]
def set_app_environment(app: "Litestar") -> None:
"""Set environment variables derived from the Litestar app instance.
This is called after set_environment() once the app is available,
to export app-specific configuration like OpenAPI paths.
Args:
app: The Litestar application instance.
"""
openapi_config = app.openapi_config
if openapi_config is not None and isinstance(openapi_config.path, str) and openapi_config.path:
os.environ.setdefault("LITESTAR_OPENAPI_PATH", openapi_config.path)
[docs]
def resolve_litestar_version() -> str:
"""Return the installed Litestar version string.
Returns:
The installed Litestar version, or "unknown" when unavailable.
"""
try:
return importlib.metadata.version("litestar")
except importlib.metadata.PackageNotFoundError:
return "unknown"
def _pick_free_port() -> int:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
_PROXY_ALLOW_PREFIXES: tuple[str, ...] = (
"/@vite",
"/@id/",
"/@fs/",
"/@react-refresh",
"/@vite/client",
"/@vite/env",
"/vite-hmr",
"/__vite_ping",
"/node_modules/.vite/",
"/@analogjs/",
)
_HOP_BY_HOP_HEADERS = frozenset({
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"content-length",
"content-encoding",
})
def _normalize_prefix(prefix: str) -> str:
if not prefix.startswith("/"):
prefix = f"/{prefix}"
if not prefix.endswith("/"):
prefix = f"{prefix}/"
return prefix
class _RoutePrefixesState(Protocol):
litestar_vite_route_prefixes: tuple[str, ...]
[docs]
def get_litestar_route_prefixes(app: "Litestar") -> tuple[str, ...]:
"""Build a cached list of Litestar route prefixes for the given app.
This function collects all registered route paths from the Litestar application
and caches them for efficient lookup. The cache is stored in app.state to ensure
it's automatically cleaned up when the app is garbage collected.
Includes:
- All registered Litestar route paths
- OpenAPI schema path (customizable via openapi_config.path)
- Common API prefixes as fallback (/api, /schema, /docs)
Args:
app: The Litestar application instance.
Returns:
A tuple of route prefix strings (without trailing slashes).
"""
state = cast("_RoutePrefixesState", app.state)
try:
return state.litestar_vite_route_prefixes
except AttributeError:
pass
prefixes: list[str] = []
for route in app.routes:
prefix = route.path.rstrip("/")
if prefix:
prefixes.append(prefix)
openapi_config = app.openapi_config
if openapi_config is not None:
schema_path = openapi_config.path
if schema_path:
prefixes.append(schema_path.rstrip("/"))
prefixes.extend(["/api", "/schema", "/docs"])
unique_prefixes = sorted(set(prefixes), key=len, reverse=True)
result = tuple(unique_prefixes)
state.litestar_vite_route_prefixes = result
if _is_proxy_debug():
console.print(f"[dim][route-detection] Cached prefixes: {result}[/]")
return result
[docs]
def is_litestar_route(path: str, app: "Litestar") -> bool:
"""Check if a path matches a registered Litestar route.
This function determines if a request path should be handled by Litestar
rather than proxied to the Vite dev server or served as SPA content.
A path matches if it equals a registered prefix or starts with prefix + "/".
Args:
path: The request path to check (e.g., "/schema", "/api/users").
app: The Litestar application instance.
Returns:
True if the path matches a Litestar route, False otherwise.
"""
excluded = get_litestar_route_prefixes(app)
return any(path == prefix or path.startswith(f"{prefix}/") for prefix in excluded)
def _static_not_found_handler(
_request: "Request[Any, Any, Any]", _exc: NotFoundException
) -> Response[bytes]: # pragma: no cover - trivial
"""Return an empty 404 response for static files routing misses.
Returns:
An empty 404 response.
"""
return Response(status_code=404, content=b"")
def _vite_not_found_handler(request: "Request[Any, Any, Any]", exc: NotFoundException) -> Response[Any]:
"""Return a consistent 404 response for missing static assets / routes.
Inertia requests are delegated to the Inertia exception handler to support
redirect_404 configuration.
Args:
request: Incoming request.
exc: NotFound exception raised by routing.
Returns:
Response instance for the 404.
"""
if request.headers.get("x-inertia", "").lower() == "true":
from litestar_vite.inertia.exception_handler import exception_to_http_response
return exception_to_http_response(request, exc)
return Response(status_code=404, content=b"")
[docs]
class ViteProxyMiddleware(AbstractMiddleware):
"""ASGI middleware to proxy Vite dev HTTP traffic to internal Vite server.
HTTP requests use httpx.AsyncClient with optional HTTP/2 support for better
connection multiplexing. WebSocket traffic (used by Vite HMR) is handled by
a dedicated WebSocket route handler created by create_vite_hmr_handler().
The middleware reads the Vite server URL from the hotfile dynamically,
ensuring it always connects to the correct Vite server even if the port changes.
"""
scopes = {ScopeType.HTTP}
[docs]
def __init__(
self,
app: "ASGIApp",
hotfile_path: Path,
asset_url: "str | None" = None,
resource_dir: "Path | None" = None,
bundle_dir: "Path | None" = None,
root_dir: "Path | None" = None,
http2: bool = True,
) -> None:
super().__init__(app)
self.hotfile_path = hotfile_path
self._cached_target: str | None = None
self._cache_initialized = False
self.asset_prefix = _normalize_prefix(asset_url) if asset_url else "/"
self.http2 = http2
self._proxy_allow_prefixes = _normalize_proxy_prefixes(
base_prefixes=_PROXY_ALLOW_PREFIXES,
asset_url=asset_url,
resource_dir=resource_dir,
bundle_dir=bundle_dir,
root_dir=root_dir,
)
def _get_target_base_url(self) -> str | None:
"""Read the Vite server URL from the hotfile with permanent caching.
The hotfile is read once and cached for the lifetime of the server.
Server restart refreshes the cache automatically.
Returns:
The Vite server URL or None if unavailable.
"""
if self._cache_initialized:
return self._cached_target.rstrip("/") if self._cached_target else None
try:
url = self.hotfile_path.read_text().strip()
self._cached_target = url
self._cache_initialized = True
if _is_proxy_debug():
console.print(f"[dim][vite-proxy] Target: {url}[/]")
return url.rstrip("/")
except FileNotFoundError:
self._cached_target = None
self._cache_initialized = True
return None
async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
scope_dict = cast("dict[str, Any]", scope)
path = scope_dict.get("path", "")
should = self._should_proxy(path, scope)
if _is_proxy_debug():
console.print(f"[dim][vite-proxy] {path} → proxy={should}[/]")
if should:
await self._proxy_http(scope_dict, receive, send)
return
await self.app(scope, receive, send)
def _should_proxy(self, path: str, scope: "Scope") -> bool:
try:
from urllib.parse import unquote
except ImportError: # pragma: no cover
decoded = path
matches_prefix = path.startswith(self._proxy_allow_prefixes)
else:
decoded = unquote(path)
matches_prefix = decoded.startswith(self._proxy_allow_prefixes) or path.startswith(
self._proxy_allow_prefixes
)
if not matches_prefix:
return False
app = scope.get("app") # pyright: ignore[reportUnknownMemberType]
return not (app and is_litestar_route(path, app))
async def _proxy_http(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
"""Proxy a single HTTP request to the Vite dev server.
The upstream response is buffered inside the httpx client context manager and only sent
after the context exits. This avoids ASGI errors when httpx raises during cleanup after the
response has started.
"""
target_base_url = self._get_target_base_url()
if target_base_url is None:
await send({"type": "http.response.start", "status": 503, "headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"Vite dev server not running", "more_body": False})
return
method = scope.get("method", "GET")
raw_path = scope.get("raw_path", b"").decode()
query_string = scope.get("query_string", b"").decode()
proxied_path = raw_path
if self.asset_prefix != "/" and not raw_path.startswith(self.asset_prefix):
proxied_path = f"{self.asset_prefix.rstrip('/')}{raw_path}"
url = f"{target_base_url}{proxied_path}"
if query_string:
url = f"{url}?{query_string}"
headers = [(k.decode(), v.decode()) for k, v in scope.get("headers", [])]
body = b""
more_body = True
while more_body:
event = await receive()
if event["type"] != "http.request":
continue
body += event.get("body", b"")
more_body = event.get("more_body", False)
http2_enabled = self.http2
if http2_enabled:
try:
import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
except ImportError:
http2_enabled = False
response_status = 502
response_headers: list[tuple[bytes, bytes]] = [(b"content-type", b"text/plain")]
response_body = b"Bad gateway"
got_full_body = False
try:
async with httpx.AsyncClient(http2=http2_enabled) as client:
upstream_resp = await client.request(method, url, headers=headers, content=body, timeout=10.0)
response_status = upstream_resp.status_code
response_headers = [
(k.encode(), v.encode())
for k, v in upstream_resp.headers.items()
if k.lower() not in _HOP_BY_HOP_HEADERS
]
response_body = upstream_resp.content
got_full_body = True
except Exception as exc: # pragma: no cover - catch all cleanup errors
if not got_full_body:
response_body = f"Upstream error: {exc}".encode()
await send({"type": "http.response.start", "status": response_status, "headers": response_headers})
await send({"type": "http.response.body", "body": response_body, "more_body": False})
[docs]
class ExternalDevServerProxyMiddleware(AbstractMiddleware):
"""ASGI middleware to proxy requests to an external dev server (deny list mode).
This middleware proxies all requests that don't match Litestar-registered routes
to the target dev server. It supports two modes:
1. **Static target**: Provide a fixed URL (e.g., "http://localhost:4200" for Angular CLI)
2. **Dynamic target**: Leave target as None and provide hotfile_path - the proxy reads
the target URL from the Vite hotfile (for SSR frameworks like Astro, Nuxt, SvelteKit)
Unlike ViteProxyMiddleware (allow list), this middleware:
- Uses deny list approach: proxies everything EXCEPT Litestar routes
- Supports both static and dynamic target URLs
- Auto-excludes Litestar routes, static mounts, and schema paths
"""
scopes = {ScopeType.HTTP}
[docs]
def __init__(
self,
app: "ASGIApp",
target: "str | None" = None,
hotfile_path: "Path | None" = None,
http2: bool = False,
litestar_app: "Litestar | None" = None,
) -> None:
"""Initialize the external dev server proxy middleware.
Args:
app: The ASGI application to wrap.
target: Static target URL to proxy to (e.g., "http://localhost:4200").
If None, uses hotfile_path for dynamic target discovery.
hotfile_path: Path to the Vite hotfile for dynamic target discovery.
Used when target is None (SSR frameworks with dynamic ports).
http2: Enable HTTP/2 for proxy connections.
litestar_app: Optional Litestar app instance for route exclusion.
"""
super().__init__(app)
self._static_target = target.rstrip("/") if target else None
self._hotfile_path = hotfile_path
self._cached_target: str | None = None
self._cache_initialized = False
self.http2 = http2
self._litestar_app = litestar_app
self._deny_prefixes: tuple[str, ...] | None = None
def _get_target(self) -> str | None:
"""Get the proxy target URL with permanent caching.
Returns static target if configured, otherwise reads from hotfile.
The hotfile is read once and cached for the lifetime of the server.
Returns:
The target URL or None if unavailable.
"""
if self._static_target:
return self._static_target
if self._hotfile_path:
if self._cache_initialized:
return self._cached_target.rstrip("/") if self._cached_target else None
try:
url = self._hotfile_path.read_text().strip()
self._cached_target = url
self._cache_initialized = True
if _is_proxy_debug():
console.print(f"[dim][proxy] Dynamic target: {url}[/]")
return url.rstrip("/")
except FileNotFoundError:
self._cached_target = None
self._cache_initialized = True
return None
return None
def _get_deny_prefixes(self, scope: "Scope") -> tuple[str, ...]:
"""Build list of path prefixes to deny from proxying (deny list).
Uses the shared get_litestar_route_prefixes() function for route detection.
Results are cached per middleware instance. If the Litestar app cannot be resolved (not
provided during init and missing from the scope), falls back to common API prefixes.
Returns:
A tuple of path prefixes that should NOT be proxied.
"""
if self._deny_prefixes is not None:
return self._deny_prefixes
app: "Litestar | None" = self._litestar_app or scope.get("app") # pyright: ignore[reportUnknownMemberType]
if app:
self._deny_prefixes = get_litestar_route_prefixes(app)
else:
self._deny_prefixes = ("/api", "/schema", "/docs")
if _is_proxy_debug():
console.print(f"[dim][external-proxy] Deny prefixes: {self._deny_prefixes}[/]")
return self._deny_prefixes
def _should_proxy(self, path: str, scope: "Scope") -> bool:
"""Determine if the request should be proxied to the external server.
Returns True if the path does NOT match any Litestar route (deny list approach).
Returns:
True if the request should be proxied, else False.
"""
deny_prefixes = self._get_deny_prefixes(scope)
return all(not (path == prefix or path.startswith(f"{prefix}/")) for prefix in deny_prefixes)
async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
scope_dict = cast("dict[str, Any]", scope)
path = scope_dict.get("path", "")
should = self._should_proxy(path, scope)
if _is_proxy_debug():
console.print(f"[dim][external-proxy] {path} → proxy={should}[/]")
if should:
await self._proxy_request(scope_dict, receive, send)
return
await self.app(scope, receive, send)
async def _proxy_request(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
"""Proxy the HTTP request to the external dev server.
The upstream response is buffered inside the httpx client context manager and only sent
after the context exits. This avoids ASGI errors when httpx raises during cleanup after the
response has started.
"""
target = self._get_target()
if target is None:
await send({"type": "http.response.start", "status": 503, "headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"Dev server not running", "more_body": False})
return
method = scope.get("method", "GET")
raw_path = scope.get("raw_path", b"").decode()
query_string = scope.get("query_string", b"").decode()
url = f"{target}{raw_path}"
if query_string:
url = f"{url}?{query_string}"
headers = [
(k.decode(), v.decode())
for k, v in scope.get("headers", [])
if k.lower() not in {b"host", b"connection", b"keep-alive"}
]
body = b""
more_body = True
while more_body:
event = await receive()
if event["type"] != "http.request":
continue
body += event.get("body", b"")
more_body = event.get("more_body", False)
http2_enabled = self.http2
if http2_enabled:
try:
import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
except ImportError:
http2_enabled = False
response_status = 502
response_headers: list[tuple[bytes, bytes]] = [(b"content-type", b"text/plain")]
response_body = b"Bad gateway"
got_full_body = False
try:
async with httpx.AsyncClient(http2=http2_enabled, timeout=30.0) as client:
upstream_resp = await client.request(method, url, headers=headers, content=body)
response_status = upstream_resp.status_code
response_headers = [
(k.encode(), v.encode())
for k, v in upstream_resp.headers.items()
if k.lower() not in _HOP_BY_HOP_HEADERS
]
response_body = upstream_resp.content
got_full_body = True
except httpx.ConnectError:
response_status = 503
response_body = f"Dev server not running at {target}".encode()
except httpx.HTTPError as exc: # pragma: no cover
if not got_full_body:
response_body = f"Upstream error: {exc}".encode()
await send({"type": "http.response.start", "status": response_status, "headers": response_headers})
await send({"type": "http.response.body", "body": response_body, "more_body": False})
def _build_hmr_target_url(hotfile_path: Path, scope: dict[str, Any], hmr_path: str, asset_url: str) -> "str | None":
"""Build the target WebSocket URL for Vite HMR proxy.
Vite's HMR WebSocket listens at {base}{hmr.path}, so we preserve
the full path including the asset prefix (e.g., /static/vite-hmr).
Returns:
The target WebSocket URL or None if the hotfile is not found.
"""
try:
vite_url = hotfile_path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
ws_url = vite_url.replace("http://", "ws://").replace("https://", "wss://")
original_path = scope.get("path", hmr_path)
query_string = scope.get("query_string", b"").decode()
target = f"{ws_url}{original_path}"
if query_string:
target = f"{target}?{query_string}"
if _is_proxy_debug():
console.print(f"[dim][vite-hmr] Connecting: {target}[/]")
return target
def _extract_forward_headers(scope: dict[str, Any]) -> list[tuple[str, str]]:
"""Extract headers to forward, excluding WebSocket handshake headers.
Excludes protocol-specific headers that websockets library handles itself.
The sec-websocket-protocol header is also excluded since we handle subprotocols separately.
Returns:
A list of (header_name, header_value) tuples.
"""
skip_headers = (
b"host",
b"upgrade",
b"connection",
b"sec-websocket-key",
b"sec-websocket-version",
b"sec-websocket-protocol",
b"sec-websocket-extensions",
)
return [(k.decode(), v.decode()) for k, v in scope.get("headers", []) if k.lower() not in skip_headers]
def _extract_subprotocols(scope: dict[str, Any]) -> list[str]:
"""Extract WebSocket subprotocols from the request headers.
Returns:
A list of subprotocol strings.
"""
for key, value in scope.get("headers", []):
if key.lower() == b"sec-websocket-protocol":
return [p.strip() for p in value.decode().split(",")]
return []
async def _run_websocket_proxy(socket: Any, upstream: Any) -> None:
"""Run bidirectional WebSocket proxy between client and upstream.
Args:
socket: The client WebSocket connection (Litestar WebSocket).
upstream: The upstream WebSocket connection (websockets client).
"""
async def client_to_upstream() -> None:
"""Forward messages from browser to Vite."""
try:
while True:
data = await socket.receive_text()
await upstream.send(data)
except (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed):
pass
finally:
with suppress(websockets.ConnectionClosed):
await upstream.close()
async def upstream_to_client() -> None:
"""Forward messages from Vite to browser."""
try:
async for msg in upstream:
if isinstance(msg, str):
await socket.send_text(msg)
else:
await socket.send_bytes(msg)
except (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed):
pass
finally:
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
await socket.close()
async with anyio.create_task_group() as tg:
tg.start_soon(client_to_upstream)
tg.start_soon(upstream_to_client)
[docs]
def create_vite_hmr_handler(hotfile_path: Path, hmr_path: str = "/static/vite-hmr", asset_url: str = "/static/") -> Any:
"""Create a WebSocket route handler for Vite HMR proxy.
This handler proxies WebSocket connections from the browser to the Vite
dev server for Hot Module Replacement (HMR) functionality.
Args:
hotfile_path: Path to the hotfile written by the Vite plugin.
hmr_path: The path to register the WebSocket handler at.
asset_url: The asset URL prefix to strip when connecting to Vite.
Returns:
A WebsocketRouteHandler that proxies HMR connections.
"""
from litestar import WebSocket, websocket
@websocket(path=hmr_path, opt={"exclude_from_auth": True})
async def vite_hmr_proxy(socket: "WebSocket[Any, Any, Any]") -> None:
"""Proxy WebSocket messages between browser and Vite dev server.
Raises:
BaseException: Re-raises unexpected exceptions to allow the ASGI server to log them.
"""
scope_dict = dict(socket.scope)
target = _build_hmr_target_url(hotfile_path, scope_dict, hmr_path, asset_url)
if target is None:
console.print("[yellow][vite-hmr] Vite dev server not running[/]")
await socket.close(code=1011, reason="Vite dev server not running")
return
headers = _extract_forward_headers(scope_dict)
subprotocols = _extract_subprotocols(scope_dict)
typed_subprotocols: list[Subprotocol] = [cast("Subprotocol", p) for p in subprotocols]
await socket.accept(subprotocols=typed_subprotocols[0] if typed_subprotocols else None)
try:
async with websockets.connect(
target, additional_headers=headers, open_timeout=10, subprotocols=typed_subprotocols or None
) as upstream:
if _is_proxy_debug():
console.print("[dim][vite-hmr] ✓ Connected[/]")
await _run_websocket_proxy(socket, upstream)
except TimeoutError:
if _is_proxy_debug():
console.print("[yellow][vite-hmr] Connection timeout[/]")
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
await socket.close(code=1011, reason="Vite HMR connection timeout")
except OSError as exc:
if _is_proxy_debug():
console.print(f"[yellow][vite-hmr] Connection failed: {exc}[/]")
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
await socket.close(code=1011, reason="Vite HMR connection failed")
except WebSocketDisconnect:
pass
except BaseException as exc:
exceptions: list[BaseException] | tuple[BaseException, ...] | None
try:
exceptions = cast("list[BaseException] | tuple[BaseException, ...]", exc.exceptions) # type: ignore[attr-defined]
except AttributeError:
exceptions = None
if exceptions is not None:
if any(not isinstance(err, _DISCONNECT_EXCEPTIONS) for err in exceptions):
raise
return
if not isinstance(exc, _DISCONNECT_EXCEPTIONS):
raise
return vite_hmr_proxy
def _check_http2_support(enable: bool) -> bool:
"""Check if HTTP/2 support is available.
Returns:
True if HTTP/2 is enabled and the h2 package is installed, else False.
"""
if not enable:
return False
try:
import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
except ImportError:
return False
else:
return True
def _build_proxy_url(target_url: str, path: str, query: str) -> str:
"""Build the full proxy URL from target, path, and query string.
Returns:
The full URL as a string.
"""
url = f"{target_url}{path}"
return f"{url}?{query}" if query else url
def _create_target_url_getter(
target: "str | None", hotfile_path: "Path | None", cached_target: list["str | None"]
) -> "Callable[[], str | None]":
"""Create a function that returns the current target URL with permanent caching.
The hotfile is read once and cached for the lifetime of the server.
Server restart refreshes the cache automatically.
Returns:
A callable that returns the target URL or None if unavailable.
"""
cache_initialized: list[bool] = [False]
def _get_target_url() -> str | None:
if target is not None:
return target.rstrip("/")
if hotfile_path is None:
return None
if cache_initialized[0]:
return cached_target[0].rstrip("/") if cached_target[0] else None
try:
url = hotfile_path.read_text().strip()
cached_target[0] = url
cache_initialized[0] = True
if _is_proxy_debug():
console.print(f"[dim][ssr-proxy] Dynamic target: {url}[/]")
return url.rstrip("/")
except FileNotFoundError:
cached_target[0] = None
cache_initialized[0] = True
return None
return _get_target_url
def _create_hmr_target_getter(
hotfile_path: "Path | None", cached_hmr_target: list["str | None"]
) -> "Callable[[], str | None]":
"""Create a function that returns the HMR target URL from hotfile with permanent caching.
The hotfile is read once and cached for the lifetime of the server.
Server restart refreshes the cache automatically.
The JS side writes HMR URLs to a sibling file at ``<hotfile>.hmr``.
Returns:
A callable that returns the HMR target URL or None if unavailable.
"""
cache_initialized: list[bool] = [False]
def _get_hmr_target_url() -> str | None:
if hotfile_path is None:
return None
if cache_initialized[0]:
return cached_hmr_target[0].rstrip("/") if cached_hmr_target[0] else None
hmr_path = Path(f"{hotfile_path}.hmr")
try:
url = hmr_path.read_text(encoding="utf-8").strip()
cached_hmr_target[0] = url
cache_initialized[0] = True
if _is_proxy_debug():
console.print(f"[dim][ssr-proxy] HMR target: {url}[/]")
return url.rstrip("/")
except FileNotFoundError:
cached_hmr_target[0] = None
cache_initialized[0] = True
return None
return _get_hmr_target_url
async def _handle_ssr_websocket_proxy(
socket: Any, ws_url: str, headers: list[tuple[str, str]], typed_subprotocols: "list[Subprotocol]"
) -> None:
"""Handle the WebSocket proxy connection to SSR framework.
Args:
socket: The client WebSocket connection.
ws_url: The upstream WebSocket URL.
headers: Headers to forward.
typed_subprotocols: WebSocket subprotocols.
"""
try:
async with websockets.connect(
ws_url, additional_headers=headers, open_timeout=10, subprotocols=typed_subprotocols or None
) as upstream:
if _is_proxy_debug():
console.print("[dim][ssr-proxy-ws] ✓ Connected[/]")
await _run_websocket_proxy(socket, upstream)
except TimeoutError:
if _is_proxy_debug():
console.print("[yellow][ssr-proxy-ws] Connection timeout[/]")
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
await socket.close(code=1011, reason="SSR HMR connection timeout")
except OSError as exc:
if _is_proxy_debug():
console.print(f"[yellow][ssr-proxy-ws] Connection failed: {exc}[/]")
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
await socket.close(code=1011, reason="SSR HMR connection failed")
except (WebSocketDisconnect, websockets.ConnectionClosed, anyio.ClosedResourceError):
pass
[docs]
def create_ssr_proxy_controller(
target: "str | None" = None, hotfile_path: "Path | None" = None, http2: bool = True
) -> type:
"""Create a Controller that proxies to an SSR framework dev server.
This controller is used for SSR frameworks (Astro, Nuxt, SvelteKit) where all
non-API requests should be proxied to the framework's dev server for rendering.
Args:
target: Static target URL to proxy to. If None, uses hotfile for dynamic discovery.
hotfile_path: Path to the hotfile for dynamic target discovery.
http2: Enable HTTP/2 for proxy connections.
Returns:
A Litestar Controller class with HTTP and WebSocket handlers for SSR proxy.
"""
from litestar import Controller, HttpMethod, Response, WebSocket, route, websocket
cached_target: list[str | None] = [target]
get_target_url = _create_target_url_getter(target, hotfile_path, cached_target)
get_hmr_target_url = _create_hmr_target_getter(hotfile_path, [None])
class SSRProxyController(Controller):
"""Controller that proxies requests to an SSR framework dev server."""
include_in_schema = False
opt = {"exclude_from_auth": True}
@route(
path=["/", "/{path:path}"],
http_method=[
HttpMethod.GET,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.PATCH,
HttpMethod.DELETE,
HttpMethod.HEAD,
HttpMethod.OPTIONS,
],
name="ssr_proxy",
)
async def http_proxy(self, request: "Request[Any, Any, Any]") -> "Response[bytes]":
"""Proxy all HTTP requests to the SSR framework dev server.
Returns:
A Response with the proxied content from the SSR server.
"""
target_url = get_target_url()
if target_url is None:
return Response(content=b"SSR dev server not running", status_code=503, media_type="text/plain")
req_path: str = request.url.path
url = _build_proxy_url(target_url, req_path, request.url.query or "")
if _is_proxy_debug():
console.print(f"[dim][ssr-proxy] {request.method} {req_path} → {url}[/]")
headers_to_forward = [
(k, v) for k, v in request.headers.items() if k.lower() not in {"host", "connection", "keep-alive"}
]
body = await request.body()
http2_enabled = _check_http2_support(http2)
async with httpx.AsyncClient(http2=http2_enabled, timeout=30.0) as client:
try:
upstream_resp = await client.request(
request.method, url, headers=headers_to_forward, content=body, follow_redirects=False
)
except httpx.ConnectError:
return Response(
content=f"SSR dev server not running at {target_url}".encode(),
status_code=503,
media_type="text/plain",
)
except httpx.HTTPError as exc:
return Response(content=str(exc).encode(), status_code=502, media_type="text/plain")
return Response(
content=upstream_resp.content,
status_code=upstream_resp.status_code,
headers=dict(upstream_resp.headers.items()),
media_type=upstream_resp.headers.get("content-type"),
)
@websocket(path=["/", "/{path:path}"], name="ssr_proxy_ws")
async def ws_proxy(self, socket: "WebSocket[Any, Any, Any]") -> None:
"""Proxy WebSocket connections to the SSR framework dev server (for HMR)."""
target_url = get_hmr_target_url() or get_target_url()
if target_url is None:
await socket.close(code=1011, reason="SSR dev server not running")
return
ws_target = target_url.replace("http://", "ws://").replace("https://", "wss://")
scope_dict = dict(socket.scope)
ws_path = str(scope_dict.get("path", "/"))
query_bytes = cast("bytes", scope_dict.get("query_string", b""))
ws_url = _build_proxy_url(ws_target, ws_path, query_bytes.decode("utf-8") if query_bytes else "")
if _is_proxy_debug():
console.print(f"[dim][ssr-proxy-ws] {ws_path} → {ws_url}[/]")
headers = _extract_forward_headers(scope_dict)
subprotocols = _extract_subprotocols(scope_dict)
typed_subprotocols: list[Subprotocol] = [cast("Subprotocol", p) for p in subprotocols]
await socket.accept(subprotocols=typed_subprotocols[0] if typed_subprotocols else None)
await _handle_ssr_websocket_proxy(socket, ws_url, headers, typed_subprotocols)
return SSRProxyController
[docs]
@dataclass
class StaticFilesConfig:
"""Configuration for static file serving.
This configuration is passed to Litestar's static files router.
"""
after_request: "AfterRequestHookHandler | None" = None
after_response: "AfterResponseHookHandler | None" = None
before_request: "BeforeRequestHookHandler | None" = None
cache_control: "CacheControlHeader | None" = None
exception_handlers: "ExceptionHandlersMap | None" = None
guards: "list[Guard] | None" = None # pyright: ignore[reportUnknownVariableType]
middleware: "Sequence[Middleware] | None" = None
opt: "dict[str, Any] | None" = None
security: "Sequence[SecurityRequirement] | None" = None
tags: "Sequence[str] | None" = None
[docs]
class ViteProcess:
"""Manages the Vite development server process.
This class handles starting and stopping the Vite dev server process,
with proper thread safety and graceful shutdown. It registers signal
handlers for SIGTERM and SIGINT to ensure child processes are terminated
even if Python is killed externally.
"""
_instances: "list[ViteProcess]" = []
_signals_registered: bool = False
_original_handlers: "dict[int, Any]" = {}
[docs]
def __init__(self, executor: "JSExecutor") -> None:
"""Initialize the Vite process manager.
Args:
executor: The JavaScript executor to use for running Vite.
"""
self.process: "subprocess.Popen[Any] | None" = None
self._lock = threading.Lock()
self._executor = executor
ViteProcess._instances.append(self)
if not ViteProcess._signals_registered:
self._register_signal_handlers()
ViteProcess._signals_registered = True
import atexit
atexit.register(ViteProcess._cleanup_all_instances)
@classmethod
def _register_signal_handlers(cls) -> None:
"""Register signal handlers for graceful shutdown on SIGTERM/SIGINT."""
for sig in (signal.SIGTERM, signal.SIGINT):
try:
original = signal.signal(sig, cls._signal_handler)
cls._original_handlers[sig] = original
except (OSError, ValueError):
pass
@classmethod
def _signal_handler(cls, signum: int, frame: Any) -> None:
"""Handle termination signals by stopping all Vite processes first."""
cls._cleanup_all_instances()
original = cls._original_handlers.get(signum, signal.SIG_DFL)
if callable(original) and original not in {signal.SIG_IGN, signal.SIG_DFL}:
original(signum, frame)
elif original == signal.SIG_DFL:
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
@classmethod
def _cleanup_all_instances(cls) -> None:
"""Stop all tracked ViteProcess instances."""
for instance in cls._instances:
with suppress(Exception):
instance.stop()
[docs]
def start(self, command: list[str], cwd: "Path | str | None") -> None:
"""Start the Vite process.
Args:
command: The command to run (e.g., ["npm", "run", "dev"]).
cwd: The working directory for the process.
If the process exits immediately, this method captures stdout/stderr and raises a
ViteProcessError with diagnostic details.
Raises:
ViteProcessError: If the process fails to start.
"""
if cwd is not None and isinstance(cwd, str):
cwd = Path(cwd)
try:
with self._lock:
if self.process and self.process.poll() is None:
return
if cwd:
self.process = self._executor.run(command, cwd)
if self.process and self.process.poll() is not None:
stdout, stderr = self.process.communicate()
out_str = stdout.decode(errors="ignore") if stdout else ""
err_str = stderr.decode(errors="ignore") if stderr else ""
console.print(
"[red]Vite process exited immediately.[/]\n"
f"[red]Command:[/] {' '.join(command)}\n"
f"[red]Exit code:[/] {self.process.returncode}\n"
f"[red]Stdout:[/]\n{out_str or '<empty>'}\n"
f"[red]Stderr:[/]\n{err_str or '<empty>'}\n"
"[yellow]Hint: Run `litestar assets doctor` to diagnose configuration issues.[/]"
)
msg = f"Vite process failed to start (exit {self.process.returncode})"
raise ViteProcessError( # noqa: TRY301
msg, command=command, exit_code=self.process.returncode, stderr=err_str, stdout=out_str
)
except Exception as e:
if isinstance(e, ViteProcessError):
raise
console.print(f"[red]Failed to start Vite process: {e!s}[/]")
msg = f"Failed to start Vite process: {e!s}"
raise ViteProcessError(msg) from e
[docs]
def stop(self, timeout: float = 5.0) -> None:
"""Stop the Vite process and all its child processes.
Uses process groups to ensure child processes (node, astro, nuxt, vite, etc.)
are terminated along with the parent npm/npx process.
Args:
timeout: Seconds to wait for graceful shutdown before killing.
Raises:
ViteProcessError: If the process fails to stop.
"""
try:
with self._lock:
self._terminate_process_group(timeout)
except Exception as e:
console.print(f"[red]Failed to stop Vite process: {e!s}[/]")
msg = f"Failed to stop Vite process: {e!s}"
raise ViteProcessError(msg) from e
def _terminate_process_group(self, timeout: float) -> None:
"""Terminate the process group, waiting and killing if needed.
When available, uses process group termination to ensure all child processes are stopped
(e.g., Vite spawning Node/SSR framework processes). The process is started with
``start_new_session=True`` so the process id is the group id.
"""
if not self.process or self.process.poll() is not None:
return
pid = self.process.pid
try:
os.killpg(pid, signal.SIGTERM)
except AttributeError:
self.process.terminate()
except ProcessLookupError:
pass
try:
self.process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
self._force_kill_process_group()
self.process.wait(timeout=1.0)
finally:
self.process = None
def _force_kill_process_group(self) -> None:
"""Force kill the process group if still alive."""
if not self.process:
return
pid = self.process.pid
try:
os.killpg(pid, signal.SIGKILL)
except AttributeError:
self.process.kill()
except ProcessLookupError:
pass
def _atexit_stop(self) -> None:
"""Best-effort stop on interpreter exit."""
with suppress(Exception):
self.stop()
[docs]
class VitePlugin(InitPluginProtocol, CLIPlugin):
"""Vite plugin for Litestar.
This plugin integrates Vite with Litestar, providing:
- Static file serving configuration
- Jinja2 template callables for asset tags
- Vite dev server process management
- Async asset loader initialization
Example::
from litestar import Litestar
from litestar_vite import VitePlugin, ViteConfig
app = Litestar(
plugins=[
VitePlugin(config=ViteConfig(dev_mode=True))
],
)
"""
__slots__ = ("_asset_loader", "_config", "_proxy_target", "_spa_handler", "_static_files_config", "_vite_process")
[docs]
def __init__(
self,
config: "ViteConfig | None" = None,
asset_loader: "ViteAssetLoader | None" = None,
static_files_config: "StaticFilesConfig | None" = None,
) -> None:
"""Initialize the Vite plugin.
Args:
config: Vite configuration. Defaults to ViteConfig() if not provided.
asset_loader: Optional pre-initialized asset loader.
static_files_config: Optional configuration for static file serving.
"""
from litestar_vite.config import ViteConfig
if config is None:
config = ViteConfig()
self._config = config
self._asset_loader = asset_loader
self._vite_process = ViteProcess(executor=config.executor)
self._static_files_config: dict[str, Any] = static_files_config.__dict__ if static_files_config else {}
self._proxy_target: "str | None" = None
self._spa_handler: "AppHandler | None" = None
@property
def config(self) -> "ViteConfig":
"""Get the Vite configuration.
Returns:
The ViteConfig instance.
"""
return self._config
@property
def asset_loader(self) -> "ViteAssetLoader":
"""Get the asset loader instance.
Lazily initializes the loader if not already set.
Returns:
The ViteAssetLoader instance.
"""
if self._asset_loader is None:
self._asset_loader = ViteAssetLoader.initialize_loader(config=self._config)
return self._asset_loader
@property
def spa_handler(self) -> "AppHandler | None":
"""Return the configured SPA handler when SPA mode is enabled.
Returns:
The AppHandler instance, or None when SPA mode is disabled/not configured.
"""
return self._spa_handler
def _resolve_bundle_dir(self) -> Path:
"""Resolve the bundle directory to an absolute path.
Returns:
The absolute path to the bundle directory.
"""
bundle_dir = Path(self._config.bundle_dir)
if not bundle_dir.is_absolute():
return self._config.root_dir / bundle_dir
return bundle_dir
def _resolve_hotfile_path(self) -> Path:
"""Resolve the path to the hotfile.
Returns:
The absolute path to the hotfile.
"""
return self._resolve_bundle_dir() / self._config.hot_file
def _write_hotfile(self, content: str) -> None:
"""Write content to the hotfile.
Args:
content: The content to write (usually the dev server URL).
"""
hotfile_path = self._resolve_hotfile_path()
hotfile_path.parent.mkdir(parents=True, exist_ok=True)
hotfile_path.write_text(content, encoding="utf-8")
def _resolve_dev_command(self) -> "list[str]":
"""Resolve the command to run for the dev server.
Returns:
The list of command arguments.
"""
ext = self._config.runtime.external_dev_server
if isinstance(ext, ExternalDevServer) and ext.enabled:
command = ext.command or self._config.executor.start_command
_log_info(f"Starting external dev server: {' '.join(command)}")
return command
if self._config.hot_reload:
_log_info("Starting Vite dev server (HMR enabled)")
return self._config.run_command
_log_info("Starting Vite watch build process")
return self._config.build_watch_command
def _ensure_proxy_target(self) -> None:
"""Prepare proxy target URL and port for proxy modes (vite, proxy, ssr).
For all proxy modes in dev mode:
- Auto-selects a free port if VITE_PORT is not explicitly set
- Sets the port in runtime config for JS integrations to read
For 'vite' mode specifically:
- Forces loopback host unless VITE_ALLOW_REMOTE is set
- Sets _proxy_target directly (JS writes hotfile when server starts)
For 'proxy'/'ssr' modes:
- Port is written to .litestar.json for SSR framework to read
- SSR framework writes hotfile with actual URL when ready
- Proxy discovers target from hotfile at request time
"""
if not self._config.is_dev_mode:
return
if self._config.proxy_mode is None:
return
if os.getenv("VITE_PORT") is None and self._config.runtime.port == 5173:
self._config.runtime.port = _pick_free_port()
if self._config.proxy_mode == "vite":
if self._proxy_target is not None:
return
if os.getenv("VITE_ALLOW_REMOTE", "False") not in TRUE_VALUES:
self._config.runtime.host = "127.0.0.1"
self._proxy_target = f"{self._config.protocol}://{self._config.host}:{self._config.port}"
def _configure_inertia(self, app_config: "AppConfig") -> "AppConfig":
"""Configure Inertia.js by registering an InertiaPlugin instance.
This is called automatically when `inertia` config is provided to ViteConfig.
Users can still use InertiaPlugin manually for more control.
Args:
app_config: The Litestar application configuration.
Returns:
The modified application configuration.
"""
from litestar_vite.inertia.plugin import InertiaPlugin
inertia_plugin = InertiaPlugin(config=self._config.inertia) # type: ignore[arg-type]
app_config.plugins.append(inertia_plugin)
return app_config
[docs]
def on_cli_init(self, cli: "Group") -> None:
"""Register CLI commands.
Args:
cli: The Click command group to add commands to.
"""
from litestar_vite.cli import vite_group
cli.add_command(vite_group)
def _configure_jinja_callables(self, app_config: "AppConfig") -> None:
"""Register Jinja2 template callables for Vite asset handling.
Args:
app_config: The Litestar application configuration.
"""
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar_vite.loader import render_asset_tag, render_hmr_client, render_routes, render_static_asset
template_config = app_config.template_config # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
if template_config and isinstance(
template_config.engine_instance, # pyright: ignore[reportUnknownMemberType]
JinjaTemplateEngine,
):
engine = template_config.engine_instance # pyright: ignore[reportUnknownMemberType]
engine.register_template_callable(key="vite_hmr", template_callable=render_hmr_client)
engine.register_template_callable(key="vite", template_callable=render_asset_tag)
engine.register_template_callable(key="vite_static", template_callable=render_static_asset)
engine.register_template_callable(key="vite_routes", template_callable=render_routes)
def _configure_static_files(self, app_config: "AppConfig") -> None:
"""Configure static file serving for Vite assets.
The static files router serves real files (JS, CSS, images). SPA fallback (serving
index.html for client-side routes) is handled by the AppHandler.
Args:
app_config: The Litestar application configuration.
"""
bundle_dir = self._resolve_bundle_dir()
resource_dir = Path(self._config.resource_dir)
if not resource_dir.is_absolute():
resource_dir = self._config.root_dir / resource_dir
static_dir = Path(self._config.static_dir)
if not static_dir.is_absolute():
static_dir = self._config.root_dir / static_dir
static_dirs = [bundle_dir, resource_dir]
if static_dir.exists() and static_dir != bundle_dir:
static_dirs.append(static_dir)
opt: dict[str, Any] = {}
if self._config.exclude_static_from_auth:
opt["exclude_from_auth"] = True
user_opt = self._static_files_config.get("opt", {})
if user_opt:
opt = {**opt, **user_opt}
base_config: dict[str, Any] = {
"directories": (static_dirs if self._config.is_dev_mode else [bundle_dir]),
"path": self._config.asset_url,
"name": "vite",
"html_mode": False,
"include_in_schema": False,
"opt": opt,
"exception_handlers": {NotFoundException: _static_not_found_handler},
}
user_config = {k: v for k, v in self._static_files_config.items() if k != "opt"}
static_files_config: dict[str, Any] = {**base_config, **user_config}
app_config.route_handlers.append(create_static_files_router(**static_files_config))
def _configure_dev_proxy(self, app_config: "AppConfig") -> None:
"""Configure dev proxy middleware and handlers based on proxy_mode.
Args:
app_config: The Litestar application configuration.
"""
proxy_mode = self._config.proxy_mode
hotfile_path = self._resolve_hotfile_path()
if proxy_mode == "vite":
self._configure_vite_proxy(app_config, hotfile_path)
elif proxy_mode == "proxy":
self._configure_ssr_proxy(app_config, hotfile_path)
def _configure_vite_proxy(self, app_config: "AppConfig", hotfile_path: Path) -> None:
"""Configure Vite proxy mode (allow list).
Args:
app_config: The Litestar application configuration.
hotfile_path: Path to the hotfile.
"""
self._ensure_proxy_target()
app_config.middleware.append(
DefineMiddleware(
ViteProxyMiddleware,
hotfile_path=hotfile_path,
asset_url=self._config.asset_url,
resource_dir=self._config.resource_dir,
bundle_dir=self._config.bundle_dir,
root_dir=self._config.root_dir,
http2=self._config.http2,
)
)
hmr_path = f"{self._config.asset_url.rstrip('/')}/vite-hmr"
app_config.route_handlers.append(
create_vite_hmr_handler(hotfile_path=hotfile_path, hmr_path=hmr_path, asset_url=self._config.asset_url)
)
def _configure_ssr_proxy(self, app_config: "AppConfig", hotfile_path: Path) -> None:
"""Configure SSR proxy mode (deny list).
Args:
app_config: The Litestar application configuration.
hotfile_path: Path to the hotfile.
"""
self._ensure_proxy_target()
external = self._config.external_dev_server
static_target = external.target if external else None
app_config.route_handlers.append(
create_ssr_proxy_controller(
target=static_target,
hotfile_path=hotfile_path if static_target is None else None,
http2=external.http2 if external else True,
)
)
hmr_path = f"{self._config.asset_url.rstrip('/')}/vite-hmr"
app_config.route_handlers.append(
create_vite_hmr_handler(hotfile_path=hotfile_path, hmr_path=hmr_path, asset_url=self._config.asset_url)
)
[docs]
def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
"""Configure the Litestar application for Vite.
This method wires up supporting configuration for dev/prod operation:
- Adds types used by generated handlers to the signature namespace.
- Ensures a consistent NotFound handler for asset/proxy lookups.
- Registers optional Inertia and Jinja integrations.
- Configures static file routing when enabled.
- Configures dev proxy middleware based on proxy_mode.
- Creates/initializes the SPA handler where applicable and registers lifespans.
Args:
app_config: The Litestar application configuration.
Returns:
The modified application configuration.
"""
from litestar import Response
from litestar.connection import Request as LitestarRequest
app_config.signature_namespace["Response"] = Response
app_config.signature_namespace["Request"] = LitestarRequest
handlers: ExceptionHandlersMap = cast("ExceptionHandlersMap", app_config.exception_handlers or {}) # pyright: ignore
if NotFoundException not in handlers:
handlers[NotFoundException] = _vite_not_found_handler
app_config.exception_handlers = handlers # pyright: ignore[reportUnknownMemberType]
if self._config.inertia is not None:
app_config = self._configure_inertia(app_config)
if JINJA_INSTALLED and self._config.mode in {"template", "htmx"}:
self._configure_jinja_callables(app_config)
skip_static = self._config.mode == "external" and self._config.is_dev_mode
if self._config.set_static_folders and not skip_static:
self._configure_static_files(app_config)
if self._config.is_dev_mode and self._config.proxy_mode is not None and not _is_non_serving_assets_cli():
self._configure_dev_proxy(app_config)
use_spa_handler = self._config.spa_handler and self._config.mode in {"spa", "framework"}
use_spa_handler = use_spa_handler or (self._config.mode == "external" and not self._config.is_dev_mode)
if use_spa_handler:
from litestar_vite._handler import AppHandler
self._spa_handler = AppHandler(self._config)
app_config.route_handlers.append(self._spa_handler.create_route_handler())
elif self._config.mode == "hybrid":
from litestar_vite._handler import AppHandler
self._spa_handler = AppHandler(self._config)
app_config.lifespan.append(self.lifespan) # pyright: ignore[reportUnknownMemberType]
return app_config
def _check_health(self) -> None:
"""Check if the Vite dev server is running and ready.
Polls the dev server URL for up to 5 seconds.
"""
import time
url = f"{self._config.protocol}://{self._config.host}:{self._config.port}/__vite_ping"
for _ in range(50):
try:
httpx.get(url, timeout=0.1)
except httpx.HTTPError:
time.sleep(0.1)
else:
_log_success("Vite dev server responded to health check")
return
_log_fail("Vite server health check failed")
def _run_health_check(self) -> None:
"""Run the appropriate health check based on proxy mode."""
match self._config.proxy_mode:
case "proxy":
self._check_ssr_health(self._resolve_hotfile_path())
case _:
self._check_health()
def _check_ssr_health(self, hotfile_path: Path, timeout: float = 10.0) -> bool:
"""Wait for SSR framework to be ready via hotfile.
Polls intelligently for the hotfile and validates HTTP connectivity.
Exits early as soon as the server is confirmed ready.
Args:
hotfile_path: Path to the hotfile written by the SSR framework.
timeout: Maximum time to wait in seconds (default 10s).
Returns:
True if SSR server is ready, False if timeout reached.
"""
import time
start = time.time()
last_url = None
while time.time() - start < timeout:
if hotfile_path.exists():
try:
url = hotfile_path.read_text(encoding="utf-8").strip()
if url:
last_url = url
resp = httpx.get(url, timeout=0.5, follow_redirects=True)
if resp.status_code < 500:
_log_success(f"SSR server ready at {url}")
return True
except OSError:
pass
except httpx.HTTPError:
pass
time.sleep(0.1)
if last_url:
_log_fail(f"SSR server at {last_url} did not respond within {timeout}s")
else:
_log_fail(f"SSR hotfile not found at {hotfile_path} within {timeout}s")
return False
def _export_types_sync(self, app: "Litestar") -> None:
"""Export type metadata synchronously on startup.
This exports OpenAPI schema, route metadata (JSON), and typed routes (TypeScript)
when type generation is enabled. The Vite plugin watches these files and triggers
@hey-api/openapi-ts when they change.
Args:
app: The Litestar application instance.
"""
from litestar_vite.config import TypeGenConfig
if not isinstance(self._config.types, TypeGenConfig):
return
types_config = self._config.types
try:
import msgspec
from litestar._openapi.plugin import OpenAPIPlugin
from litestar.serialization import encode_json, get_serializer
from litestar_vite.codegen import generate_routes_json, generate_routes_ts
openapi_plugin = next((p for p in app.plugins._plugins if isinstance(p, OpenAPIPlugin)), None) # pyright: ignore[reportPrivateUsage]
has_openapi = openapi_plugin is not None and openapi_plugin._openapi_config is not None # pyright: ignore[reportPrivateUsage]
exported_files: list[str] = []
unchanged_files: list[str] = []
openapi_schema = self._export_openapi_schema_sync(
app=app,
has_openapi=has_openapi,
types_config=types_config,
msgspec=msgspec,
encode_json=encode_json,
get_serializer=get_serializer,
exported_files=exported_files,
unchanged_files=unchanged_files,
)
routes_data = generate_routes_json(app, include_components=True, openapi_schema=openapi_schema)
routes_data["litestar_version"] = resolve_litestar_version()
self._export_routes_json_sync(
msgspec=msgspec,
types_config=types_config,
encode_json=encode_json,
routes_data=routes_data,
exported_files=exported_files,
unchanged_files=unchanged_files,
)
if types_config.generate_routes:
routes_ts_content = generate_routes_ts(
app, openapi_schema=openapi_schema, global_route=types_config.global_route
)
self._export_routes_ts_sync(
types_config=types_config,
routes_ts_content=routes_ts_content,
exported_files=exported_files,
unchanged_files=unchanged_files,
)
if exported_files:
_log_success(f"Types exported → {', '.join(exported_files)}")
except (OSError, TypeError, ValueError, ImportError) as e: # pragma: no cover
_log_warn(f"Type export failed: {e}")
def _export_openapi_schema_sync(
self,
*,
app: "Litestar",
has_openapi: bool,
types_config: "TypeGenConfig",
msgspec: Any,
encode_json: Any,
get_serializer: Any,
exported_files: list[str],
unchanged_files: list[str],
) -> "dict[str, Any] | None":
if not has_openapi:
console.print("[yellow]! OpenAPI schema not available; skipping openapi.json export[/]")
return None
try:
encoders: Any
try:
encoders = app.type_encoders # pyright: ignore[reportUnknownMemberType]
except AttributeError:
encoders = None
serializer = get_serializer(encoders if isinstance(encoders, dict) else None)
schema_dict = app.openapi_schema.to_schema()
schema_content = msgspec.json.format(encode_json(schema_dict, serializer=serializer), indent=2)
openapi_path = types_config.openapi_path
if openapi_path is None:
openapi_path = types_config.output / "openapi.json"
if _write_if_changed(openapi_path, schema_content):
exported_files.append(f"openapi: {_fmt_path(openapi_path)}")
else:
unchanged_files.append("openapi.json")
except (TypeError, ValueError, OSError, AttributeError) as exc: # pragma: no cover
console.print(f"[yellow]! OpenAPI export skipped: {exc}[/]")
else:
return schema_dict
return None
def _export_routes_json_sync(
self,
*,
msgspec: Any,
types_config: "TypeGenConfig",
encode_json: Any,
routes_data: dict[str, Any],
exported_files: list[str],
unchanged_files: list[str],
) -> None:
routes_content = msgspec.json.format(encode_json(routes_data), indent=2)
routes_path = types_config.routes_path
if routes_path is None:
routes_path = types_config.output / "routes.json"
if _write_if_changed(routes_path, routes_content):
exported_files.append(_fmt_path(routes_path))
else:
unchanged_files.append("routes.json")
def _export_routes_ts_sync(
self,
*,
types_config: "TypeGenConfig",
routes_ts_content: str,
exported_files: list[str],
unchanged_files: list[str],
) -> None:
routes_ts_path = types_config.routes_ts_path
if routes_ts_path is None:
routes_ts_path = types_config.output / "routes.ts"
if _write_if_changed(routes_ts_path, routes_ts_content):
exported_files.append(_fmt_path(routes_ts_path))
else:
unchanged_files.append("routes.ts")
[docs]
@contextmanager
def server_lifespan(self, app: "Litestar") -> "Iterator[None]":
"""Server-level lifespan context manager (runs ONCE per server, before workers).
This is called by Litestar CLI before workers start. It handles:
- Environment variable setup (with logging)
- Vite dev server process start/stop (ONE instance for all workers)
- Type export on startup
Note: SPA handler and asset loader initialization happens in the per-worker
`lifespan` method, which is auto-registered in `on_app_init`.
Hotfile behavior: the hotfile is written before starting the dev server to ensure proxy
middleware and SPA handlers can resolve a target URL immediately on first request.
Args:
app: The Litestar application instance.
Yields:
None
"""
if self._config.is_dev_mode:
self._ensure_proxy_target()
if self._config.set_environment:
set_environment(config=self._config)
set_app_environment(app)
_log_info("Applied Vite environment variables")
self._export_types_sync(app)
if self._config.is_dev_mode and self._config.runtime.start_dev_server:
ext = self._config.runtime.external_dev_server
is_external = isinstance(ext, ExternalDevServer) and ext.enabled
command_to_run = self._resolve_dev_command()
if is_external and isinstance(ext, ExternalDevServer) and ext.target:
self._write_hotfile(ext.target)
elif not is_external:
target_url = f"{self._config.protocol}://{self._config.host}:{self._config.port}"
self._write_hotfile(target_url)
try:
self._vite_process.start(command_to_run, self._config.root_dir)
_log_success("Dev server process started")
if self._config.health_check and not is_external:
self._run_health_check()
yield
finally:
self._vite_process.stop()
_log_info("Dev server process stopped.")
else:
yield
[docs]
@asynccontextmanager
async def lifespan(self, app: "Litestar") -> "AsyncIterator[None]":
"""Worker-level lifespan context manager (runs per worker process).
This is auto-registered in `on_app_init` and handles per-worker initialization:
- Environment variable setup (silently - each worker needs process-local env vars)
- Asset loader initialization
- SPA handler initialization
- Route metadata injection
Note: The Vite dev server process is started in `server_lifespan`, which
runs ONCE per server before workers start.
Args:
app: The Litestar application instance.
Yields:
None
"""
from litestar_vite.loader import ViteAssetLoader
if self._config.set_environment:
set_environment(config=self._config)
set_app_environment(app)
if self._asset_loader is None:
self._asset_loader = ViteAssetLoader(config=self._config)
await self._asset_loader.initialize()
if self._spa_handler is not None and not self._spa_handler.is_initialized:
self._spa_handler.initialize_sync(vite_url=self._proxy_target)
_log_success("SPA handler initialized")
is_ssr_mode = self._config.mode == "framework" or self._config.proxy_mode == "proxy"
if not self._config.is_dev_mode and not self._config.has_built_assets() and not is_ssr_mode:
_log_warn(
"Vite dev server is disabled (dev_mode=False) but no index.html was found. "
"Run your front-end build or set VITE_DEV_MODE=1 to enable HMR."
)
try:
yield
finally:
if self._spa_handler is not None:
await self._spa_handler.shutdown_async()
def _normalize_proxy_prefixes(
base_prefixes: tuple[str, ...],
asset_url: "str | None" = None,
resource_dir: "Path | None" = None,
bundle_dir: "Path | None" = None,
root_dir: "Path | None" = None,
) -> tuple[str, ...]:
prefixes: list[str] = list(base_prefixes)
if asset_url:
prefixes.append(_normalize_prefix(asset_url))
def _add_path(path: Path | str | None) -> None:
if path is None:
return
p = Path(path)
if root_dir and p.is_absolute():
with suppress(ValueError):
p = p.relative_to(root_dir)
prefixes.append(_normalize_prefix(str(p).replace("\\", "/")))
_add_path(resource_dir)
_add_path(bundle_dir)
seen: set[str] = set()
unique: list[str] = []
for p in prefixes:
if p not in seen:
unique.append(p)
seen.add(p)
return tuple(unique)