Source code for litestar_vite.inertia.plugin

import functools
import inspect
from collections.abc import Mapping
from contextlib import asynccontextmanager
from dataclasses import fields, is_dataclass
from typing import TYPE_CHECKING, Any, cast

import msgspec
from litestar.handlers.http_handlers.base import HTTPRouteHandler
from litestar.plugins import InitPlugin
from litestar.response import Response

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator

    from litestar import Litestar, Request
    from litestar.config.app import AppConfig

    from litestar_vite.config import InertiaConfig
    from litestar_vite.ipc import BaseIPCTransport, SSRCircuitBreaker


[docs] class InertiaPlugin(InitPlugin): """Inertia plugin. This plugin configures Litestar for Inertia.js support, including: - Optional session-backed redirect state - Exception handler for Inertia responses - InertiaRequest and InertiaResponse as default classes - Type encoders for StaticProp and DeferredProp - Shared IPC transport and circuit breaker for SSR Async Prop Resolution: Async ``optional()``/``defer()``/``lazy()``/``once()`` callbacks are pre-resolved by ``InertiaResponse`` on the request event loop before the body is serialized. This guarantees they share the loop with request-scoped async resources (asyncpg/aiosqlite/sqlspec sessions), so callbacks can safely use those resources. Example:: from litestar_vite.inertia import InertiaPlugin, InertiaConfig app = Litestar( plugins=[InertiaPlugin(InertiaConfig())], ) """ __slots__ = ("_circuit_breaker", "_ipc_transport", "config")
[docs] def __init__(self, config: "InertiaConfig") -> None: """Initialize the plugin with Inertia configuration.""" self.config = config self._ipc_transport: "BaseIPCTransport | None" = None self._circuit_breaker: "SSRCircuitBreaker | None" = None
[docs] @asynccontextmanager async def lifespan(self, app: "Litestar") -> "AsyncGenerator[None, None]": """Lifespan to manage the shared SSR IPC transport and circuit breaker. Args: app: The :class:`Litestar <litestar.app.Litestar>` instance. Yields: An asynchronous context manager. """ owns_transport = False ssr_config = self.config.ssr_config if ssr_config is not None: if ssr_config.circuit_breaker_enabled: from litestar_vite.ipc import SSRCircuitBreaker self._circuit_breaker = SSRCircuitBreaker( failure_threshold=ssr_config.circuit_breaker_failure_threshold, reset_timeout=ssr_config.circuit_breaker_reset_timeout, ) from litestar_vite.plugin import VitePlugin try: vite_plugin: VitePlugin | None = app.plugins.get(VitePlugin) except KeyError: vite_plugin = None is_dev_mode = vite_plugin.config.is_dev_mode if vite_plugin is not None else False if not is_dev_mode: from litestar_vite.ipc import StdioIPCTransport if vite_plugin is not None: self._ipc_transport = vite_plugin.get_ipc_transport(ssr_config=ssr_config) else: from litestar_vite.config import PathConfig from litestar_vite.config._paths import resolve_ssr_bundle_path self._ipc_transport = StdioIPCTransport( command=ssr_config.command or ["node", str(resolve_ssr_bundle_path(PathConfig()))], cwd=ssr_config.cwd, ) owns_transport = True try: yield finally: if owns_transport and self._ipc_transport is not None: await self._ipc_transport.close() self._ipc_transport = None self._circuit_breaker = None
@property def circuit_breaker(self) -> "SSRCircuitBreaker | None": """Return the active SSRCircuitBreaker instance.""" return self._circuit_breaker @property def ipc_transport(self) -> "BaseIPCTransport | None": """Return the active BaseIPCTransport instance.""" return self._ipc_transport
[docs] def on_app_init(self, app_config: "AppConfig") -> "AppConfig": """Configure application for use with Vite. Args: app_config: The :class:`AppConfig <litestar.config.app.AppConfig>` instance. Returns: The :class:`AppConfig <litestar.config.app.AppConfig>` instance. """ from litestar.exceptions import HTTPException, ValidationException from litestar_vite.inertia.exception_handler import ( _register_exception_handlers, # pyright: ignore[reportPrivateUsage] exception_to_http_response, ) from litestar_vite.inertia.helpers import DeferredProp, StaticProp from litestar_vite.inertia.middleware import InertiaMiddleware from litestar_vite.inertia.precognition import create_precognition_exception_handler from litestar_vite.inertia.request import InertiaRequest from litestar_vite.inertia.response import InertiaBack, InertiaResponse if app_config.response_class is InertiaResponse: # pyright: ignore[reportUnknownMemberType] return app_config exception_handlers: "dict[type[Exception] | int, Any]" = { Exception: exception_to_http_response, HTTPException: exception_to_http_response, } if self.config.precognition: exception_handlers[ValidationException] = create_precognition_exception_handler( fallback_handler=exception_to_http_response ) _register_exception_handlers(exception_handlers=exception_handlers, default_handler=exception_to_http_response) app_config.exception_handlers.update(exception_handlers) # pyright: ignore[reportUnknownMemberType] app_config.request_class = InertiaRequest app_config.response_class = InertiaResponse app_config.middleware.append(InertiaMiddleware) app_config.signature_types.extend([InertiaRequest, InertiaResponse, InertiaBack, StaticProp, DeferredProp]) app_config.type_encoders = { StaticProp: lambda val: val.render(), DeferredProp: lambda val: val.render(), **(app_config.type_encoders or {}), } app_config.type_decoders = [ (lambda x: x is StaticProp, lambda t, v: t(v)), (lambda x: x is DeferredProp, lambda t, v: t(v)), *(app_config.type_decoders or []), ] app_config.lifespan.append(self.lifespan) # pyright: ignore[reportUnknownMemberType] app_config.on_startup.append( # pyright: ignore[reportUnknownMemberType] functools.partial(_wrap_app_handlers, component_opt_keys=self.config.component_opt_keys) ) return app_config
def _request_from_context(kwargs: "dict[str, Any]") -> "Request[Any, Any, Any] | None": from litestar_vite.inertia.middleware import get_current_inertia_request request = kwargs.get("request") if request is not None: return cast("Request[Any, Any, Any]", request) return get_current_inertia_request() def _as_inertia_prop_mapping(data: "Any") -> "Mapping[str, Any] | None": """Return a shallow field mapping for structured prop-bag return types. A ``msgspec.Struct``, dataclass, or pydantic model returned from an Inertia handler represents a bag of page props, mirroring a returned ``dict``. They are converted to a shallow mapping (field name -> live value) so each field becomes a top-level Inertia prop and special prop objects (``StaticProp`` / ``DeferredProp``) are preserved for the response prop pipeline. A deep conversion is deliberately avoided so it matches ``dict`` handling exactly. Args: data: The handler return value. Returns: A shallow ``{field: value}`` mapping, or ``None`` when ``data`` is not a recognized structured prop-bag type. """ obj: "Any" = data model_fields: "Any" = getattr(cast("Any", type(obj)), "model_fields", None) if model_fields is not None and hasattr(obj, "model_dump"): return {name: getattr(obj, name) for name in model_fields} if isinstance(obj, msgspec.Struct): return {field.encode_name: getattr(obj, field.name) for field in msgspec.structs.fields(obj)} if is_dataclass(obj) and not isinstance(obj, type): return {field.name: getattr(obj, field.name) for field in fields(obj)} return None async def _resolve_inertia_response_data(data: "Any", request: "Request[Any, Any, Any]") -> "Any": from litestar_vite.inertia.helpers import is_pagination_container from litestar_vite.inertia.response import InertiaResponse if isinstance(data, InertiaResponse): await data.resolve_async_props(request) return cast("InertiaResponse[Any]", data) if isinstance(data, Response): return cast("Response[Any]", data) content: "Any" if data is None or isinstance(data, Mapping) or is_pagination_container(data): content = cast("Any", data) else: content = _as_inertia_prop_mapping(data) if content is None: return data response: InertiaResponse[Any] = InertiaResponse(content=content) response._defer_status_to_handler = True # pyright: ignore[reportPrivateUsage] await response.resolve_async_props(request) return response def _wrap_handler_fn(handler: "HTTPRouteHandler") -> None: """Wrap ``handler.fn`` so async Inertia prop callbacks resolve inside the DI ``AsyncExitStack`` frame. Litestar resolves layered ``after_request`` hooks by keeping only ``after_request_handlers[-1]`` (see ``litestar/handlers/http_handlers/base.py``), so a plugin-registered hook can be silently dropped by any user-defined ``after_request`` at router/controller/handler level. Wrapping ``fn`` directly sidesteps that — the wrapper IS the handler, and runs inside the ``async with stack:`` block in ``_call_handler_function`` where yield-based dependencies are still alive. """ if getattr(handler.fn, "_inertia_wrapped", False): return original = handler.fn @functools.wraps(original) # pyright: ignore[reportUnknownArgumentType] async def wrapped(**kwargs: "Any") -> "Any": result = original(**kwargs) if inspect.isawaitable(result): result = await result request = _request_from_context(kwargs) if request is None: return result return await _resolve_inertia_response_data(result, request) wrapped._inertia_wrapped = True # type: ignore[attr-defined] # pyright: ignore[reportFunctionMemberAccess] handler._fn = wrapped # pyright: ignore[reportPrivateUsage] handler.has_sync_callable = False def _handler_supports_inertia(handler: "HTTPRouteHandler", *, component_opt_keys: "tuple[str, ...]") -> bool: """Return whether a route handler declares an Inertia page target.""" opt: dict[str, Any] = handler.opt or {} return any(bool(opt.get(key)) for key in component_opt_keys) def _wrap_app_handlers(app: "Litestar", *, component_opt_keys: "tuple[str, ...]" = ("component", "page")) -> None: """Idempotently wrap Inertia-capable HTTP route handlers on the live app. Run after Litestar's full route registration so dynamically-attached handlers (controllers instantiated late, plugins, etc.) are also wrapped. """ for route in app.routes: for handler in getattr(route, "route_handlers", ()): if isinstance(handler, HTTPRouteHandler) and _handler_supports_inertia( handler, component_opt_keys=component_opt_keys ): _wrap_handler_fn(handler)