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,612 @@
1
+ """SPA mode handler for Vite integration.
2
+
3
+ This module provides :class:`~litestar_vite.handler.AppHandler` which manages serving
4
+ the Single Page Application (SPA) HTML in both development and production modes.
5
+
6
+ In dev mode, it proxies requests to the Vite dev server for HMR support.
7
+ In production, it serves the built index.html with async caching.
8
+ """
9
+
10
+ import logging
11
+ from contextlib import suppress
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, NoReturn
14
+
15
+ import anyio
16
+ import httpx
17
+ from litestar import get
18
+ from litestar.exceptions import ImproperlyConfiguredException, SerializationException
19
+ from litestar.serialization import decode_json, encode_json
20
+
21
+ from litestar_vite.config import InertiaConfig
22
+ from litestar_vite.handler._routing import spa_handler_dev, spa_handler_prod
23
+ from litestar_vite.html_transform import (
24
+ inject_head_script,
25
+ inject_page_script,
26
+ inject_vite_dev_scripts,
27
+ set_data_attribute,
28
+ transform_asset_urls,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from litestar.connection import Request
33
+ from litestar.types import Guard # pyright: ignore[reportUnknownVariableType]
34
+
35
+ from litestar_vite.config import SPAConfig, ViteConfig
36
+
37
+ logger = logging.getLogger("litestar_vite")
38
+
39
+
40
+ class AppHandler:
41
+ """Handler for serving SPA HTML in both dev and production modes."""
42
+
43
+ __slots__ = (
44
+ "_cached_bytes",
45
+ "_cached_html",
46
+ "_cached_transformed_html",
47
+ "_config",
48
+ "_http_client",
49
+ "_http_client_sync",
50
+ "_initialized",
51
+ "_manifest",
52
+ "_spa_config",
53
+ "_vite_url",
54
+ )
55
+
56
+ def __init__(self, config: "ViteConfig") -> None:
57
+ """Initialize the SPA handler.
58
+
59
+ Args:
60
+ config: The Vite configuration.
61
+ """
62
+ self._config = config
63
+ self._spa_config: "SPAConfig | None" = config.spa_config
64
+ self._cached_html: "str | None" = None
65
+ self._cached_bytes: "bytes | None" = None
66
+ self._cached_transformed_html: "str | None" = None
67
+ self._initialized = False
68
+ self._http_client: "httpx.AsyncClient | None" = None
69
+ self._http_client_sync: "httpx.Client | None" = None
70
+ self._vite_url: "str | None" = None
71
+ self._manifest: "dict[str, Any]" = {}
72
+
73
+ @property
74
+ def is_initialized(self) -> bool:
75
+ """Whether the handler has been initialized.
76
+
77
+ Returns:
78
+ True when initialized, otherwise False.
79
+ """
80
+ return self._initialized
81
+
82
+ async def initialize_async(self, vite_url: "str | None" = None) -> None:
83
+ """Initialize the handler asynchronously.
84
+
85
+ Args:
86
+ vite_url: Optional Vite server URL to use for proxying.
87
+ """
88
+ if self._initialized:
89
+ return
90
+
91
+ if self._config.is_dev_mode and self._config.hot_reload:
92
+ self._init_http_clients(vite_url)
93
+ else:
94
+ await self._load_production_assets_async()
95
+
96
+ self._initialized = True
97
+
98
+ def initialize_sync(self, vite_url: "str | None" = None) -> None:
99
+ """Initialize the handler synchronously.
100
+
101
+ Args:
102
+ vite_url: Optional Vite server URL to use for proxying.
103
+ """
104
+ if self._initialized:
105
+ return
106
+
107
+ if self._config.is_dev_mode and self._config.hot_reload:
108
+ self._init_http_clients(vite_url)
109
+ else:
110
+ self._load_production_assets_sync()
111
+
112
+ self._initialized = True
113
+
114
+ def _init_http_clients(self, vite_url: "str | None" = None) -> None:
115
+ """Initialize HTTP clients for dev mode proxying."""
116
+ self._vite_url = vite_url or self._resolve_vite_url()
117
+
118
+ http2_enabled = self._config.http2
119
+ if http2_enabled:
120
+ try:
121
+ import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
122
+ except ImportError:
123
+ http2_enabled = False
124
+
125
+ self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(5.0), http2=http2_enabled)
126
+ self._http_client_sync = httpx.Client(timeout=httpx.Timeout(5.0))
127
+
128
+ async def shutdown_async(self) -> None:
129
+ """Shutdown the handler asynchronously."""
130
+ if self._http_client is not None:
131
+ with suppress(RuntimeError):
132
+ await self._http_client.aclose()
133
+ self._http_client = None
134
+ if self._http_client_sync is not None:
135
+ with suppress(RuntimeError):
136
+ self._http_client_sync.close()
137
+ self._http_client_sync = None
138
+
139
+ def _load_production_assets_sync(self) -> None:
140
+ """Load manifest and index.html synchronously in production modes."""
141
+ if self._config.mode != "external":
142
+ self._load_manifest_sync()
143
+ self._load_index_html_sync()
144
+
145
+ async def _load_production_assets_async(self) -> None:
146
+ """Load manifest and index.html asynchronously in production modes."""
147
+ if self._config.mode != "external":
148
+ await self._load_manifest_async()
149
+ await self._load_index_html_async()
150
+
151
+ def _transform_html(
152
+ self, html: str, page_data: "dict[str, Any] | None" = None, csrf_token: "str | None" = None
153
+ ) -> str:
154
+ """Transform HTML by injecting CSRF token and/or page data.
155
+
156
+ Returns:
157
+ The transformed HTML.
158
+ """
159
+ if self._spa_config is None:
160
+ if page_data is not None:
161
+ json_data = encode_json(page_data).decode("utf-8")
162
+ html = set_data_attribute(html, "#app", "data-page", json_data)
163
+ return html
164
+
165
+ if self._spa_config.inject_csrf and csrf_token:
166
+ script = f'window.{self._spa_config.csrf_var_name} = "{csrf_token}";'
167
+ html = inject_head_script(html, script, escape=False, nonce=self._config.csp_nonce)
168
+
169
+ if page_data is not None:
170
+ json_data = encode_json(page_data).decode("utf-8")
171
+ # Check InertiaConfig for use_script_element (Inertia-specific setting)
172
+ inertia = self._config.inertia
173
+ use_script_element = isinstance(inertia, InertiaConfig) and inertia.use_script_element
174
+ if use_script_element:
175
+ # v2.3+ Inertia protocol: Use script element for better performance (~37% smaller)
176
+ html = inject_page_script(html, json_data, nonce=self._config.csp_nonce)
177
+ else:
178
+ # Legacy: Use data-page attribute
179
+ html = set_data_attribute(html, self._spa_config.app_selector, "data-page", json_data)
180
+
181
+ return html
182
+
183
+ async def _load_index_html_async(self) -> None:
184
+ """Load and cache index.html asynchronously."""
185
+ resolved_path: Path | None = None
186
+ for candidate in self._config.candidate_index_html_paths():
187
+ candidate_path = anyio.Path(candidate)
188
+ if await candidate_path.exists():
189
+ resolved_path = candidate
190
+ break
191
+
192
+ if resolved_path is None:
193
+ self._raise_index_not_found()
194
+
195
+ raw_bytes = await anyio.Path(resolved_path).read_bytes()
196
+ html = raw_bytes.decode("utf-8")
197
+ html = self._transform_asset_urls_in_html(html)
198
+
199
+ self._cached_html = html
200
+ self._cached_bytes = html.encode("utf-8")
201
+
202
+ def _load_index_html_sync(self) -> None:
203
+ """Load and cache index.html synchronously."""
204
+ resolved_path: Path | None = None
205
+ for candidate in self._config.candidate_index_html_paths():
206
+ candidate_path = Path(candidate)
207
+ if candidate_path.exists():
208
+ resolved_path = candidate_path
209
+ break
210
+
211
+ if resolved_path is None:
212
+ self._raise_index_not_found()
213
+
214
+ raw_bytes = resolved_path.read_bytes()
215
+ html = raw_bytes.decode("utf-8")
216
+ html = self._transform_asset_urls_in_html(html)
217
+
218
+ self._cached_html = html
219
+ self._cached_bytes = html.encode("utf-8")
220
+
221
+ def _raise_index_not_found(self) -> NoReturn:
222
+ """Raise an exception when index.html is not found.
223
+
224
+ Raises:
225
+ ImproperlyConfiguredException: Always raised.
226
+ """
227
+ joined_paths = ", ".join(str(path) for path in self._config.candidate_index_html_paths())
228
+ msg = (
229
+ "index.html not found. "
230
+ f"Checked: {joined_paths}. "
231
+ "SPA mode requires index.html in one of the expected locations. "
232
+ "Did you forget to build your assets?"
233
+ )
234
+ raise ImproperlyConfiguredException(msg)
235
+
236
+ def _get_manifest_path(self) -> Path:
237
+ """Get the path to the Vite manifest file.
238
+
239
+ Returns:
240
+ Absolute path to the manifest file location.
241
+ """
242
+ bundle_dir = self._config.bundle_dir
243
+ if not bundle_dir.is_absolute():
244
+ bundle_dir = self._config.root_dir / bundle_dir
245
+ return bundle_dir / self._config.manifest_name
246
+
247
+ async def _load_manifest_async(self) -> None:
248
+ """Asynchronously load the Vite manifest for asset URL transformation."""
249
+ manifest_path = anyio.Path(self._get_manifest_path())
250
+ try:
251
+ if await manifest_path.exists():
252
+ content = await manifest_path.read_bytes()
253
+ self._manifest = decode_json(content)
254
+ else:
255
+ logger.warning(
256
+ "Vite manifest not found at %s. "
257
+ "Asset URLs in index.html will not be transformed. "
258
+ "Run 'litestar assets build' to generate the manifest.",
259
+ manifest_path,
260
+ )
261
+ except OSError as exc:
262
+ logger.warning("Failed to read Vite manifest file: %s", exc)
263
+ except SerializationException as exc:
264
+ logger.warning("Failed to parse Vite manifest JSON: %s", exc)
265
+
266
+ def _load_manifest_sync(self) -> None:
267
+ """Synchronously load the Vite manifest for asset URL transformation."""
268
+ manifest_path = self._get_manifest_path()
269
+ try:
270
+ if manifest_path.exists():
271
+ content = manifest_path.read_bytes()
272
+ self._manifest = decode_json(content)
273
+ else:
274
+ logger.warning(
275
+ "Vite manifest not found at %s. "
276
+ "Asset URLs in index.html will not be transformed. "
277
+ "Run 'litestar assets build' to generate the manifest.",
278
+ manifest_path,
279
+ )
280
+ except OSError as exc:
281
+ logger.warning("Failed to read Vite manifest file: %s", exc)
282
+ except SerializationException as exc:
283
+ logger.warning("Failed to parse Vite manifest JSON: %s", exc)
284
+
285
+ def _transform_asset_urls_in_html(self, html: str) -> str:
286
+ """Transform source asset URLs to production hashed URLs using manifest.
287
+
288
+ Args:
289
+ html: The HTML to transform.
290
+
291
+ Returns:
292
+ The transformed HTML (or original HTML when no manifest is loaded).
293
+ """
294
+ if not self._manifest:
295
+ return html
296
+ return transform_asset_urls(html, self._manifest, asset_url=self._config.asset_url, base_url=None)
297
+
298
+ def _inject_dev_scripts(self, html: str) -> str:
299
+ """Inject Vite dev scripts for hybrid mode HTML served by Litestar.
300
+
301
+ Returns:
302
+ The HTML with Vite dev scripts injected.
303
+ """
304
+ resource_dir = self._config.resource_dir
305
+ try:
306
+ resource_dir_str = str(resource_dir.relative_to(self._config.root_dir))
307
+ except ValueError:
308
+ resource_dir_str = resource_dir.name
309
+ return inject_vite_dev_scripts(
310
+ html,
311
+ "",
312
+ asset_url=self._config.asset_url,
313
+ is_react=self._config.is_react,
314
+ csp_nonce=self._config.csp_nonce,
315
+ resource_dir=resource_dir_str,
316
+ )
317
+
318
+ async def _transform_html_with_vite(self, html: str, url: str) -> str:
319
+ """Transform HTML using the Vite dev server pipeline.
320
+
321
+ Returns:
322
+ The transformed HTML.
323
+ """
324
+ if self._http_client is None or self._vite_url is None:
325
+ msg = "HTTP client not initialized. Ensure initialize_async() was called for dev mode."
326
+ raise ImproperlyConfiguredException(msg)
327
+ endpoint = f"{self._vite_url.rstrip('/')}/__litestar__/transform-index"
328
+ response = await self._http_client.post(endpoint, json={"url": url, "html": html}, timeout=5.0)
329
+ response.raise_for_status()
330
+ return response.text
331
+
332
+ def _transform_html_with_vite_sync(self, html: str, url: str) -> str:
333
+ """Transform HTML using the Vite dev server pipeline (sync).
334
+
335
+ Returns:
336
+ The transformed HTML.
337
+ """
338
+ if self._http_client_sync is None or self._vite_url is None:
339
+ msg = "HTTP client not initialized. Ensure initialize_sync() was called for dev mode."
340
+ raise ImproperlyConfiguredException(msg)
341
+ endpoint = f"{self._vite_url.rstrip('/')}/__litestar__/transform-index"
342
+ response = self._http_client_sync.post(endpoint, json={"url": url, "html": html}, timeout=5.0)
343
+ response.raise_for_status()
344
+ return response.text
345
+
346
+ async def _get_dev_html(self, request: "Request[Any, Any, Any]") -> str:
347
+ """Resolve dev HTML for SPA or hybrid modes.
348
+
349
+ Returns:
350
+ The HTML to serve in development.
351
+ """
352
+ if self._config.mode == "hybrid":
353
+ if self._cached_html is None:
354
+ await self._load_index_html_async()
355
+ base_html = self._cached_html or ""
356
+ request_url = request.url.path or "/"
357
+ try:
358
+ return await self._transform_html_with_vite(base_html, request_url)
359
+ except Exception as exc: # noqa: BLE001
360
+ logger.warning("Falling back to manual Vite script injection: %s", exc)
361
+ return self._inject_dev_scripts(base_html)
362
+ return await self._proxy_to_dev_server(request)
363
+
364
+ def _get_dev_html_sync(self, page_url: str | None = None) -> str:
365
+ """Resolve dev HTML synchronously for SPA or hybrid modes.
366
+
367
+ Returns:
368
+ The HTML to serve in development.
369
+ """
370
+ if self._config.mode == "hybrid":
371
+ if self._cached_html is None:
372
+ self._load_index_html_sync()
373
+ base_html = self._cached_html or ""
374
+ request_url = page_url or "/"
375
+ try:
376
+ return self._transform_html_with_vite_sync(base_html, request_url)
377
+ except Exception as exc: # noqa: BLE001
378
+ logger.warning("Falling back to manual Vite script injection: %s", exc)
379
+ return self._inject_dev_scripts(base_html)
380
+ return self._proxy_to_dev_server_sync()
381
+
382
+ def _get_csrf_token(self, request: "Request[Any, Any, Any]") -> "str | None":
383
+ """Extract CSRF token from the request scope.
384
+
385
+ Args:
386
+ request: Incoming request.
387
+
388
+ Returns:
389
+ The CSRF token, or None if not present.
390
+ """
391
+ from litestar.utils.empty import value_or_default
392
+ from litestar.utils.scope.state import ScopeState
393
+
394
+ return value_or_default(ScopeState.from_scope(request.scope).csrf_token, None)
395
+
396
+ async def get_html(self, request: "Request[Any, Any, Any]", *, page_data: "dict[str, Any] | None" = None) -> str:
397
+ """Get the HTML for the SPA with optional transformations.
398
+
399
+ Args:
400
+ request: Incoming request.
401
+ page_data: Optional page data to inject (e.g., Inertia page props).
402
+
403
+ Returns:
404
+ The rendered HTML.
405
+
406
+ Raises:
407
+ ImproperlyConfiguredException: If the handler is not initialized.
408
+ """
409
+ if not self._initialized:
410
+ msg = "AppHandler not initialized. Call initialize() during app startup."
411
+ raise ImproperlyConfiguredException(msg)
412
+
413
+ needs_transform = self._spa_config is not None or page_data is not None
414
+ needs_csrf = self._spa_config is not None and self._spa_config.inject_csrf
415
+ csrf_token = self._get_csrf_token(request) if needs_csrf else None
416
+
417
+ if self._config.is_dev_mode and self._config.hot_reload:
418
+ html = await self._get_dev_html(request)
419
+ if needs_transform:
420
+ html = self._transform_html(html, page_data, csrf_token)
421
+ return html
422
+
423
+ if self._cached_html is None:
424
+ await self._load_index_html_async()
425
+
426
+ base_html = self._cached_html or ""
427
+
428
+ if not needs_transform:
429
+ return base_html
430
+
431
+ if page_data is not None or csrf_token is not None:
432
+ return self._transform_html(base_html, page_data, csrf_token)
433
+
434
+ if self._spa_config is not None and self._spa_config.cache_transformed_html:
435
+ if self._cached_transformed_html is not None:
436
+ return self._cached_transformed_html
437
+ self._cached_transformed_html = self._transform_html(base_html, None, None)
438
+ return self._cached_transformed_html
439
+
440
+ return self._transform_html(base_html, None, None)
441
+
442
+ def get_html_sync(self, *, page_data: "dict[str, Any] | None" = None, csrf_token: "str | None" = None) -> str:
443
+ """Get the HTML for the SPA synchronously.
444
+
445
+ Args:
446
+ page_data: Optional page data to inject (e.g., Inertia page props).
447
+ csrf_token: Optional CSRF token to inject.
448
+
449
+ Returns:
450
+ The rendered HTML.
451
+ """
452
+ if not self._initialized:
453
+ logger.warning(
454
+ "AppHandler lazy init triggered - lifespan may not have run. "
455
+ "Consider calling initialize_sync() explicitly during app startup."
456
+ )
457
+ self.initialize_sync()
458
+
459
+ needs_transform = self._spa_config is not None or page_data is not None
460
+ if not needs_transform:
461
+ if self._config.is_dev_mode and self._config.hot_reload:
462
+ return self._get_dev_html_sync()
463
+ return self._cached_html or ""
464
+
465
+ if self._config.is_dev_mode and self._config.hot_reload:
466
+ page_url = None
467
+ if page_data is not None:
468
+ url_value = page_data.get("url")
469
+ if isinstance(url_value, str) and url_value:
470
+ page_url = url_value
471
+ html = self._get_dev_html_sync(page_url)
472
+ return self._transform_html(html, page_data, csrf_token)
473
+
474
+ base_html = self._cached_html or ""
475
+ return self._transform_html(base_html, page_data, csrf_token)
476
+
477
+ async def get_bytes(self) -> bytes:
478
+ """Get cached index.html bytes (production).
479
+
480
+ Returns:
481
+ Cached HTML bytes. .
482
+ """
483
+ if not self._initialized:
484
+ logger.warning(
485
+ "AppHandler lazy init triggered - lifespan may not have run. "
486
+ "Consider calling initialize_sync() explicitly during app startup."
487
+ )
488
+ await self.initialize_async()
489
+
490
+ if self._cached_bytes is None:
491
+ await self._load_index_html_async()
492
+
493
+ return self._cached_bytes or b""
494
+
495
+ async def _proxy_to_dev_server(self, request: "Request[Any, Any, Any]") -> str:
496
+ """Proxy request to Vite dev server and return HTML.
497
+
498
+ Args:
499
+ request: Incoming request.
500
+
501
+ Returns:
502
+ HTML from the Vite dev server.
503
+
504
+ Raises:
505
+ ImproperlyConfiguredException: If the dev server cannot be reached or proxying is not configured.
506
+ """
507
+ if self._http_client is None:
508
+ msg = "HTTP client not initialized. Ensure initialize_async() was called for dev mode."
509
+ raise ImproperlyConfiguredException(msg)
510
+
511
+ if self._vite_url is None:
512
+ msg = "Vite URL not resolved. Ensure initialize_sync() or initialize_async() was called."
513
+ raise ImproperlyConfiguredException(msg)
514
+
515
+ target_url = f"{self._vite_url}/"
516
+
517
+ try:
518
+ response = await self._http_client.get(target_url, follow_redirects=True)
519
+ response.raise_for_status()
520
+ except httpx.HTTPError as e:
521
+ msg = f"Failed to proxy request to Vite server at {target_url}. Is the dev server running? Error: {e!s}"
522
+ raise ImproperlyConfiguredException(msg) from e
523
+ else:
524
+ return response.text
525
+
526
+ def _proxy_to_dev_server_sync(self) -> str:
527
+ """Proxy request to Vite dev server synchronously.
528
+
529
+ Returns:
530
+ HTML from the Vite dev server.
531
+
532
+ Raises:
533
+ ImproperlyConfiguredException: If the dev server cannot be reached or proxying is not configured.
534
+ """
535
+ if self._http_client_sync is None:
536
+ msg = "HTTP client not initialized. Ensure initialize_sync() was called for dev mode."
537
+ raise ImproperlyConfiguredException(msg)
538
+
539
+ if self._vite_url is None:
540
+ msg = "Vite URL not resolved. Ensure initialize_sync() or initialize_async() was called."
541
+ raise ImproperlyConfiguredException(msg)
542
+
543
+ target_url = f"{self._vite_url}/"
544
+
545
+ try:
546
+ response = self._http_client_sync.get(target_url, follow_redirects=True)
547
+ response.raise_for_status()
548
+ except httpx.HTTPError as e:
549
+ msg = f"Failed to proxy request to Vite server at {target_url}. Is the dev server running? Error: {e!s}"
550
+ raise ImproperlyConfiguredException(msg) from e
551
+ else:
552
+ return response.text
553
+
554
+ def _resolve_vite_url(self) -> str:
555
+ """Resolve the Vite server URL from hotfile or config.
556
+
557
+ Returns:
558
+ The base Vite URL without a trailing slash.
559
+ """
560
+ hotfile = self._config.bundle_dir / self._config.hot_file
561
+ if not hotfile.is_absolute():
562
+ hotfile = self._config.root_dir / hotfile
563
+
564
+ if hotfile.exists():
565
+ try:
566
+ url = hotfile.read_text().strip()
567
+ if url:
568
+ return url.rstrip("/")
569
+ except OSError:
570
+ pass
571
+
572
+ return f"{self._config.protocol}://{self._config.host}:{self._config.port}"
573
+
574
+ def create_route_handler(self) -> Any:
575
+ """Create a Litestar route handler for the SPA.
576
+
577
+ Returns:
578
+ A Litestar route handler suitable for registering on an application.
579
+ """
580
+ is_dev = self._config.is_dev_mode and self._config.hot_reload
581
+
582
+ opt: dict[str, Any] = {}
583
+ if self._config.exclude_static_from_auth:
584
+ opt["exclude_from_auth"] = True
585
+ opt["_vite_spa_handler"] = self
586
+
587
+ guards: "list[Guard] | None" = list(self._config.guards) if self._config.guards else None # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType,reportUnknownArgumentType]
588
+
589
+ asset_url = self._config.asset_url
590
+ spa_path = self._config.spa_path
591
+ effective_spa_path = spa_path if spa_path is not None else "/"
592
+ include_root = self._config.include_root_spa_paths
593
+
594
+ if effective_spa_path and effective_spa_path != "/":
595
+ base = effective_spa_path.rstrip("/")
596
+ paths: list[str] = [f"{base}/", f"{base}/{{path:path}}"]
597
+ if include_root:
598
+ paths.extend(["/", "/{path:path}"])
599
+ else:
600
+ paths = ["/", "/{path:path}"]
601
+
602
+ needs_exclusion = asset_url and asset_url != "/" and (effective_spa_path == "/" or include_root)
603
+ asset_prefix = asset_url.rstrip("/") if needs_exclusion else None
604
+ if asset_prefix:
605
+ opt["_vite_asset_prefix"] = asset_prefix
606
+
607
+ if is_dev:
608
+ return get(path=paths, name="vite_spa", opt=opt, include_in_schema=False, guards=guards)(spa_handler_dev)
609
+
610
+ return get(path=paths, name="vite_spa", opt=opt, include_in_schema=False, cache=3600, guards=guards)(
611
+ spa_handler_prod
612
+ )