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,802 @@
1
+ import contextlib
2
+ import itertools
3
+ from collections.abc import Iterable, Mapping
4
+ from dataclasses import dataclass
5
+ from mimetypes import guess_type
6
+ from pathlib import PurePath
7
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
8
+ from urllib.parse import quote, urlparse
9
+
10
+ import httpx
11
+ from litestar import Litestar, MediaType, Request, Response
12
+ from litestar.datastructures.cookie import Cookie
13
+ from litestar.exceptions import ImproperlyConfiguredException
14
+ from litestar.response import Redirect
15
+ from litestar.response.base import ASGIResponse
16
+ from litestar.serialization import get_serializer
17
+ from litestar.status_codes import HTTP_200_OK, HTTP_303_SEE_OTHER, HTTP_307_TEMPORARY_REDIRECT, HTTP_409_CONFLICT
18
+ from litestar.utils.empty import value_or_default
19
+ from litestar.utils.helpers import get_enum_string_value
20
+ from litestar.utils.scope.state import ScopeState
21
+
22
+ from litestar_vite.html_transform import inject_head_html, set_element_inner_html
23
+ from litestar_vite.inertia._utils import get_headers
24
+ from litestar_vite.inertia.helpers import (
25
+ extract_deferred_props,
26
+ extract_merge_props,
27
+ extract_once_props,
28
+ extract_pagination_scroll_props,
29
+ get_shared_props,
30
+ is_merge_prop,
31
+ is_or_contains_lazy_prop,
32
+ is_or_contains_special_prop,
33
+ is_pagination_container,
34
+ lazy_render,
35
+ pagination_to_dict,
36
+ should_render,
37
+ )
38
+ from litestar_vite.inertia.plugin import InertiaPlugin
39
+ from litestar_vite.inertia.request import InertiaDetails, InertiaRequest
40
+ from litestar_vite.inertia.types import InertiaHeaderType, PageProps, ScrollPropsConfig
41
+ from litestar_vite.plugin import VitePlugin
42
+
43
+ if TYPE_CHECKING:
44
+ from anyio.from_thread import BlockingPortal
45
+ from litestar.background_tasks import BackgroundTask, BackgroundTasks
46
+ from litestar.connection.base import AuthT, StateT, UserT
47
+ from litestar.types import ResponseCookies, ResponseHeaders, TypeEncodersMap
48
+
49
+
50
+ T = TypeVar("T")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class _InertiaSSRResult:
55
+ head: list[str]
56
+ body: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _InertiaRequestInfo:
61
+ inertia_enabled: bool
62
+ is_inertia: bool
63
+ is_partial_render: bool
64
+ partial_keys: set[str]
65
+ partial_except_keys: set[str]
66
+ reset_keys: set[str]
67
+
68
+
69
+ def _get_inertia_request_info(request: "Request[Any, Any, Any]") -> _InertiaRequestInfo:
70
+ """Return Inertia request state for both InertiaRequest and plain Request.
71
+
72
+ InertiaResponse is typically used together with InertiaMiddleware, which wraps
73
+ incoming requests in :class:`~litestar_vite.inertia.request.InertiaRequest`.
74
+
75
+ This helper preserves compatibility with plain :class:`litestar.Request` by
76
+ falling back to header parsing via :class:`~litestar_vite.inertia.request.InertiaDetails`.
77
+
78
+ Returns:
79
+ Aggregated Inertia-related request flags and partial-render metadata.
80
+ """
81
+ if isinstance(request, InertiaRequest):
82
+ is_inertia = request.is_inertia
83
+ return _InertiaRequestInfo(
84
+ inertia_enabled=bool(request.inertia_enabled or is_inertia),
85
+ is_inertia=is_inertia,
86
+ is_partial_render=request.is_partial_render,
87
+ partial_keys=request.partial_keys,
88
+ partial_except_keys=request.partial_except_keys,
89
+ reset_keys=request.reset_keys,
90
+ )
91
+
92
+ details = InertiaDetails(request)
93
+ is_inertia = bool(details)
94
+ return _InertiaRequestInfo(
95
+ inertia_enabled=bool(details.route_component is not None or is_inertia),
96
+ is_inertia=is_inertia,
97
+ is_partial_render=details.is_partial_render,
98
+ partial_keys=set(details.partial_keys),
99
+ partial_except_keys=set(details.partial_except_keys),
100
+ reset_keys=set(details.reset_keys),
101
+ )
102
+
103
+
104
+ def _parse_inertia_ssr_payload(payload: Any, url: str) -> _InertiaSSRResult:
105
+ if not isinstance(payload, dict):
106
+ msg = f"Inertia SSR server at {url!r} returned unexpected payload type: {type(payload)!r}."
107
+ raise ImproperlyConfiguredException(msg)
108
+
109
+ payload_dict = cast("dict[str, Any]", payload)
110
+
111
+ body = payload_dict.get("body")
112
+ if not isinstance(body, str):
113
+ msg = f"Inertia SSR server at {url!r} returned invalid 'body' (expected string)."
114
+ raise ImproperlyConfiguredException(msg)
115
+
116
+ head_raw: Any = payload_dict.get("head", [])
117
+ if head_raw is None:
118
+ head_raw = []
119
+ if not isinstance(head_raw, list):
120
+ msg = f"Inertia SSR server at {url!r} returned invalid 'head' (expected list[str])."
121
+ raise ImproperlyConfiguredException(msg)
122
+
123
+ head_list = cast("list[Any]", head_raw)
124
+ if any(not isinstance(item, str) for item in head_list):
125
+ msg = f"Inertia SSR server at {url!r} returned invalid 'head' (expected list[str])."
126
+ raise ImproperlyConfiguredException(msg)
127
+
128
+ return _InertiaSSRResult(head=cast("list[str]", head_list), body=body)
129
+
130
+
131
+ def _render_inertia_ssr_sync(
132
+ page: dict[str, Any],
133
+ url: str,
134
+ *,
135
+ timeout_seconds: float,
136
+ portal: "BlockingPortal",
137
+ client: "httpx.AsyncClient | None" = None,
138
+ ) -> _InertiaSSRResult:
139
+ """Call the Inertia SSR server and return head/body HTML.
140
+
141
+ The official Inertia SSR server listens on ``/render`` and expects the raw
142
+ page object as JSON. It returns JSON with at least a ``body`` field, and
143
+ optionally ``head`` (list of strings).
144
+
145
+ This function uses the application's :class:`~anyio.from_thread.BlockingPortal`
146
+ to call the async HTTP client without blocking the event loop thread.
147
+
148
+ Args:
149
+ page: The page object to send to the SSR server.
150
+ url: The SSR server URL.
151
+ timeout_seconds: Request timeout in seconds.
152
+ portal: BlockingPortal for sync-to-async bridging.
153
+ client: Optional shared httpx.AsyncClient for connection pooling.
154
+
155
+ Returns:
156
+ An _InertiaSSRResult with head and body HTML.
157
+ """
158
+ return portal.call(_render_inertia_ssr, page, url, timeout_seconds, client)
159
+
160
+
161
+ async def _render_inertia_ssr(
162
+ page: dict[str, Any], url: str, timeout_seconds: float, client: "httpx.AsyncClient | None" = None
163
+ ) -> _InertiaSSRResult:
164
+ """Call the Inertia SSR server asynchronously and return head/body HTML.
165
+
166
+ Args:
167
+ page: The page object to send to the SSR server.
168
+ url: The SSR server URL (typically http://localhost:13714/render).
169
+ timeout_seconds: Request timeout in seconds.
170
+ client: Optional shared httpx.AsyncClient for connection pooling.
171
+ If None, creates a new client per request (slower).
172
+
173
+ Returns:
174
+ An _InertiaSSRResult with head and body HTML.
175
+ """
176
+ return await _do_ssr_request(page, url, timeout_seconds, client)
177
+
178
+
179
+ async def _do_ssr_request(
180
+ page: dict[str, Any], url: str, timeout_seconds: float, client: "httpx.AsyncClient | None"
181
+ ) -> _InertiaSSRResult:
182
+ """Execute the SSR request with optional client reuse.
183
+
184
+ Args:
185
+ page: The page object to send to the SSR server.
186
+ url: The SSR server URL.
187
+ timeout_seconds: Request timeout in seconds.
188
+ client: Optional shared httpx.AsyncClient.
189
+
190
+ Raises:
191
+ ImproperlyConfiguredException: If the SSR server is unreachable,
192
+ returns an error status, or returns invalid payload.
193
+
194
+ Returns:
195
+ An _InertiaSSRResult with head and body HTML.
196
+ """
197
+ response: "httpx.Response"
198
+
199
+ if client is not None:
200
+ # Use shared client for connection pooling benefits
201
+ try:
202
+ response = await client.post(url, json=page, timeout=timeout_seconds)
203
+ response.raise_for_status()
204
+ except httpx.RequestError as exc:
205
+ msg = (
206
+ f"Inertia SSR is enabled but the SSR server is not reachable at {url!r}. "
207
+ "Start the SSR server (Node) or disable InertiaConfig.ssr."
208
+ )
209
+ raise ImproperlyConfiguredException(msg) from exc
210
+ except httpx.HTTPStatusError as exc:
211
+ msg = f"Inertia SSR server at {url!r} returned HTTP {exc.response.status_code}. Check the SSR server logs."
212
+ raise ImproperlyConfiguredException(msg) from exc
213
+ else:
214
+ # Fallback: create a new client per request (graceful degradation)
215
+ try:
216
+ async with httpx.AsyncClient() as fallback_client:
217
+ response = await fallback_client.post(url, json=page, timeout=timeout_seconds)
218
+ response.raise_for_status()
219
+ except httpx.RequestError as exc:
220
+ msg = (
221
+ f"Inertia SSR is enabled but the SSR server is not reachable at {url!r}. "
222
+ "Start the SSR server (Node) or disable InertiaConfig.ssr."
223
+ )
224
+ raise ImproperlyConfiguredException(msg) from exc
225
+ except httpx.HTTPStatusError as exc:
226
+ msg = f"Inertia SSR server at {url!r} returned HTTP {exc.response.status_code}. Check the SSR server logs."
227
+ raise ImproperlyConfiguredException(msg) from exc
228
+
229
+ try:
230
+ payload = response.json()
231
+ except ValueError as exc:
232
+ msg = f"Inertia SSR server at {url!r} returned invalid JSON. Check the SSR server logs."
233
+ raise ImproperlyConfiguredException(msg) from exc
234
+
235
+ return _parse_inertia_ssr_payload(payload, url)
236
+
237
+
238
+ def _get_redirect_url(request: "Request[Any, Any, Any]", url: str | None) -> str:
239
+ """Return a safe redirect URL, falling back to base_url when invalid.
240
+
241
+ Args:
242
+ request: The request object.
243
+ url: Candidate redirect URL.
244
+
245
+ Returns:
246
+ A safe redirect URL (same-origin absolute, or relative), otherwise the request base URL.
247
+ """
248
+ base_url = str(request.base_url)
249
+
250
+ if not url:
251
+ return base_url
252
+
253
+ parsed = urlparse(url)
254
+ base = urlparse(base_url)
255
+
256
+ if not parsed.scheme and not parsed.netloc:
257
+ return url
258
+
259
+ if parsed.scheme not in {"http", "https"}:
260
+ return base_url
261
+
262
+ if parsed.netloc != base.netloc:
263
+ return base_url
264
+
265
+ return url
266
+
267
+
268
+ def _get_relative_url(request: "Request[Any, Any, Any]") -> str:
269
+ """Return the relative URL including query string for Inertia page props.
270
+
271
+ The Inertia.js protocol requires the ``url`` property to include query parameters
272
+ so that page state (e.g., filters, pagination) is preserved on refresh.
273
+
274
+ This matches the behavior of other Inertia adapters:
275
+ - Laravel: Uses ``fullUrl()`` minus scheme/host
276
+ - Rails: Uses ``request.fullpath``
277
+ - Django: Uses ``request.get_full_path()``
278
+
279
+ Args:
280
+ request: The request object.
281
+
282
+ Returns:
283
+ The path with query string if present, e.g., ``/reports?page=1&status=active``.
284
+ """
285
+ path = request.url.path
286
+ query = request.url.query
287
+ return f"{path}?{query}" if query else path
288
+
289
+
290
+ class InertiaResponse(Response[T]):
291
+ """Inertia Response"""
292
+
293
+ def __init__(
294
+ self,
295
+ content: T,
296
+ *,
297
+ template_name: "str | None" = None,
298
+ template_str: "str | None" = None,
299
+ background: "BackgroundTask | BackgroundTasks | None" = None,
300
+ context: "dict[str, Any] | None" = None,
301
+ cookies: "ResponseCookies | None" = None,
302
+ encoding: "str" = "utf-8",
303
+ headers: "ResponseHeaders | None" = None,
304
+ media_type: "MediaType | str | None" = None,
305
+ status_code: "int" = HTTP_200_OK,
306
+ type_encoders: "TypeEncodersMap | None" = None,
307
+ encrypt_history: "bool | None" = None,
308
+ clear_history: bool = False,
309
+ scroll_props: "ScrollPropsConfig | None" = None,
310
+ ) -> None:
311
+ """Handle the rendering of a given template into a bytes string.
312
+
313
+ Args:
314
+ content: A value for the response body that will be rendered into bytes string.
315
+ template_name: Path-like name for the template to be rendered, e.g. ``index.html``.
316
+ template_str: A string representing the template, e.g. ``tmpl = "Hello <strong>World</strong>"``.
317
+ background: A :class:`BackgroundTask <.background_tasks.BackgroundTask>` instance or
318
+ :class:`BackgroundTasks <.background_tasks.BackgroundTasks>` to execute after the response is finished.
319
+ Defaults to ``None``.
320
+ context: A dictionary of key/value pairs to be passed to the temple engine's render method.
321
+ cookies: A list of :class:`Cookie <.datastructures.Cookie>` instances to be set under the response
322
+ ``Set-Cookie`` header.
323
+ encoding: Content encoding
324
+ headers: A string keyed dictionary of response headers. Header keys are insensitive.
325
+ media_type: A string or member of the :class:`MediaType <.enums.MediaType>` enum. If not set, try to infer
326
+ the media type based on the template name. If this fails, fall back to ``text/plain``.
327
+ status_code: A value for the response HTTP status code.
328
+ type_encoders: A mapping of types to callables that transform them into types supported for serialization.
329
+ encrypt_history: Enable browser history encryption for this response (v2 feature).
330
+ When True, the Inertia client encrypts history state using the browser's
331
+ crypto API. If None, falls back to InertiaConfig.encrypt_history.
332
+ See: https://inertiajs.com/history-encryption
333
+ clear_history: Clear previously encrypted history state (v2 feature).
334
+ When True, the client will regenerate its encryption key, invalidating
335
+ all previously encrypted history entries. Use during logout to ensure
336
+ sensitive data cannot be recovered from browser history.
337
+ scroll_props: Configuration for infinite scroll (v2 feature).
338
+ Provides next/previous page information for paginated data.
339
+ Use the scroll_props() helper to create this configuration.
340
+
341
+ Raises:
342
+ ValueError: If both template_name and template_str are provided.
343
+ """
344
+ if template_name and template_str:
345
+ msg = "Either template_name or template_str must be provided, not both."
346
+ raise ValueError(msg)
347
+ self.content = content
348
+ self.background = background
349
+ self.cookies: list[Cookie] = (
350
+ [Cookie(key=key, value=value) for key, value in cookies.items()]
351
+ if isinstance(cookies, Mapping)
352
+ else list(cookies or [])
353
+ )
354
+ self.encoding = encoding
355
+ self.headers: dict[str, Any] = (
356
+ dict(headers) if isinstance(headers, Mapping) else {h.name: h.value for h in headers or {}}
357
+ )
358
+ self.media_type = media_type
359
+ self.status_code = status_code
360
+ self.response_type_encoders = {**(self.type_encoders or {}), **(type_encoders or {})}
361
+ self.context = context or {}
362
+ self.template_name = template_name
363
+ self.template_str = template_str
364
+ self.encrypt_history = encrypt_history
365
+ self.clear_history = clear_history
366
+ self.scroll_props = scroll_props
367
+
368
+ def create_template_context(
369
+ self,
370
+ request: "Request[UserT, AuthT, StateT]",
371
+ page_props: "PageProps[T]",
372
+ type_encoders: "TypeEncodersMap | None" = None,
373
+ ) -> "dict[str, Any]":
374
+ """Create a context object for the template.
375
+
376
+ Args:
377
+ request: A :class:`Request <.connection.Request>` instance.
378
+ page_props: A formatted object to return the inertia configuration.
379
+ type_encoders: A mapping of types to callables that transform them into types supported for serialization.
380
+
381
+ Returns:
382
+ A dictionary holding the template context
383
+ """
384
+ csrf_token = value_or_default(ScopeState.from_scope(request.scope).csrf_token, "")
385
+ inertia_props = self.render(page_props.to_dict(), MediaType.JSON, get_serializer(type_encoders)).decode()
386
+ return {
387
+ **self.context,
388
+ "inertia": inertia_props,
389
+ "request": request,
390
+ "csrf_input": f'<input type="hidden" name="_csrf_token" value="{csrf_token}" />',
391
+ }
392
+
393
+ def _build_page_props(
394
+ self,
395
+ request: "Request[UserT, AuthT, StateT]",
396
+ partial_data: "set[str] | None",
397
+ partial_except: "set[str] | None",
398
+ reset_keys: "set[str]",
399
+ vite_plugin: "VitePlugin",
400
+ inertia_plugin: "InertiaPlugin",
401
+ ) -> "PageProps[T]":
402
+ """Build the PageProps object for the response.
403
+
404
+ Args:
405
+ request: The request object.
406
+ partial_data: Set of partial data keys.
407
+ partial_except: Set of partial except keys.
408
+ reset_keys: Set of keys to reset.
409
+ vite_plugin: The Vite plugin instance.
410
+ inertia_plugin: The Inertia plugin instance.
411
+
412
+ Returns:
413
+ The PageProps object.
414
+ """
415
+ shared_props = get_shared_props(request, partial_data=partial_data, partial_except=partial_except)
416
+
417
+ for key in reset_keys:
418
+ shared_props.pop(key, None)
419
+
420
+ route_content: Any | None = None
421
+ if is_or_contains_lazy_prop(self.content) or is_or_contains_special_prop(self.content):
422
+ filtered_content = lazy_render(self.content, partial_data, inertia_plugin.portal, partial_except)
423
+ if filtered_content is not None:
424
+ route_content = filtered_content
425
+ elif should_render(self.content, partial_data, partial_except):
426
+ route_content = self.content
427
+
428
+ if route_content is not None:
429
+ if isinstance(route_content, Mapping):
430
+ mapping_content = cast("Mapping[str, Any]", route_content)
431
+ for key, value in mapping_content.items():
432
+ shared_props[key] = value
433
+ elif is_pagination_container(route_content):
434
+ route_handler = request.scope.get("route_handler") # pyright: ignore[reportUnknownMemberType]
435
+ prop_key = (route_handler.opt.get("key", "items") if route_handler else "items") or "items"
436
+ shared_props[prop_key] = route_content
437
+ else:
438
+ shared_props["content"] = route_content
439
+
440
+ deferred_props = extract_deferred_props(shared_props) or None
441
+ # Extract once props tracked during get_shared_props (already rendered)
442
+ once_props_from_shared = shared_props.pop("_once_props", [])
443
+ # Also check route content for once props
444
+ once_props_from_content = extract_once_props(shared_props) or []
445
+ once_props = (once_props_from_shared + once_props_from_content) or None
446
+
447
+ merge_props_list, prepend_props_list, deep_merge_props_list, match_props_on = extract_merge_props(shared_props)
448
+
449
+ for key, value in list(shared_props.items()):
450
+ if is_merge_prop(value):
451
+ shared_props[key] = value.value
452
+
453
+ extracted_scroll_props: "ScrollPropsConfig | None" = self.scroll_props
454
+
455
+ route_handler = request.scope.get("route_handler") # pyright: ignore[reportUnknownMemberType]
456
+ infinite_scroll_enabled = bool(route_handler and route_handler.opt.get("infinite_scroll", False))
457
+
458
+ for key, value in list(shared_props.items()):
459
+ if is_pagination_container(value):
460
+ _, scroll = extract_pagination_scroll_props(value)
461
+ if extracted_scroll_props is None and scroll is not None and infinite_scroll_enabled:
462
+ extracted_scroll_props = scroll
463
+
464
+ pagination_dict = pagination_to_dict(value)
465
+ shared_props[key] = pagination_dict.pop("items")
466
+ shared_props.update(pagination_dict)
467
+
468
+ encrypt_history = self.encrypt_history
469
+ if encrypt_history is None:
470
+ encrypt_history = inertia_plugin.config.encrypt_history
471
+
472
+ clear_history_flag = self.clear_history
473
+ if not clear_history_flag:
474
+ with contextlib.suppress(AttributeError, ImproperlyConfiguredException):
475
+ clear_history_flag = request.session.pop("_inertia_clear_history", False) # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue]
476
+
477
+ # v2.3+ protocol: Extract flash to top level (not in props)
478
+ # This prevents flash from persisting in browser history state
479
+ # Always send {} for empty flash to support router.flash((current) => ({ ...current }))
480
+ flash_data: "dict[str, list[str]]" = shared_props.pop("flash", None) or {}
481
+
482
+ return PageProps[T](
483
+ component=request.inertia.route_component, # type: ignore[attr-defined] # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType,reportAttributeAccessIssue]
484
+ props=shared_props, # pyright: ignore[reportArgumentType]
485
+ version=vite_plugin.asset_loader.version_id,
486
+ url=_get_relative_url(request),
487
+ encrypt_history=encrypt_history,
488
+ clear_history=clear_history_flag,
489
+ deferred_props=deferred_props,
490
+ once_props=once_props,
491
+ merge_props=merge_props_list or None,
492
+ prepend_props=prepend_props_list or None,
493
+ deep_merge_props=deep_merge_props_list or None,
494
+ match_props_on=match_props_on or None,
495
+ scroll_props=extracted_scroll_props,
496
+ flash=flash_data,
497
+ )
498
+
499
+ def _render_template(
500
+ self,
501
+ request: "Request[UserT, AuthT, StateT]",
502
+ page_props: "PageProps[T]",
503
+ type_encoders: "TypeEncodersMap | None",
504
+ inertia_plugin: "InertiaPlugin",
505
+ ) -> bytes:
506
+ """Render the template to bytes.
507
+
508
+ Args:
509
+ request: The request object.
510
+ page_props: The page props to render.
511
+ type_encoders: Type encoders for serialization.
512
+ inertia_plugin: The Inertia plugin instance.
513
+
514
+ Returns:
515
+ The rendered template as bytes.
516
+
517
+ Raises:
518
+ ImproperlyConfiguredException: If the template engine is not configured.
519
+ """
520
+ template_engine = request.app.template_engine # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
521
+ if not template_engine:
522
+ msg = "Template engine is not configured"
523
+ raise ImproperlyConfiguredException(msg)
524
+
525
+ context = self.create_template_context(request, page_props, type_encoders) # pyright: ignore[reportUnknownMemberType]
526
+ if self.template_str is not None:
527
+ return template_engine.render_string(self.template_str, context).encode(self.encoding) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType,reportReturnType]
528
+
529
+ template_name = self.template_name or inertia_plugin.config.root_template
530
+ template = template_engine.get_template(template_name) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
531
+ return template.render(**context).encode(self.encoding) # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType,reportReturnType]
532
+
533
+ def _get_csrf_token(self, request: "Request[UserT, AuthT, StateT]") -> "str | None":
534
+ """Extract CSRF token from the request scope.
535
+
536
+ Args:
537
+ request: The incoming request.
538
+
539
+ Returns:
540
+ The CSRF token if available, otherwise None.
541
+ """
542
+ csrf_token = value_or_default(ScopeState.from_scope(request.scope).csrf_token, "")
543
+ return csrf_token or None
544
+
545
+ def _render_spa(
546
+ self,
547
+ request: "Request[UserT, AuthT, StateT]",
548
+ page_props: "PageProps[T]",
549
+ vite_plugin: "VitePlugin",
550
+ inertia_plugin: "InertiaPlugin",
551
+ ) -> bytes:
552
+ """Render the page using SPA mode (HTML transformation instead of templates).
553
+
554
+ This method uses AppHandler to get the base HTML and injects
555
+ the page props as a data-page attribute on the app element.
556
+
557
+ Uses get_html_sync() for both dev and production modes to avoid
558
+ deadlocks when calling async code from sync context within the
559
+ same event loop thread.
560
+
561
+ Args:
562
+ request: The request object.
563
+ page_props: The page props to render.
564
+ vite_plugin: The Vite plugin instance (for SPA handler access).
565
+ inertia_plugin: The Inertia plugin instance (for config access).
566
+
567
+ Returns:
568
+ The rendered HTML as bytes.
569
+
570
+ Raises:
571
+ ImproperlyConfiguredException: If AppHandler is not available.
572
+ """
573
+ spa_handler = vite_plugin.spa_handler
574
+ if spa_handler is None:
575
+ msg = (
576
+ "SPA mode requires VitePlugin with mode='spa' or mode='hybrid'. "
577
+ "Set mode='hybrid' in ViteConfig for template-less Inertia."
578
+ )
579
+ raise ImproperlyConfiguredException(msg)
580
+
581
+ page_dict = page_props.to_dict()
582
+
583
+ ssr_config = inertia_plugin.config.ssr_config
584
+ if ssr_config is not None:
585
+ ssr_payload = _render_inertia_ssr_sync(
586
+ page_dict,
587
+ ssr_config.url,
588
+ timeout_seconds=ssr_config.timeout,
589
+ portal=inertia_plugin.portal,
590
+ client=inertia_plugin.ssr_client,
591
+ )
592
+
593
+ csrf_token = self._get_csrf_token(request)
594
+ html = spa_handler.get_html_sync(page_data=page_dict, csrf_token=csrf_token)
595
+
596
+ selector = "#app"
597
+ spa_config = spa_handler._spa_config # pyright: ignore
598
+ if spa_config is not None:
599
+ selector = spa_config.app_selector
600
+
601
+ html = set_element_inner_html(html, selector, ssr_payload.body)
602
+ if ssr_payload.head:
603
+ html = inject_head_html(html, "\n".join(ssr_payload.head))
604
+
605
+ return html.encode(self.encoding)
606
+
607
+ csrf_token = self._get_csrf_token(request)
608
+
609
+ html = spa_handler.get_html_sync(page_data=page_dict, csrf_token=csrf_token)
610
+
611
+ return html.encode(self.encoding)
612
+
613
+ def _determine_media_type(self, media_type: "MediaType | str | None") -> "MediaType | str":
614
+ """Determine the media type for the response.
615
+
616
+ Args:
617
+ media_type: The provided media type or None.
618
+
619
+ Returns:
620
+ The determined media type.
621
+ """
622
+ if media_type:
623
+ return media_type
624
+ if self.template_name:
625
+ suffixes = PurePath(self.template_name).suffixes
626
+ for suffix in suffixes:
627
+ if type_ := guess_type(f"name{suffix}")[0]:
628
+ return type_
629
+ return MediaType.TEXT
630
+ return MediaType.HTML
631
+
632
+ def to_asgi_response(
633
+ self,
634
+ app: "Litestar | None",
635
+ request: "Request[UserT, AuthT, StateT]",
636
+ *,
637
+ background: "BackgroundTask | BackgroundTasks | None" = None,
638
+ cookies: "Iterable[Cookie] | None" = None,
639
+ encoded_headers: "Iterable[tuple[bytes, bytes]] | None" = None,
640
+ headers: "dict[str, str] | None" = None,
641
+ is_head_response: "bool" = False,
642
+ media_type: "MediaType | str | None" = None,
643
+ status_code: "int | None" = None,
644
+ type_encoders: "TypeEncodersMap | None" = None,
645
+ ) -> "ASGIResponse":
646
+ inertia_info = _get_inertia_request_info(cast("Request[Any, Any, Any]", request))
647
+ headers = {**headers, **self.headers} if headers is not None else self.headers
648
+ cookies = self.cookies if cookies is None else itertools.chain(self.cookies, cookies)
649
+ type_encoders = (
650
+ {**type_encoders, **(self.response_type_encoders or {})} if type_encoders else self.response_type_encoders
651
+ )
652
+
653
+ if not inertia_info.inertia_enabled:
654
+ resolved_media_type = get_enum_string_value(self.media_type or media_type or MediaType.JSON)
655
+ return ASGIResponse(
656
+ background=self.background or background,
657
+ body=self.render(self.content, resolved_media_type, get_serializer(type_encoders)),
658
+ cookies=cookies,
659
+ encoded_headers=encoded_headers,
660
+ encoding=self.encoding,
661
+ headers=headers,
662
+ is_head_response=is_head_response,
663
+ media_type=resolved_media_type,
664
+ status_code=self.status_code or status_code,
665
+ )
666
+
667
+ vite_plugin = request.app.plugins.get(VitePlugin)
668
+ inertia_plugin = request.app.plugins.get(InertiaPlugin)
669
+ headers.update({
670
+ "Vary": "Accept",
671
+ **get_headers(InertiaHeaderType(enabled=True, version=vite_plugin.asset_loader.version_id)),
672
+ })
673
+
674
+ partial_data: "set[str] | None" = (
675
+ inertia_info.partial_keys if inertia_info.is_partial_render and inertia_info.partial_keys else None
676
+ )
677
+ partial_except: "set[str] | None" = (
678
+ inertia_info.partial_except_keys
679
+ if inertia_info.is_partial_render and inertia_info.partial_except_keys
680
+ else None
681
+ )
682
+
683
+ page_props = self._build_page_props(
684
+ request, partial_data, partial_except, inertia_info.reset_keys, vite_plugin, inertia_plugin
685
+ )
686
+
687
+ if inertia_info.is_inertia:
688
+ resolved_media_type = get_enum_string_value(self.media_type or media_type or MediaType.JSON)
689
+ body = self.render(page_props.to_dict(), resolved_media_type, get_serializer(type_encoders))
690
+ return ASGIResponse( # pyright: ignore[reportUnknownMemberType]
691
+ background=self.background or background,
692
+ body=body,
693
+ cookies=cookies,
694
+ encoded_headers=encoded_headers,
695
+ encoding=self.encoding,
696
+ headers=headers,
697
+ is_head_response=is_head_response,
698
+ media_type=resolved_media_type,
699
+ status_code=self.status_code or status_code,
700
+ )
701
+
702
+ resolved_media_type = self._determine_media_type(media_type or MediaType.HTML)
703
+
704
+ if vite_plugin.config.mode == "hybrid":
705
+ body = self._render_spa(request, page_props, vite_plugin, inertia_plugin)
706
+ else:
707
+ body = self._render_template(request, page_props, type_encoders, inertia_plugin)
708
+
709
+ return ASGIResponse( # pyright: ignore[reportUnknownMemberType]
710
+ background=self.background or background,
711
+ body=body,
712
+ cookies=cookies,
713
+ encoded_headers=encoded_headers,
714
+ encoding=self.encoding,
715
+ headers=headers,
716
+ is_head_response=is_head_response,
717
+ media_type=resolved_media_type,
718
+ status_code=self.status_code or status_code,
719
+ )
720
+
721
+
722
+ class InertiaExternalRedirect(Response[Any]):
723
+ """External redirect via Inertia protocol (409 + X-Inertia-Location).
724
+
725
+ This response type triggers a client-side hard redirect in Inertia.js.
726
+ Unlike InertiaRedirect, this does NOT validate the redirect URL as same-origin
727
+ because external redirects are explicitly intended for cross-origin navigation
728
+ (e.g., OAuth callbacks, external payment pages).
729
+
730
+ Note:
731
+ Request cookies are intentionally NOT passed to the response to prevent
732
+ cookie leakage in redirect responses.
733
+ """
734
+
735
+ def __init__(self, request: "Request[Any, Any, Any]", redirect_to: "str", **kwargs: "Any") -> None:
736
+ """Initialize external redirect with 409 status and X-Inertia-Location header.
737
+
738
+ Args:
739
+ request: The request object.
740
+ redirect_to: The URL to redirect to (can be external).
741
+ **kwargs: Additional keyword arguments passed to the Response constructor.
742
+ """
743
+ super().__init__(
744
+ content=b"",
745
+ status_code=HTTP_409_CONFLICT,
746
+ headers={"X-Inertia-Location": quote(redirect_to, safe="/#%[]=:;$&()+,!?*@'~")},
747
+ **kwargs,
748
+ )
749
+
750
+
751
+ class InertiaRedirect(Redirect):
752
+ """Redirect to a specified URL with same-origin validation.
753
+
754
+ This class validates the redirect URL to prevent open redirect attacks.
755
+ If the URL is not same-origin, it falls back to the application's base URL.
756
+
757
+ Note:
758
+ Request cookies are intentionally NOT passed to the response to prevent
759
+ cookie leakage in redirect responses.
760
+ """
761
+
762
+ def __init__(self, request: "Request[Any, Any, Any]", redirect_to: "str", **kwargs: "Any") -> None:
763
+ """Initialize redirect with safe URL validation.
764
+
765
+ Args:
766
+ request: The request object.
767
+ redirect_to: The URL to redirect to. Must be same-origin or relative.
768
+ **kwargs: Additional keyword arguments passed to the Redirect constructor.
769
+ """
770
+ safe_url = _get_redirect_url(request, redirect_to)
771
+ super().__init__( # pyright: ignore[reportUnknownMemberType]
772
+ path=safe_url,
773
+ status_code=HTTP_307_TEMPORARY_REDIRECT if request.method == "GET" else HTTP_303_SEE_OTHER,
774
+ **kwargs,
775
+ )
776
+
777
+
778
+ class InertiaBack(Redirect):
779
+ """Redirect back to the previous page using the Referer header.
780
+
781
+ This class safely validates the Referer header to prevent open redirect
782
+ attacks. If the Referer is not same-origin or is missing, it falls back
783
+ to the application's base URL.
784
+
785
+ Note:
786
+ Request cookies are intentionally NOT passed to the response to prevent
787
+ cookie leakage in redirect responses.
788
+ """
789
+
790
+ def __init__(self, request: "Request[Any, Any, Any]", **kwargs: "Any") -> None:
791
+ """Initialize back redirect with safe URL validation.
792
+
793
+ Args:
794
+ request: The request object.
795
+ **kwargs: Additional keyword arguments passed to the Redirect constructor.
796
+ """
797
+ safe_url = _get_redirect_url(request, request.headers.get("Referer"))
798
+ super().__init__( # pyright: ignore[reportUnknownMemberType]
799
+ path=safe_url,
800
+ status_code=HTTP_307_TEMPORARY_REDIRECT if request.method == "GET" else HTTP_303_SEE_OTHER,
801
+ **kwargs,
802
+ )