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