"""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
Example::
from litestar import Litestar
from litestar_vite import VitePlugin, ViteConfig
app = Litestar(
plugins=[VitePlugin(config=ViteConfig(dev_mode=True))],
)
"""
from __future__ import annotations
import importlib.metadata
import json
import os
import signal
import subprocess
import threading
from contextlib import asynccontextmanager, contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import anyio
import httpx # used in proxy middleware health and HTTP forwarding
import websockets # used in proxy middleware WS forwarding
from litestar.cli._utils import console # pyright: ignore[reportPrivateImportUsage]
from litestar.enums import ScopeType
from litestar.exceptions import 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 websockets.typing import Subprotocol
from litestar_vite.config import JINJA_INSTALLED, TRUE_VALUES, TypeGenConfig, ViteConfig
from litestar_vite.exceptions import ViteProcessError
# Disconnect exceptions that should be silently ignored during WebSocket shutdown
_DISCONNECT_EXCEPTIONS = (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed)
# Cache debug flag check to avoid repeated os.environ lookups
_vite_proxy_debug: bool | None = None
def _is_proxy_debug() -> bool:
"""Check if VITE_PROXY_DEBUG is enabled (cached)."""
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.
"""
import logging
if not _is_proxy_debug():
for logger_name in ("httpx", "websockets", "uvicorn.protocols.websockets"):
logging.getLogger(logger_name).setLevel(logging.WARNING)
# Configure proxy logging on module load
_configure_proxy_logging()
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator, Sequence
from click import Group
from litestar import Litestar
from litestar.config.app import AppConfig
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.executor import JSExecutor
from litestar_vite.loader import ViteAssetLoader
from litestar_vite.spa import ViteSPAHandler
def _write_runtime_config_file(config: ViteConfig) -> str:
"""Write a JSON handoff file for the Vite plugin and return its path."""
root = config.root_dir or Path.cwd()
path = Path(root) / ".litestar-vite.json"
types = config.types if isinstance(config.types, TypeGenConfig) else None
deploy = config.deploy_config
resource_dir = config.resource_dir
resource_dir_value = str(resource_dir) if resource_dir != Path("src") else None
bundle_dir_value = str(config.bundle_dir)
ssr_out_dir_value = str(config.ssr_output_dir) if config.ssr_output_dir else None
if resource_dir_value is None:
# Keep JS defaults (resources/bootstrap/ssr)
ssr_out_dir_value = None
payload = {
"assetUrl": config.asset_url,
"baseUrl": config.base_url,
"bundleDir": bundle_dir_value,
"resourceDir": resource_dir_value,
"publicDir": str(config.public_dir),
"manifest": config.manifest_name,
"mode": config.mode,
"ssrOutDir": ssr_out_dir_value,
"types": {
"enabled": types.enabled,
"output": str(types.output),
"openapiPath": str(types.openapi_path),
"routesPath": str(types.routes_path),
"generateZod": types.generate_zod,
"generateSdk": types.generate_sdk,
}
if types
else None,
"deploy": {
"storageBackend": deploy.storage_backend if deploy else None,
"deleteOrphaned": deploy.delete_orphaned if deploy else None,
"includeManifest": deploy.include_manifest if deploy else None,
"contentTypes": deploy.content_types if deploy else None,
},
}
path.write_text(json.dumps(payload, indent=2))
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 = _resolve_litestar_version()
asset_url = asset_url_override or config.asset_url
base_url = config.base_url or asset_url
if asset_url:
os.environ.setdefault("ASSET_URL", asset_url)
if base_url:
os.environ.setdefault("VITE_BASE_URL", base_url)
os.environ.setdefault("VITE_ALLOW_REMOTE", str(True))
# VITE_PORT must be force-set because _ensure_proxy_target() may have picked a new port
os.environ["VITE_PORT"] = str(config.port)
os.environ["VITE_HOST"] = config.host
os.environ.setdefault("VITE_PROTOCOL", config.protocol)
os.environ.setdefault("VITE_PROXY_MODE", config.proxy_mode)
os.environ.setdefault("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))
os.environ.setdefault("APP_URL", f"http://localhost:{os.environ.get('LITESTAR_PORT', '8000')}")
if config.is_dev_mode:
os.environ.setdefault("VITE_DEV_MODE", str(config.is_dev_mode))
config_path = _write_runtime_config_file(config)
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.
"""
# Export OpenAPI schema path for Vite plugin health checks
openapi_config = app.openapi_config
if openapi_config is not None:
path = getattr(openapi_config, "path", None)
if isinstance(path, str) and path:
# The path attribute contains the schema endpoint path (default: "/schema")
os.environ.setdefault("LITESTAR_OPENAPI_PATH", path)
def _resolve_litestar_version() -> str:
"""Safely resolve the installed Litestar version as a string."""
try:
return importlib.metadata.version("litestar")
except importlib.metadata.PackageNotFoundError:
# Fallback to runtime constant if available
try:
from litestar import __version__
return getattr(__version__, "formatted", lambda: str(__version__))()
except (AttributeError, TypeError): # pragma: no cover - extremely rare fallback
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_PATH_PREFIXES: tuple[str, ...] = (
"/@vite",
"/@id/",
"/@fs/",
"/@react-refresh",
"/@vite/client",
"/@vite/env",
"/vite-hmr",
"/node_modules/.vite/",
"/@analogjs/",
"/src/",
)
def _normalize_prefix(prefix: str) -> str:
if not prefix.startswith("/"):
prefix = f"/{prefix}"
if not prefix.endswith("/"):
prefix = f"{prefix}/"
return prefix
[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.
"""
# Only handle HTTP requests - WebSocket HMR is handled by a dedicated route handler
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.asset_prefix = _normalize_prefix(asset_url) if asset_url else "/"
self.http2 = http2
self._proxy_path_prefixes = _normalize_proxy_prefixes(
base_prefixes=_PROXY_PATH_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.
Caches the result to avoid reading the file on every request.
The cache is invalidated if the hotfile is modified.
"""
try:
url = self.hotfile_path.read_text().strip()
if url != self._cached_target:
self._cached_target = url
if _is_proxy_debug():
console.print(f"[dim][vite-proxy] Target: {url}[/]")
return url.rstrip("/")
except FileNotFoundError:
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)
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) -> bool:
# Litestar may hand us percent-encoded paths (e.g. /%40vite/client).
try:
from urllib.parse import unquote
except ImportError: # pragma: no cover - extremely small surface
return path.startswith(self._proxy_path_prefixes)
decoded = unquote(path)
return decoded.startswith(self._proxy_path_prefixes) or path.startswith(self._proxy_path_prefixes)
async def _proxy_http(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
target_base_url = self._get_target_base_url()
if target_base_url is None:
# Hotfile not found - Vite server not running
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"})
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)
# Use HTTP/2 when enabled for better connection multiplexing
# Note: httpx handles the protocol negotiation automatically
# HTTP/2 requires the h2 package - gracefully fallback if not installed
http2_enabled = self.http2
if http2_enabled:
try:
import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
except ImportError:
http2_enabled = False
async with httpx.AsyncClient(http2=http2_enabled) as client:
try:
upstream_resp = await client.request(method, url, headers=headers, content=body, timeout=10.0)
except httpx.HTTPError as exc: # pragma: no cover - network failure path
await send(
{
"type": "http.response.start",
"status": 502,
"headers": [(b"content-type", b"text/plain")],
}
)
await send({"type": "http.response.body", "body": str(exc).encode()})
return
response_headers = [(k.encode(), v.encode()) for k, v in upstream_resp.headers.items()]
await send(
{
"type": "http.response.start",
"status": upstream_resp.status_code,
"headers": response_headers,
}
)
await send({"type": "http.response.body", "body": upstream_resp.content})
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.
Returns None if the hotfile doesn't exist (Vite not running).
Note: Vite's HMR WebSocket listens at {base}{hmr.path}, so we preserve
the full path including the asset prefix (e.g., /static/vite-hmr).
"""
try:
vite_url = hotfile_path.read_text().strip()
except FileNotFoundError:
return None
ws_url = vite_url.replace("http://", "ws://").replace("https://", "wss://")
# Use the original path as-is - Vite expects the full path including base
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.
Note: We exclude protocol-specific headers that websockets library handles itself.
The sec-websocket-protocol header is also excluded since we handle subprotocols separately.
"""
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."""
for key, value in scope.get("headers", []):
if key.lower() == b"sec-websocket-protocol":
# Subprotocols are comma-separated
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( # noqa: C901
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."""
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 if typed_subprotocols else 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 # Normal client disconnect
except BaseException as exc:
if hasattr(exc, "exceptions"):
non_disconnect = [
err for err in getattr(exc, "exceptions", []) if not isinstance(err, _DISCONNECT_EXCEPTIONS)
]
if non_disconnect:
raise
elif not isinstance(exc, _DISCONNECT_EXCEPTIONS):
raise
return vite_hmr_proxy
[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
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.
"""
_atexit_registered: bool = False
[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[bytes] | None" = None
self._lock = threading.Lock()
self._executor = executor
if not ViteProcess._atexit_registered:
import atexit
atexit.register(self._atexit_stop)
ViteProcess._atexit_registered = True
[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.
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 the process exited immediately, surface stdout/stderr for debugging
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.
Args:
timeout: Seconds to wait for graceful shutdown before killing.
Raises:
ViteProcessError: If the process fails to stop.
"""
try:
with self._lock:
if self.process and self.process.poll() is None:
# Send SIGTERM for graceful shutdown
if hasattr(signal, "SIGTERM"):
self.process.terminate()
try:
self.process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Force kill if still alive
if hasattr(signal, "SIGKILL"):
self.process.kill()
self.process.wait(timeout=1.0)
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 _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",
"_use_server_lifespan",
"_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._use_server_lifespan = True
self._proxy_target: "str | None" = None
self._spa_handler: "ViteSPAHandler | None" = None
@property
def config(self) -> "ViteConfig":
"""Get the Vite configuration."""
return self._config
@property
def asset_loader(self) -> "ViteAssetLoader":
"""Get the asset loader instance.
Lazily initializes the loader if not already set.
"""
from litestar_vite.loader import ViteAssetLoader
if self._asset_loader is None:
self._asset_loader = ViteAssetLoader.initialize_loader(config=self._config)
return self._asset_loader
def _ensure_proxy_target(self) -> None:
"""Prepare proxy target URL and hotfile for proxy mode."""
if self._proxy_target is not None or self._config.proxy_mode != "proxy" or not self._config.is_dev_mode:
return
# Force loopback for internal dev server unless explicitly overridden
if os.getenv("VITE_ALLOW_REMOTE", "False") not in TRUE_VALUES:
self._config.runtime.host = "127.0.0.1"
# If VITE_PORT not explicitly set, pick a free one for the internal server
if os.getenv("VITE_PORT") is None:
self._config.runtime.port = _pick_free_port()
self._proxy_target = f"{self._config.protocol}://{self._config.host}:{self._config.port}"
# Note: We don't write the hotfile here anymore.
# The TypeScript Vite plugin writes it when the dev server starts.
# This ensures the hotfile contains the actual Vite server URL.
[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)
[docs]
def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
"""Configure the Litestar application for Vite.
This method:
- Registers Jinja2 template callables (if Jinja2 is installed and template mode)
- Configures static file serving
- Sets up SPA handler if in SPA mode
- Sets up the server lifespan hook if enabled
Args:
app_config: The Litestar application configuration.
Returns:
The modified application configuration.
"""
from litestar_vite.loader import (
render_asset_tag,
render_hmr_client,
render_partial_asset_tag,
render_static_asset,
)
# Register Jinja2 template callables if Jinja2 is installed and in template mode
if JINJA_INSTALLED and self._config.mode in {"template", "htmx"}:
from litestar.contrib.jinja import JinjaTemplateEngine
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_partial",
template_callable=render_partial_asset_tag,
)
# Configure static file serving
if self._config.set_static_folders:
static_dirs = [
Path(self._config.bundle_dir),
Path(self._config.resource_dir),
]
if Path(self._config.public_dir).exists() and self._config.public_dir != self._config.bundle_dir:
static_dirs.append(Path(self._config.public_dir))
base_config = {
"directories": (static_dirs if self._config.is_dev_mode else [Path(self._config.bundle_dir)]),
"path": self._config.asset_url,
"name": "vite",
"html_mode": False,
"include_in_schema": False,
"opt": {"exclude_from_auth": True},
}
static_files_config: dict[str, Any] = {**base_config, **self._static_files_config}
app_config.route_handlers.append(create_static_files_router(**static_files_config))
# Add dev proxy middleware and WebSocket HMR handler for single-port mode
if self._config.is_dev_mode and self._config.proxy_mode == "proxy":
self._ensure_proxy_target()
# Both middleware and WebSocket handler read the Vite URL from the hotfile
# This ensures they always connect to the correct port even if it changes
# Resolve bundle_dir relative to root_dir to handle --app-dir scenarios
bundle_dir = self._config.bundle_dir
if not bundle_dir.is_absolute():
bundle_dir = self._config.root_dir / bundle_dir
hotfile_path = bundle_dir / self._config.hot_file
# Add middleware for HTTP proxy requests
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,
)
)
# Add WebSocket route handler for HMR
# Vite HMR uses WebSocket at {asset_url}/vite-hmr
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,
)
)
console.print(f"[dim]Vite HMR proxy enabled at {hmr_path}[/]")
# Add SPA catch-all route handler if in SPA mode
if self._config.mode == "spa" and self._config.spa_handler:
from litestar_vite.spa import ViteSPAHandler
self._spa_handler = ViteSPAHandler(self._config)
# Add the catch-all route - it should be last to avoid conflicts with API routes
app_config.route_handlers.append(self._spa_handler.create_route_handler())
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:
return
console.print("[red]Vite server health check failed[/]")
def _export_types_sync(self, app: "Litestar") -> None:
"""Export type metadata synchronously on startup.
This exports OpenAPI schema and route metadata 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) or not self._config.types.enabled:
return
try:
import msgspec
from litestar.serialization import encode_json, get_serializer
from litestar_vite.codegen import generate_routes_json
console.print("[dim]Exporting type metadata for Vite...[/]")
# Check if OpenAPI is configured by looking at the plugins registry
# (accessing openapi_schema property directly raises when not configured)
from litestar._openapi.plugin import OpenAPIPlugin
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]
if has_openapi:
try:
serializer = get_serializer(
app.type_encoders if isinstance(getattr(app, "type_encoders", None), dict) else None
)
schema_dict = app.openapi_schema.to_schema()
schema_content = msgspec.json.format(
encode_json(schema_dict, serializer=serializer),
indent=2,
)
self._config.types.openapi_path.parent.mkdir(parents=True, exist_ok=True)
self._config.types.openapi_path.write_bytes(schema_content)
except (TypeError, ValueError, OSError, AttributeError) as exc: # pragma: no cover
console.print(f"[yellow]! OpenAPI export skipped: {exc}[/]")
else:
console.print("[yellow]! OpenAPI schema not available; skipping openapi.json export[/]")
# Export routes
routes_data = generate_routes_json(app, include_components=True)
routes_data["litestar_version"] = _resolve_litestar_version()
routes_content = msgspec.json.format(
msgspec.json.encode(routes_data),
indent=2,
)
self._config.types.routes_path.parent.mkdir(parents=True, exist_ok=True)
self._config.types.routes_path.write_bytes(routes_content)
console.print(
f"[green]✓ Types exported to {self._config.types.routes_path}[/]"
+ (f" (openapi: {self._config.types.openapi_path})" if has_openapi else " (openapi skipped)")
)
except (OSError, TypeError, ValueError, ImportError) as e: # pragma: no cover
console.print(f"[yellow]! Type export failed: {e}[/]")
def _inject_routes_to_spa_handler(self, app: "Litestar") -> None:
"""Extract route metadata and inject it into the SPA handler.
This method is called during lifespan startup when SPA route injection
is enabled. It uses generate_routes_json() to extract routes and passes
them to the SPA handler for HTML injection.
Args:
app: The Litestar application instance.
"""
spa_config = self._config.spa_config
if self._spa_handler is None or spa_config is None:
return
if not spa_config.inject_routes:
return
try:
from litestar_vite.codegen import generate_routes_json
# Extract routes with filtering
routes_data = generate_routes_json(
app,
only=spa_config.routes_include,
exclude=spa_config.routes_exclude,
include_components=True,
)
self._spa_handler.set_routes_metadata(routes_data)
console.print(f"[dim]Injected {len(routes_data.get('routes', {}))} routes into SPA handler[/]")
except (ImportError, TypeError, ValueError, AttributeError) as e:
console.print(f"[yellow]! Route injection failed: {e}[/]")
[docs]
@contextmanager
def server_lifespan(self, app: "Litestar") -> "Iterator[None]":
"""Synchronous context manager for Vite server lifecycle.
Manages the Vite dev server process during the application lifespan.
Args:
app: The Litestar application instance.
Yields:
None
"""
import asyncio
# Ensure proxy target is set BEFORE environment variables (port selection)
if self._use_server_lifespan and self._config.is_dev_mode:
self._ensure_proxy_target()
if self._config.set_environment:
set_environment(config=self._config)
set_app_environment(app)
# Initialize SPA handler if enabled
if self._spa_handler is not None:
asyncio.get_event_loop().run_until_complete(self._spa_handler.initialize())
# Inject route metadata into SPA handler (if configured)
self._inject_routes_to_spa_handler(app)
# Export types on startup (when enabled)
self._export_types_sync(app)
if self._use_server_lifespan and self._config.is_dev_mode:
if not app.debug:
console.print("[yellow]WARNING: Vite dev mode is enabled in production![/]")
command_to_run = self._config.run_command if self._config.hot_reload else self._config.build_watch_command
if self._config.hot_reload:
console.rule("[yellow]Starting Vite process with HMR Enabled[/]", align="left")
else:
console.rule("[yellow]Starting Vite watch and build process[/]", align="left")
if self._proxy_target:
console.print(f"[dim]Vite proxy target: {self._proxy_target}[/]")
try:
self._vite_process.start(command_to_run, self._config.root_dir)
if self._config.health_check:
self._check_health()
yield
finally:
self._vite_process.stop()
console.print("[yellow]Vite process stopped.[/]")
# Shutdown SPA handler
if self._spa_handler is not None:
asyncio.get_event_loop().run_until_complete(self._spa_handler.shutdown())
else:
try:
yield
finally:
# Shutdown SPA handler
if self._spa_handler is not None:
asyncio.get_event_loop().run_until_complete(self._spa_handler.shutdown())
[docs]
@asynccontextmanager
async def async_server_lifespan(self, app: "Litestar") -> "AsyncIterator[None]":
"""Async context manager for Vite server lifecycle.
This is the preferred lifespan manager for async applications.
It initializes the asset loader asynchronously for non-blocking I/O.
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)
# Initialize asset loader asynchronously
if self._asset_loader is None:
self._asset_loader = ViteAssetLoader(config=self._config)
await self._asset_loader.initialize()
# Initialize SPA handler if enabled
if self._spa_handler is not None:
await self._spa_handler.initialize()
# Inject route metadata into SPA handler (if configured)
self._inject_routes_to_spa_handler(app)
# Export types on startup (when enabled)
self._export_types_sync(app)
if self._use_server_lifespan and self._config.is_dev_mode:
self._ensure_proxy_target()
if self._config.set_environment:
set_environment(config=self._config)
if not app.debug:
console.print("[yellow]WARNING: Vite dev mode is enabled in production![/]")
command_to_run = self._config.run_command if self._config.hot_reload else self._config.build_watch_command
if self._config.hot_reload:
console.rule("[yellow]Starting Vite process with HMR Enabled[/]", align="left")
else:
console.rule("[yellow]Starting Vite watch and build process[/]", align="left")
try:
self._vite_process.start(command_to_run, self._config.root_dir)
if self._config.health_check:
self._check_health()
yield
finally:
self._vite_process.stop()
console.print("[yellow]Vite process stopped.[/]")
# Shutdown SPA handler
if self._spa_handler is not None:
await self._spa_handler.shutdown()
else:
try:
yield
finally:
# Shutdown SPA handler
if self._spa_handler is not None:
await self._spa_handler.shutdown()
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, ...]:
def _normalize_prefix(prefix: str) -> str:
if not prefix.startswith("/"):
prefix = f"/{prefix}"
if not prefix.endswith("/"):
prefix = f"{prefix}/"
return prefix
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)
# Remove duplicates while preserving order
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)