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.
Files changed (169) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/cli.py +1048 -10
  4. litestar_vite/codegen/__init__.py +48 -0
  5. litestar_vite/codegen/_export.py +229 -0
  6. litestar_vite/codegen/_inertia.py +619 -0
  7. litestar_vite/codegen/_openapi.py +280 -0
  8. litestar_vite/codegen/_routes.py +720 -0
  9. litestar_vite/codegen/_ts.py +235 -0
  10. litestar_vite/codegen/_utils.py +141 -0
  11. litestar_vite/commands.py +73 -0
  12. litestar_vite/config/__init__.py +997 -0
  13. litestar_vite/config/_constants.py +97 -0
  14. litestar_vite/config/_deploy.py +70 -0
  15. litestar_vite/config/_inertia.py +241 -0
  16. litestar_vite/config/_paths.py +63 -0
  17. litestar_vite/config/_runtime.py +235 -0
  18. litestar_vite/config/_spa.py +93 -0
  19. litestar_vite/config/_types.py +94 -0
  20. litestar_vite/deploy.py +366 -0
  21. litestar_vite/doctor.py +1181 -0
  22. litestar_vite/exceptions.py +78 -0
  23. litestar_vite/executor.py +360 -0
  24. litestar_vite/handler/__init__.py +9 -0
  25. litestar_vite/handler/_app.py +612 -0
  26. litestar_vite/handler/_routing.py +130 -0
  27. litestar_vite/html_transform.py +569 -0
  28. litestar_vite/inertia/__init__.py +77 -0
  29. litestar_vite/inertia/_utils.py +119 -0
  30. litestar_vite/inertia/exception_handler.py +178 -0
  31. litestar_vite/inertia/helpers.py +1571 -0
  32. litestar_vite/inertia/middleware.py +54 -0
  33. litestar_vite/inertia/plugin.py +199 -0
  34. litestar_vite/inertia/precognition.py +274 -0
  35. litestar_vite/inertia/request.py +334 -0
  36. litestar_vite/inertia/response.py +802 -0
  37. litestar_vite/inertia/types.py +335 -0
  38. litestar_vite/loader.py +464 -123
  39. litestar_vite/plugin/__init__.py +687 -0
  40. litestar_vite/plugin/_process.py +185 -0
  41. litestar_vite/plugin/_proxy.py +689 -0
  42. litestar_vite/plugin/_proxy_headers.py +244 -0
  43. litestar_vite/plugin/_static.py +37 -0
  44. litestar_vite/plugin/_utils.py +489 -0
  45. litestar_vite/py.typed +0 -0
  46. litestar_vite/scaffolding/__init__.py +20 -0
  47. litestar_vite/scaffolding/generator.py +270 -0
  48. litestar_vite/scaffolding/templates.py +437 -0
  49. litestar_vite/templates/__init__.py +0 -0
  50. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  51. litestar_vite/templates/angular/index.html.j2 +12 -0
  52. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular/package.json.j2 +36 -0
  54. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  55. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  56. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  57. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  58. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  59. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  60. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  61. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  62. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  63. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  64. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  65. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  66. litestar_vite/templates/angular-cli/package.json.j2 +28 -0
  67. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  68. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  69. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  70. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  71. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  72. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  73. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  74. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  75. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  76. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  77. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  78. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  79. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  80. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  81. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  82. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  83. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  84. litestar_vite/templates/base/.gitignore.j2 +42 -0
  85. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  86. litestar_vite/templates/base/package.json.j2 +39 -0
  87. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  88. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  89. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  90. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  91. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  92. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  93. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  94. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  95. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  96. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  97. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  98. litestar_vite/templates/react/index.html.j2 +13 -0
  99. litestar_vite/templates/react/src/App.css.j2 +56 -0
  100. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  101. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  102. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  103. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  104. litestar_vite/templates/react-inertia/package.json.j2 +47 -0
  105. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  106. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  107. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  108. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  109. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  110. litestar_vite/templates/react-router/index.html.j2 +12 -0
  111. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  112. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  113. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  114. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  115. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  116. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  117. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  118. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  119. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  120. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  121. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  122. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  123. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  124. litestar_vite/templates/svelte/index.html.j2 +13 -0
  125. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  126. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  127. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  128. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  129. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  130. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  131. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  132. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  133. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  134. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  135. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  136. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  137. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  138. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  139. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  140. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  141. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  142. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  143. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  144. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  145. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  146. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  147. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  148. litestar_vite/templates/vue/index.html.j2 +13 -0
  149. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  150. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  151. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  152. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  153. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  154. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  155. litestar_vite/templates/vue-inertia/package.json.j2 +50 -0
  156. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  157. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  158. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  159. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  160. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  161. litestar_vite-0.15.0.dist-info/METADATA +230 -0
  162. litestar_vite-0.15.0.dist-info/RECORD +164 -0
  163. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
  164. litestar_vite/config.py +0 -100
  165. litestar_vite/plugin.py +0 -45
  166. litestar_vite/template_engine.py +0 -103
  167. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  168. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  169. {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