Source code for litestar_vite.inertia.plugin

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

import httpx
from litestar.handlers.http_handlers.base import HTTPRouteHandler
from litestar.plugins import InitPluginProtocol
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


[docs] class InertiaPlugin(InitPluginProtocol): """Inertia plugin. This plugin configures Litestar for Inertia.js support, including: - Session middleware requirement validation - Exception handler for Inertia responses - InertiaRequest and InertiaResponse as default classes - Type encoders for StaticProp and DeferredProp 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. SSR Client Pooling: When SSR is enabled, the plugin maintains a shared ``httpx.AsyncClient`` for all SSR requests. This provides significant performance benefits: - Connection pooling with keep-alive - TLS session reuse - HTTP/2 multiplexing (when available) The client is initialized during app lifespan and properly closed on shutdown. Access via ``inertia_plugin.ssr_client`` if needed. Example:: from litestar_vite.inertia import InertiaPlugin, InertiaConfig app = Litestar( plugins=[InertiaPlugin(InertiaConfig())], middleware=[ServerSideSessionConfig().middleware], ) """ __slots__ = ("_ssr_client", "config")
[docs] def __init__(self, config: "InertiaConfig") -> "None": """Initialize the plugin with Inertia configuration.""" self.config = config self._ssr_client: "httpx.AsyncClient | None" = None
[docs] @asynccontextmanager async def lifespan(self, app: "Litestar") -> "AsyncGenerator[None, None]": """Lifespan to manage the shared SSR HTTP client. Args: app: The :class:`Litestar <litestar.app.Litestar>` instance. Yields: An asynchronous context manager. """ # Initialize shared SSR client with connection pooling # These limits are tuned for typical SSR workloads: # - max_keepalive_connections: 10 per-host keep-alive connections # - max_connections: 20 total concurrent connections # - keepalive_expiry: 30s idle timeout before closing limits = httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=30.0) self._ssr_client = httpx.AsyncClient( limits=limits, timeout=httpx.Timeout(10.0), # Default timeout, can be overridden per-request ) try: yield finally: await self._ssr_client.aclose() self._ssr_client = None # Reset to signal client is closed
@property def ssr_client(self) -> "httpx.AsyncClient | None": """Return the shared httpx.AsyncClient for SSR requests. The client is initialized during app lifespan and provides connection pooling, TLS session reuse, and HTTP/2 multiplexing benefits. Returns: The shared AsyncClient instance, or None if not initialized. """ return self._ssr_client
[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. Raises: ImproperlyConfiguredException: If the Inertia plugin is not properly configured. Returns: The :class:`AppConfig <litestar.config.app.AppConfig>` instance. """ from litestar.exceptions import HTTPException, ImproperlyConfiguredException, ValidationException from litestar.middleware import DefineMiddleware from litestar.middleware.session import SessionMiddleware from litestar.security.session_auth.middleware import MiddlewareWrapper from litestar.utils.predicates import is_class_and_subclass from litestar_vite.inertia.exception_handler import exception_to_http_response from litestar_vite.inertia.helpers import DeferredProp, StaticProp from litestar_vite.inertia.middleware import InertiaMiddleware 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 for mw in app_config.middleware: if isinstance(mw, DefineMiddleware) and is_class_and_subclass( mw.middleware, (MiddlewareWrapper, SessionMiddleware) ): break else: msg = "The Inertia plugin require a session middleware." raise ImproperlyConfiguredException(msg) # Register exception handlers exception_handlers: "dict[type[Exception] | int, Any]" = { Exception: exception_to_http_response, HTTPException: exception_to_http_response, } # Add Precognition exception handler when enabled # Note: The exception handler formats validation errors in Laravel's format. # For successful validation to return 204 (without executing the handler), # use the @precognition decorator on your route handlers. if self.config.precognition: from litestar_vite.inertia.precognition import create_precognition_exception_handler exception_handlers[ValidationException] = create_precognition_exception_handler( fallback_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]) # Type encoders for prop resolution. Async DeferredProp callbacks are # pre-resolved on the request loop by InertiaResponse before the encoder # ever runs, so render() short-circuits at the cached _result. 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] # Wrap every HTTP route handler at app startup so async Inertia prop # callbacks resolve inside Litestar's _call_handler_function # AsyncExitStack frame (where DI-scoped resources are still alive). # Runs at startup (NOT on_app_init) because the layered handler # objects are only fully resolved with runtime attributes # (has_sync_callable, signature_model, etc.) after route registration # completes. app_config.on_startup.append(_wrap_app_handlers) # pyright: ignore[reportUnknownMemberType] return app_config
def _request_from_context(kwargs: "dict[str, Any]") -> "Request[Any, Any, Any] | None": request = kwargs.get("request") if request is not None: return cast("Request[Any, Any, Any]", request) from litestar_vite.inertia.middleware import get_current_inertia_request return get_current_inertia_request() async def _resolve_inertia_response_data(data: "Any", request: "Request[Any, Any, Any]") -> "Any": from litestar_vite.inertia.response import InertiaResponse if isinstance(data, InertiaResponse): await data.resolve_async_props(request) return cast("Any", data) if isinstance(data, Response): return cast("Any", data) if isinstance(data, Mapping) or data is None: response: InertiaResponse[Any] = InertiaResponse(content=cast("Any", data)) await response.resolve_async_props(request) return cast("Any", response) return data 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): # idempotent guard 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`` is a property; the backing attribute is ``_fn``. handler._fn = wrapped # pyright: ignore[reportPrivateUsage] handler.has_sync_callable = False def _wrap_app_handlers(app: "Litestar") -> None: """Idempotently wrap every HTTP route handler 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): _wrap_handler_fn(handler)