litestar-vite 0.1.1__py3-none-any.whl → 0.15.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litestar_vite/__init__.py +54 -4
- litestar_vite/__metadata__.py +12 -7
- litestar_vite/cli.py +1048 -10
- litestar_vite/codegen/__init__.py +48 -0
- litestar_vite/codegen/_export.py +229 -0
- litestar_vite/codegen/_inertia.py +619 -0
- litestar_vite/codegen/_openapi.py +280 -0
- litestar_vite/codegen/_routes.py +720 -0
- litestar_vite/codegen/_ts.py +235 -0
- litestar_vite/codegen/_utils.py +141 -0
- litestar_vite/commands.py +73 -0
- litestar_vite/config/__init__.py +997 -0
- litestar_vite/config/_constants.py +97 -0
- litestar_vite/config/_deploy.py +70 -0
- litestar_vite/config/_inertia.py +241 -0
- litestar_vite/config/_paths.py +63 -0
- litestar_vite/config/_runtime.py +235 -0
- litestar_vite/config/_spa.py +93 -0
- litestar_vite/config/_types.py +94 -0
- litestar_vite/deploy.py +366 -0
- litestar_vite/doctor.py +1181 -0
- litestar_vite/exceptions.py +78 -0
- litestar_vite/executor.py +360 -0
- litestar_vite/handler/__init__.py +9 -0
- litestar_vite/handler/_app.py +612 -0
- litestar_vite/handler/_routing.py +130 -0
- litestar_vite/html_transform.py +569 -0
- litestar_vite/inertia/__init__.py +77 -0
- litestar_vite/inertia/_utils.py +119 -0
- litestar_vite/inertia/exception_handler.py +178 -0
- litestar_vite/inertia/helpers.py +1571 -0
- litestar_vite/inertia/middleware.py +54 -0
- litestar_vite/inertia/plugin.py +199 -0
- litestar_vite/inertia/precognition.py +274 -0
- litestar_vite/inertia/request.py +334 -0
- litestar_vite/inertia/response.py +802 -0
- litestar_vite/inertia/types.py +335 -0
- litestar_vite/loader.py +464 -123
- litestar_vite/plugin/__init__.py +687 -0
- litestar_vite/plugin/_process.py +185 -0
- litestar_vite/plugin/_proxy.py +689 -0
- litestar_vite/plugin/_proxy_headers.py +244 -0
- litestar_vite/plugin/_static.py +37 -0
- litestar_vite/plugin/_utils.py +489 -0
- litestar_vite/py.typed +0 -0
- litestar_vite/scaffolding/__init__.py +20 -0
- litestar_vite/scaffolding/generator.py +270 -0
- litestar_vite/scaffolding/templates.py +437 -0
- litestar_vite/templates/__init__.py +0 -0
- litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
- litestar_vite/templates/angular/index.html.j2 +12 -0
- litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/angular/package.json.j2 +36 -0
- litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
- litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
- litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
- litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
- litestar_vite/templates/angular/src/main.ts.j2 +9 -0
- litestar_vite/templates/angular/src/styles.css.j2 +9 -0
- litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
- litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
- litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
- litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
- litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
- litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/angular-cli/package.json.j2 +28 -0
- litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
- litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
- litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
- litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
- litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
- litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
- litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
- litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
- litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
- litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
- litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
- litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
- litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
- litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
- litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
- litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
- litestar_vite/templates/base/.gitignore.j2 +42 -0
- litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/base/package.json.j2 +39 -0
- litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
- litestar_vite/templates/base/tsconfig.json.j2 +37 -0
- litestar_vite/templates/htmx/src/main.js.j2 +8 -0
- litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
- litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
- litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
- litestar_vite/templates/nuxt/app.vue.j2 +29 -0
- litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
- litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
- litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
- litestar_vite/templates/react/index.html.j2 +13 -0
- litestar_vite/templates/react/src/App.css.j2 +56 -0
- litestar_vite/templates/react/src/App.tsx.j2 +19 -0
- litestar_vite/templates/react/src/main.tsx.j2 +10 -0
- litestar_vite/templates/react/vite.config.ts.j2 +39 -0
- litestar_vite/templates/react-inertia/index.html.j2 +14 -0
- litestar_vite/templates/react-inertia/package.json.j2 +47 -0
- litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
- litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
- litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
- litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
- litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
- litestar_vite/templates/react-router/index.html.j2 +12 -0
- litestar_vite/templates/react-router/src/App.css.j2 +17 -0
- litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
- litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
- litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
- litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
- litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
- litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
- litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
- litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
- litestar_vite/templates/svelte/index.html.j2 +13 -0
- litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
- litestar_vite/templates/svelte/src/app.css.j2 +45 -0
- litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
- litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
- litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
- litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
- litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
- litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
- litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
- litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
- litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
- litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
- litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
- litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
- litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
- litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
- litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
- litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
- litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
- litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
- litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
- litestar_vite/templates/vue/env.d.ts.j2 +7 -0
- litestar_vite/templates/vue/index.html.j2 +13 -0
- litestar_vite/templates/vue/src/App.vue.j2 +28 -0
- litestar_vite/templates/vue/src/main.ts.j2 +5 -0
- litestar_vite/templates/vue/src/style.css.j2 +45 -0
- litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
- litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
- litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
- litestar_vite/templates/vue-inertia/package.json.j2 +50 -0
- litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
- litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
- litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
- litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
- litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
- litestar_vite-0.15.0.dist-info/METADATA +230 -0
- litestar_vite-0.15.0.dist-info/RECORD +164 -0
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
- litestar_vite/config.py +0 -100
- litestar_vite/plugin.py +0 -45
- litestar_vite/template_engine.py +0 -103
- litestar_vite-0.1.1.dist-info/METADATA +0 -68
- litestar_vite-0.1.1.dist-info/RECORD +0 -11
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from litestar.middleware import AbstractMiddleware
|
|
4
|
+
from litestar.types import Receive, Scope, Send
|
|
5
|
+
|
|
6
|
+
from litestar_vite.inertia.request import InertiaRequest
|
|
7
|
+
from litestar_vite.inertia.response import InertiaExternalRedirect
|
|
8
|
+
from litestar_vite.plugin import VitePlugin
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from litestar.types import ASGIApp, Receive, Scope, Send
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def redirect_on_asset_version_mismatch(request: "InertiaRequest[Any, Any, Any]") -> "InertiaExternalRedirect | None":
|
|
15
|
+
"""Return redirect response when client and server asset versions differ.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
An InertiaExternalRedirect when versions differ, otherwise None.
|
|
19
|
+
"""
|
|
20
|
+
if not request.is_inertia:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
inertia_version = request.inertia_version
|
|
24
|
+
if inertia_version is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
vite_plugin = request.app.plugins.get(VitePlugin)
|
|
28
|
+
if inertia_version == vite_plugin.asset_loader.version_id:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
return InertiaExternalRedirect(request, redirect_to=str(request.url))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InertiaMiddleware(AbstractMiddleware):
|
|
35
|
+
"""Middleware for handling Inertia.js protocol requirements.
|
|
36
|
+
|
|
37
|
+
This middleware:
|
|
38
|
+
1. Detects version mismatches between client and server assets
|
|
39
|
+
2. Returns 409 Conflict with X-Inertia-Location header when versions differ
|
|
40
|
+
3. Triggers client-side hard refresh to reload the updated assets
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, app: "ASGIApp") -> None:
|
|
44
|
+
super().__init__(app)
|
|
45
|
+
self.app = app
|
|
46
|
+
|
|
47
|
+
async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
|
|
48
|
+
request: InertiaRequest[Any, Any, Any] = InertiaRequest(scope=scope)
|
|
49
|
+
redirect = redirect_on_asset_version_mismatch(request)
|
|
50
|
+
if redirect is not None:
|
|
51
|
+
response = redirect.to_asgi_response(app=None, request=request) # pyright: ignore[reportUnknownMemberType]
|
|
52
|
+
await response(scope, receive, send)
|
|
53
|
+
else:
|
|
54
|
+
await self.app(scope, receive, send)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from anyio.from_thread import start_blocking_portal
|
|
6
|
+
from litestar.plugins import InitPluginProtocol
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import AsyncGenerator
|
|
10
|
+
|
|
11
|
+
from anyio.from_thread import BlockingPortal
|
|
12
|
+
from litestar import Litestar
|
|
13
|
+
from litestar.config.app import AppConfig
|
|
14
|
+
|
|
15
|
+
from litestar_vite.config import InertiaConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InertiaPlugin(InitPluginProtocol):
|
|
19
|
+
"""Inertia plugin.
|
|
20
|
+
|
|
21
|
+
This plugin configures Litestar for Inertia.js support, including:
|
|
22
|
+
- Session middleware requirement validation
|
|
23
|
+
- Exception handler for Inertia responses
|
|
24
|
+
- InertiaRequest and InertiaResponse as default classes
|
|
25
|
+
- Type encoders for StaticProp and DeferredProp
|
|
26
|
+
|
|
27
|
+
BlockingPortal Behavior:
|
|
28
|
+
The plugin creates a BlockingPortal during its lifespan for executing
|
|
29
|
+
async DeferredProp callbacks from synchronous type encoders. This is
|
|
30
|
+
necessary because Litestar's JSON serialization happens synchronously,
|
|
31
|
+
but DeferredProp may contain async callables.
|
|
32
|
+
|
|
33
|
+
The portal is shared across all requests during the app's lifetime.
|
|
34
|
+
Type encoders for StaticProp and DeferredProp use ``val.render()``
|
|
35
|
+
which may access this portal for async resolution.
|
|
36
|
+
|
|
37
|
+
If you're using DeferredProp outside of InertiaResponse (e.g., in
|
|
38
|
+
custom serialization), ensure the app lifespan is active and the
|
|
39
|
+
portal is available via ``inertia_plugin.portal``.
|
|
40
|
+
|
|
41
|
+
SSR Client Pooling:
|
|
42
|
+
When SSR is enabled, the plugin maintains a shared ``httpx.AsyncClient``
|
|
43
|
+
for all SSR requests. This provides significant performance benefits:
|
|
44
|
+
- Connection pooling with keep-alive
|
|
45
|
+
- TLS session reuse
|
|
46
|
+
- HTTP/2 multiplexing (when available)
|
|
47
|
+
|
|
48
|
+
The client is initialized during app lifespan and properly closed on shutdown.
|
|
49
|
+
Access via ``inertia_plugin.ssr_client`` if needed.
|
|
50
|
+
|
|
51
|
+
Example::
|
|
52
|
+
|
|
53
|
+
from litestar_vite.inertia import InertiaPlugin, InertiaConfig
|
|
54
|
+
|
|
55
|
+
app = Litestar(
|
|
56
|
+
plugins=[InertiaPlugin(InertiaConfig())],
|
|
57
|
+
middleware=[ServerSideSessionConfig().middleware],
|
|
58
|
+
)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("_portal", "_ssr_client", "config")
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: "InertiaConfig") -> "None":
|
|
64
|
+
"""Initialize the plugin with Inertia configuration."""
|
|
65
|
+
self.config = config
|
|
66
|
+
self._ssr_client: "httpx.AsyncClient | None" = None
|
|
67
|
+
self._portal: "BlockingPortal | None" = None # pyright: ignore[reportInvalidTypeForm]
|
|
68
|
+
|
|
69
|
+
@asynccontextmanager
|
|
70
|
+
async def lifespan(self, app: "Litestar") -> "AsyncGenerator[None, None]":
|
|
71
|
+
"""Lifespan to ensure the event loop is available.
|
|
72
|
+
|
|
73
|
+
Initializes:
|
|
74
|
+
- BlockingPortal for sync-to-async DeferredProp resolution
|
|
75
|
+
- Shared httpx.AsyncClient for SSR requests (connection pooling)
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
app: The :class:`Litestar <litestar.app.Litestar>` instance.
|
|
79
|
+
|
|
80
|
+
Yields:
|
|
81
|
+
An asynchronous context manager.
|
|
82
|
+
"""
|
|
83
|
+
# Initialize shared SSR client with connection pooling
|
|
84
|
+
# These limits are tuned for typical SSR workloads:
|
|
85
|
+
# - max_keepalive_connections: 10 per-host keep-alive connections
|
|
86
|
+
# - max_connections: 20 total concurrent connections
|
|
87
|
+
# - keepalive_expiry: 30s idle timeout before closing
|
|
88
|
+
limits = httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=30.0)
|
|
89
|
+
self._ssr_client = httpx.AsyncClient(
|
|
90
|
+
limits=limits,
|
|
91
|
+
timeout=httpx.Timeout(10.0), # Default timeout, can be overridden per-request
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
with start_blocking_portal() as portal:
|
|
96
|
+
self._portal = portal
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
await self._ssr_client.aclose()
|
|
100
|
+
self._ssr_client = None # Reset to signal client is closed
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def portal(self) -> "BlockingPortal":
|
|
104
|
+
"""Return the blocking portal used for deferred prop resolution.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The BlockingPortal instance.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
RuntimeError: If accessed before app lifespan is active.
|
|
111
|
+
"""
|
|
112
|
+
if self._portal is None:
|
|
113
|
+
msg = "BlockingPortal not available. Ensure app lifespan is active."
|
|
114
|
+
raise RuntimeError(msg)
|
|
115
|
+
return self._portal
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def ssr_client(self) -> "httpx.AsyncClient | None":
|
|
119
|
+
"""Return the shared httpx.AsyncClient for SSR requests.
|
|
120
|
+
|
|
121
|
+
The client is initialized during app lifespan and provides connection
|
|
122
|
+
pooling, TLS session reuse, and HTTP/2 multiplexing benefits.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The shared AsyncClient instance, or None if not initialized.
|
|
126
|
+
"""
|
|
127
|
+
return self._ssr_client
|
|
128
|
+
|
|
129
|
+
def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
|
|
130
|
+
"""Configure application for use with Vite.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
app_config: The :class:`AppConfig <litestar.config.app.AppConfig>` instance.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ImproperlyConfiguredException: If the Inertia plugin is not properly configured.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The :class:`AppConfig <litestar.config.app.AppConfig>` instance.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
from litestar.exceptions import HTTPException, ImproperlyConfiguredException, ValidationException
|
|
143
|
+
from litestar.middleware import DefineMiddleware
|
|
144
|
+
from litestar.middleware.session import SessionMiddleware
|
|
145
|
+
from litestar.security.session_auth.middleware import MiddlewareWrapper
|
|
146
|
+
from litestar.utils.predicates import is_class_and_subclass
|
|
147
|
+
|
|
148
|
+
from litestar_vite.inertia.exception_handler import exception_to_http_response
|
|
149
|
+
from litestar_vite.inertia.helpers import DeferredProp, StaticProp
|
|
150
|
+
from litestar_vite.inertia.middleware import InertiaMiddleware
|
|
151
|
+
from litestar_vite.inertia.request import InertiaRequest
|
|
152
|
+
from litestar_vite.inertia.response import InertiaBack, InertiaResponse
|
|
153
|
+
|
|
154
|
+
for mw in app_config.middleware:
|
|
155
|
+
if isinstance(mw, DefineMiddleware) and is_class_and_subclass(
|
|
156
|
+
mw.middleware, (MiddlewareWrapper, SessionMiddleware)
|
|
157
|
+
):
|
|
158
|
+
break
|
|
159
|
+
else:
|
|
160
|
+
msg = "The Inertia plugin require a session middleware."
|
|
161
|
+
raise ImproperlyConfiguredException(msg)
|
|
162
|
+
|
|
163
|
+
# Register exception handlers
|
|
164
|
+
exception_handlers: "dict[type[Exception] | int, Any]" = {
|
|
165
|
+
Exception: exception_to_http_response,
|
|
166
|
+
HTTPException: exception_to_http_response,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Add Precognition exception handler when enabled
|
|
170
|
+
# Note: The exception handler formats validation errors in Laravel's format.
|
|
171
|
+
# For successful validation to return 204 (without executing the handler),
|
|
172
|
+
# use the @precognition decorator on your route handlers.
|
|
173
|
+
if self.config.precognition:
|
|
174
|
+
from litestar_vite.inertia.precognition import create_precognition_exception_handler
|
|
175
|
+
|
|
176
|
+
exception_handlers[ValidationException] = create_precognition_exception_handler(
|
|
177
|
+
fallback_handler=exception_to_http_response
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
app_config.exception_handlers.update(exception_handlers) # pyright: ignore[reportUnknownMemberType]
|
|
181
|
+
app_config.request_class = InertiaRequest
|
|
182
|
+
app_config.response_class = InertiaResponse
|
|
183
|
+
app_config.middleware.append(InertiaMiddleware)
|
|
184
|
+
app_config.signature_types.extend([InertiaRequest, InertiaResponse, InertiaBack, StaticProp, DeferredProp])
|
|
185
|
+
# Type encoders for prop resolution
|
|
186
|
+
# DeferredProp encoder passes the plugin's portal for efficient async resolution
|
|
187
|
+
# This avoids creating a new BlockingPortal per DeferredProp (~5-10ms savings)
|
|
188
|
+
app_config.type_encoders = {
|
|
189
|
+
StaticProp: lambda val: val.render(),
|
|
190
|
+
DeferredProp: lambda val: val.render(portal=getattr(self, "_portal", None)),
|
|
191
|
+
**(app_config.type_encoders or {}),
|
|
192
|
+
}
|
|
193
|
+
app_config.type_decoders = [
|
|
194
|
+
(lambda x: x is StaticProp, lambda t, v: t(v)),
|
|
195
|
+
(lambda x: x is DeferredProp, lambda t, v: t(v)),
|
|
196
|
+
*(app_config.type_decoders or []),
|
|
197
|
+
]
|
|
198
|
+
app_config.lifespan.append(self.lifespan) # pyright: ignore[reportUnknownMemberType]
|
|
199
|
+
return app_config
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Precognition support for real-time form validation.
|
|
2
|
+
|
|
3
|
+
Precognition is a Laravel protocol for running server-side validation without
|
|
4
|
+
executing handler side effects. This module provides Litestar integration.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
1. Enable in config: ``InertiaConfig(precognition=True)``
|
|
8
|
+
2. Add ``@precognition`` decorator to route handlers
|
|
9
|
+
|
|
10
|
+
The plugin automatically handles validation failures (422 responses).
|
|
11
|
+
The decorator prevents handler execution on successful validation (204 responses).
|
|
12
|
+
|
|
13
|
+
See: https://laravel.com/docs/precognition
|
|
14
|
+
|
|
15
|
+
Note on Rate Limiting:
|
|
16
|
+
Real-time validation can result in many requests. Laravel has no official
|
|
17
|
+
rate limiting solution for Precognition. Consider:
|
|
18
|
+
- Throttling Precognition requests separately from normal requests
|
|
19
|
+
- Using debounce on the frontend (laravel-precognition libraries do this)
|
|
20
|
+
- Implementing custom rate limiting that checks for Precognition header
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from functools import wraps
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from litestar import MediaType, Response
|
|
27
|
+
from litestar.exceptions import ValidationException
|
|
28
|
+
from litestar.status_codes import HTTP_204_NO_CONTENT, HTTP_422_UNPROCESSABLE_ENTITY
|
|
29
|
+
|
|
30
|
+
from litestar_vite.inertia._utils import InertiaHeaders
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
|
|
35
|
+
from litestar import Request
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
"PrecognitionResponse",
|
|
39
|
+
"create_precognition_exception_handler",
|
|
40
|
+
"normalize_validation_errors",
|
|
41
|
+
"precognition",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_validation_errors(exc: ValidationException, validate_only: "set[str] | None" = None) -> "dict[str, Any]":
|
|
46
|
+
"""Normalize Litestar validation errors to Laravel format.
|
|
47
|
+
|
|
48
|
+
Laravel's Precognition protocol expects errors in this format:
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"message": "The given data was invalid.",
|
|
52
|
+
"errors": {
|
|
53
|
+
"email": ["The email field is required."],
|
|
54
|
+
"password": ["The password must be at least 8 characters."]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
exc: The ValidationException from Litestar.
|
|
61
|
+
validate_only: If provided, only include errors for these fields.
|
|
62
|
+
Used for partial field validation as the user types.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A dict in Laravel's validation error format.
|
|
66
|
+
"""
|
|
67
|
+
errors: "dict[str, list[str]]" = {}
|
|
68
|
+
|
|
69
|
+
# Litestar's ValidationException.detail can be:
|
|
70
|
+
# - A string message
|
|
71
|
+
# - A list of error dicts with 'key', 'message', and 'source'
|
|
72
|
+
# - Other structured data
|
|
73
|
+
detail = exc.detail
|
|
74
|
+
|
|
75
|
+
if isinstance(detail, list):
|
|
76
|
+
for error in detail: # pyright: ignore[reportUnknownVariableType]
|
|
77
|
+
if isinstance(error, dict):
|
|
78
|
+
key: str = str(error.get("key", "")) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
|
79
|
+
message: str = str(error.get("message", str(error))) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
|
80
|
+
source: str = str(error.get("source", "")) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
|
81
|
+
|
|
82
|
+
# Build field name from source and key
|
|
83
|
+
if source and key:
|
|
84
|
+
field_name = f"{source}.{key}" if source != "body" else key
|
|
85
|
+
elif key:
|
|
86
|
+
field_name = key
|
|
87
|
+
else:
|
|
88
|
+
field_name = "_root"
|
|
89
|
+
|
|
90
|
+
# Filter by validate_only if specified
|
|
91
|
+
if validate_only and field_name not in validate_only:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if field_name not in errors:
|
|
95
|
+
errors[field_name] = []
|
|
96
|
+
errors[field_name].append(message)
|
|
97
|
+
elif isinstance(detail, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
98
|
+
errors["_root"] = [detail]
|
|
99
|
+
|
|
100
|
+
return {"message": "The given data was invalid.", "errors": errors}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PrecognitionResponse(Response[Any]):
|
|
104
|
+
"""Response for successful Precognition validation.
|
|
105
|
+
|
|
106
|
+
Returns 204 No Content with Precognition-Success header.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
super().__init__(
|
|
111
|
+
content=None, status_code=HTTP_204_NO_CONTENT, headers={InertiaHeaders.PRECOGNITION_SUCCESS.value: "true"}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_precognition_exception_handler(
|
|
116
|
+
*, fallback_handler: "Callable[[Request[Any, Any, Any], ValidationException], Response[Any]] | None" = None
|
|
117
|
+
) -> "Callable[[Request[Any, Any, Any], ValidationException], Response[Any]]":
|
|
118
|
+
"""Create an exception handler for ValidationException that supports Precognition.
|
|
119
|
+
|
|
120
|
+
This handler checks if the request is a Precognition request and returns
|
|
121
|
+
errors in Laravel's format. For non-Precognition requests, it either uses
|
|
122
|
+
the fallback handler or returns Litestar's default format.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
fallback_handler: Optional handler for non-Precognition requests.
|
|
126
|
+
If not provided, returns a standard JSON error response.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
An exception handler function suitable for Litestar's exception_handlers.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def handler(request: "Request[Any, Any, Any]", exc: ValidationException) -> "Response[Any]":
|
|
133
|
+
# Check if this is a Precognition request
|
|
134
|
+
precognition_header = request.headers.get(InertiaHeaders.PRECOGNITION.value.lower())
|
|
135
|
+
is_precognition = precognition_header == "true"
|
|
136
|
+
|
|
137
|
+
if is_precognition:
|
|
138
|
+
# Get validate_only fields for partial validation
|
|
139
|
+
validate_only_header = request.headers.get(InertiaHeaders.PRECOGNITION_VALIDATE_ONLY.value.lower())
|
|
140
|
+
validate_only = (
|
|
141
|
+
{field.strip() for field in validate_only_header.split(",") if field.strip()}
|
|
142
|
+
if validate_only_header
|
|
143
|
+
else None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Normalize errors to Laravel format
|
|
147
|
+
error_data = normalize_validation_errors(exc, validate_only)
|
|
148
|
+
|
|
149
|
+
# If filtering removed all errors, return success (204)
|
|
150
|
+
if validate_only and not error_data["errors"]:
|
|
151
|
+
return PrecognitionResponse()
|
|
152
|
+
|
|
153
|
+
return Response(
|
|
154
|
+
content=error_data,
|
|
155
|
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
|
156
|
+
media_type=MediaType.JSON,
|
|
157
|
+
headers={InertiaHeaders.PRECOGNITION.value: "true"},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Non-Precognition request - use fallback or default
|
|
161
|
+
if fallback_handler is not None:
|
|
162
|
+
return fallback_handler(request, exc)
|
|
163
|
+
|
|
164
|
+
# Default Litestar-style error response
|
|
165
|
+
return Response(
|
|
166
|
+
content={
|
|
167
|
+
"status_code": HTTP_422_UNPROCESSABLE_ENTITY,
|
|
168
|
+
"detail": "Validation failed",
|
|
169
|
+
"extra": exc.detail or [],
|
|
170
|
+
},
|
|
171
|
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
|
172
|
+
media_type=MediaType.JSON,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return handler
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def precognition(fn: "Callable[..., Any]") -> "Callable[..., Any]":
|
|
179
|
+
"""Decorator to enable Precognition on a route handler.
|
|
180
|
+
|
|
181
|
+
When a Precognition request passes DTO validation, this decorator
|
|
182
|
+
returns a 204 No Content response instead of executing the handler body.
|
|
183
|
+
This prevents side effects (database writes, emails, etc.) during validation.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
fn: The route handler function to wrap.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A wrapped handler that short-circuits on valid Precognition requests.
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
```python
|
|
193
|
+
from litestar import post
|
|
194
|
+
from litestar_vite.inertia import precognition, InertiaRedirect
|
|
195
|
+
|
|
196
|
+
@post("/users")
|
|
197
|
+
@precognition
|
|
198
|
+
async def create_user(data: UserDTO) -> InertiaRedirect:
|
|
199
|
+
# This only runs for actual form submissions
|
|
200
|
+
# Precognition validation requests return 204 automatically
|
|
201
|
+
user = await User.create(**data.dict())
|
|
202
|
+
return InertiaRedirect(request, f"/users/{user.id}")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Note:
|
|
206
|
+
- Validation errors are handled by the exception handler (automatic)
|
|
207
|
+
- This decorator handles the success case (prevents handler execution)
|
|
208
|
+
- The decorator checks for Precognition header AFTER DTO validation
|
|
209
|
+
"""
|
|
210
|
+
import asyncio
|
|
211
|
+
|
|
212
|
+
@wraps(fn)
|
|
213
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
214
|
+
# Find the request object in args or kwargs
|
|
215
|
+
request = _find_request(args, kwargs) # pyright: ignore[reportUnknownVariableType]
|
|
216
|
+
|
|
217
|
+
if request is not None:
|
|
218
|
+
# Check for Precognition header
|
|
219
|
+
precognition_header = request.headers.get(InertiaHeaders.PRECOGNITION.value.lower())
|
|
220
|
+
if precognition_header == "true":
|
|
221
|
+
# Validation passed (we got here), return success
|
|
222
|
+
return PrecognitionResponse()
|
|
223
|
+
|
|
224
|
+
# Not a Precognition request, run handler normally
|
|
225
|
+
return fn(*args, **kwargs)
|
|
226
|
+
|
|
227
|
+
@wraps(fn)
|
|
228
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
229
|
+
# Find the request object in args or kwargs
|
|
230
|
+
request = _find_request(args, kwargs) # pyright: ignore[reportUnknownVariableType]
|
|
231
|
+
|
|
232
|
+
if request is not None:
|
|
233
|
+
# Check for Precognition header
|
|
234
|
+
precognition_header = request.headers.get(InertiaHeaders.PRECOGNITION.value.lower())
|
|
235
|
+
if precognition_header == "true":
|
|
236
|
+
# Validation passed (we got here), return success
|
|
237
|
+
return PrecognitionResponse()
|
|
238
|
+
|
|
239
|
+
# Not a Precognition request, run handler normally
|
|
240
|
+
result = fn(*args, **kwargs)
|
|
241
|
+
if asyncio.iscoroutine(result):
|
|
242
|
+
return await result
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
# Return appropriate wrapper based on function type
|
|
246
|
+
if asyncio.iscoroutinefunction(fn):
|
|
247
|
+
return async_wrapper
|
|
248
|
+
return sync_wrapper
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _find_request(args: tuple[Any, ...], kwargs: "dict[str, Any]") -> "Request[Any, Any, Any] | None": # pyright: ignore[reportUnknownParameterType]
|
|
252
|
+
"""Find Request object in function arguments.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
args: Positional arguments to the handler.
|
|
256
|
+
kwargs: Keyword arguments to the handler.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The Request object if found, otherwise None.
|
|
260
|
+
"""
|
|
261
|
+
from litestar import Request
|
|
262
|
+
|
|
263
|
+
# Check kwargs first (named 'request' parameter)
|
|
264
|
+
if "request" in kwargs:
|
|
265
|
+
req = kwargs["request"]
|
|
266
|
+
if isinstance(req, Request): # pyright: ignore[reportUnknownVariableType]
|
|
267
|
+
return req # pyright: ignore[reportUnknownVariableType]
|
|
268
|
+
|
|
269
|
+
# Check positional args
|
|
270
|
+
for arg in args:
|
|
271
|
+
if isinstance(arg, Request): # pyright: ignore[reportUnknownVariableType]
|
|
272
|
+
return arg # pyright: ignore[reportUnknownVariableType]
|
|
273
|
+
|
|
274
|
+
return None
|