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.
- litestar_vite/__init__.py +54 -4
- litestar_vite/__metadata__.py +12 -7
- litestar_vite/_codegen/__init__.py +26 -0
- litestar_vite/_codegen/inertia.py +407 -0
- litestar_vite/_codegen/openapi.py +233 -0
- litestar_vite/_codegen/routes.py +653 -0
- litestar_vite/_codegen/ts.py +235 -0
- litestar_vite/_handler/__init__.py +8 -0
- litestar_vite/_handler/app.py +524 -0
- litestar_vite/_handler/routing.py +130 -0
- litestar_vite/cli.py +1147 -10
- litestar_vite/codegen.py +39 -0
- litestar_vite/commands.py +79 -0
- litestar_vite/config.py +1594 -70
- litestar_vite/deploy.py +355 -0
- litestar_vite/doctor.py +1179 -0
- litestar_vite/exceptions.py +78 -0
- litestar_vite/executor.py +316 -0
- litestar_vite/handler.py +9 -0
- litestar_vite/html_transform.py +426 -0
- litestar_vite/inertia/__init__.py +53 -0
- litestar_vite/inertia/_utils.py +114 -0
- litestar_vite/inertia/exception_handler.py +172 -0
- litestar_vite/inertia/helpers.py +1043 -0
- litestar_vite/inertia/middleware.py +54 -0
- litestar_vite/inertia/plugin.py +133 -0
- litestar_vite/inertia/request.py +286 -0
- litestar_vite/inertia/response.py +706 -0
- litestar_vite/inertia/types.py +316 -0
- litestar_vite/loader.py +462 -121
- litestar_vite/plugin.py +2160 -21
- litestar_vite/py.typed +0 -0
- litestar_vite/scaffolding/__init__.py +20 -0
- litestar_vite/scaffolding/generator.py +270 -0
- litestar_vite/scaffolding/templates.py +437 -0
- litestar_vite/templates/__init__.py +0 -0
- litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
- litestar_vite/templates/angular/index.html.j2 +12 -0
- litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/angular/package.json.j2 +35 -0
- litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
- litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
- litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
- litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
- litestar_vite/templates/angular/src/main.ts.j2 +9 -0
- litestar_vite/templates/angular/src/styles.css.j2 +9 -0
- litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
- litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
- litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
- litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
- litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
- litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/angular-cli/package.json.j2 +27 -0
- litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
- litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
- litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
- litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
- litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
- litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
- litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
- litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
- litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
- litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
- litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
- litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
- litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
- litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
- litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
- litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
- litestar_vite/templates/base/.gitignore.j2 +42 -0
- litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/base/package.json.j2 +38 -0
- litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
- litestar_vite/templates/base/tsconfig.json.j2 +37 -0
- litestar_vite/templates/htmx/src/main.js.j2 +8 -0
- litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
- litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
- litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
- litestar_vite/templates/nuxt/app.vue.j2 +29 -0
- litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
- litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
- litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
- litestar_vite/templates/react/index.html.j2 +13 -0
- litestar_vite/templates/react/src/App.css.j2 +56 -0
- litestar_vite/templates/react/src/App.tsx.j2 +19 -0
- litestar_vite/templates/react/src/main.tsx.j2 +10 -0
- litestar_vite/templates/react/vite.config.ts.j2 +39 -0
- litestar_vite/templates/react-inertia/index.html.j2 +14 -0
- litestar_vite/templates/react-inertia/package.json.j2 +46 -0
- litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
- litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
- litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
- litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
- litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
- litestar_vite/templates/react-router/index.html.j2 +12 -0
- litestar_vite/templates/react-router/src/App.css.j2 +17 -0
- litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
- litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
- litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
- litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
- litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
- litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
- litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
- litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
- litestar_vite/templates/svelte/index.html.j2 +13 -0
- litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
- litestar_vite/templates/svelte/src/app.css.j2 +45 -0
- litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
- litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
- litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
- litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
- litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
- litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
- litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
- litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
- litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
- litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
- litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
- litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
- litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
- litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
- litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
- litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
- litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
- litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
- litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
- litestar_vite/templates/vue/env.d.ts.j2 +7 -0
- litestar_vite/templates/vue/index.html.j2 +13 -0
- litestar_vite/templates/vue/src/App.vue.j2 +28 -0
- litestar_vite/templates/vue/src/main.ts.j2 +5 -0
- litestar_vite/templates/vue/src/style.css.j2 +45 -0
- litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
- litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
- litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
- litestar_vite/templates/vue-inertia/package.json.j2 +49 -0
- litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
- litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
- litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
- litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
- litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
- litestar_vite-0.15.0rc2.dist-info/METADATA +230 -0
- litestar_vite-0.15.0rc2.dist-info/RECORD +151 -0
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/WHEEL +1 -1
- litestar_vite/template_engine.py +0 -103
- litestar_vite-0.1.1.dist-info/METADATA +0 -68
- litestar_vite-0.1.1.dist-info/RECORD +0 -11
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,524 @@
|
|
|
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._handler.routing import spa_handler_dev, spa_handler_prod
|
|
22
|
+
from litestar_vite.html_transform import inject_head_script, set_data_attribute, transform_asset_urls
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from litestar.connection import Request
|
|
26
|
+
from litestar.types import Guard # pyright: ignore[reportUnknownVariableType]
|
|
27
|
+
|
|
28
|
+
from litestar_vite.config import SPAConfig, ViteConfig
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("litestar_vite")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AppHandler:
|
|
34
|
+
"""Handler for serving SPA HTML in both dev and production modes."""
|
|
35
|
+
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"_cached_bytes",
|
|
38
|
+
"_cached_html",
|
|
39
|
+
"_cached_transformed_html",
|
|
40
|
+
"_config",
|
|
41
|
+
"_http_client",
|
|
42
|
+
"_http_client_sync",
|
|
43
|
+
"_initialized",
|
|
44
|
+
"_manifest",
|
|
45
|
+
"_spa_config",
|
|
46
|
+
"_vite_url",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: "ViteConfig") -> None:
|
|
50
|
+
"""Initialize the SPA handler.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config: The Vite configuration.
|
|
54
|
+
"""
|
|
55
|
+
self._config = config
|
|
56
|
+
self._spa_config: "SPAConfig | None" = config.spa_config
|
|
57
|
+
self._cached_html: "str | None" = None
|
|
58
|
+
self._cached_bytes: "bytes | None" = None
|
|
59
|
+
self._cached_transformed_html: "str | None" = None
|
|
60
|
+
self._initialized = False
|
|
61
|
+
self._http_client: "httpx.AsyncClient | None" = None
|
|
62
|
+
self._http_client_sync: "httpx.Client | None" = None
|
|
63
|
+
self._vite_url: "str | None" = None
|
|
64
|
+
self._manifest: "dict[str, Any]" = {}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_initialized(self) -> bool:
|
|
68
|
+
"""Whether the handler has been initialized.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True when initialized, otherwise False.
|
|
72
|
+
"""
|
|
73
|
+
return self._initialized
|
|
74
|
+
|
|
75
|
+
async def initialize_async(self, vite_url: "str | None" = None) -> None:
|
|
76
|
+
"""Initialize the handler asynchronously.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
vite_url: Optional Vite server URL to use for proxying.
|
|
80
|
+
"""
|
|
81
|
+
if self._initialized:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if self._config.is_dev_mode and self._config.hot_reload:
|
|
85
|
+
self._init_http_clients(vite_url)
|
|
86
|
+
else:
|
|
87
|
+
await self._load_production_assets_async()
|
|
88
|
+
|
|
89
|
+
self._initialized = True
|
|
90
|
+
|
|
91
|
+
def initialize_sync(self, vite_url: "str | None" = None) -> None:
|
|
92
|
+
"""Initialize the handler synchronously.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
vite_url: Optional Vite server URL to use for proxying.
|
|
96
|
+
"""
|
|
97
|
+
if self._initialized:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if self._config.is_dev_mode and self._config.hot_reload:
|
|
101
|
+
self._init_http_clients(vite_url)
|
|
102
|
+
else:
|
|
103
|
+
self._load_production_assets_sync()
|
|
104
|
+
|
|
105
|
+
self._initialized = True
|
|
106
|
+
|
|
107
|
+
def _init_http_clients(self, vite_url: "str | None" = None) -> None:
|
|
108
|
+
"""Initialize HTTP clients for dev mode proxying."""
|
|
109
|
+
self._vite_url = vite_url or self._resolve_vite_url()
|
|
110
|
+
|
|
111
|
+
http2_enabled = self._config.http2
|
|
112
|
+
if http2_enabled:
|
|
113
|
+
try:
|
|
114
|
+
import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
|
|
115
|
+
except ImportError:
|
|
116
|
+
http2_enabled = False
|
|
117
|
+
|
|
118
|
+
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(5.0), http2=http2_enabled)
|
|
119
|
+
self._http_client_sync = httpx.Client(timeout=httpx.Timeout(5.0))
|
|
120
|
+
|
|
121
|
+
async def shutdown_async(self) -> None:
|
|
122
|
+
"""Shutdown the handler asynchronously."""
|
|
123
|
+
if self._http_client is not None:
|
|
124
|
+
with suppress(RuntimeError):
|
|
125
|
+
await self._http_client.aclose()
|
|
126
|
+
self._http_client = None
|
|
127
|
+
if self._http_client_sync is not None:
|
|
128
|
+
with suppress(RuntimeError):
|
|
129
|
+
self._http_client_sync.close()
|
|
130
|
+
self._http_client_sync = None
|
|
131
|
+
|
|
132
|
+
def _load_production_assets_sync(self) -> None:
|
|
133
|
+
"""Load manifest and index.html synchronously in production modes."""
|
|
134
|
+
if self._config.mode != "external":
|
|
135
|
+
self._load_manifest_sync()
|
|
136
|
+
self._load_index_html_sync()
|
|
137
|
+
|
|
138
|
+
async def _load_production_assets_async(self) -> None:
|
|
139
|
+
"""Load manifest and index.html asynchronously in production modes."""
|
|
140
|
+
if self._config.mode != "external":
|
|
141
|
+
await self._load_manifest_async()
|
|
142
|
+
await self._load_index_html_async()
|
|
143
|
+
|
|
144
|
+
def _transform_html(
|
|
145
|
+
self, html: str, page_data: "dict[str, Any] | None" = None, csrf_token: "str | None" = None
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Transform HTML by injecting CSRF token and/or page data.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The transformed HTML.
|
|
151
|
+
"""
|
|
152
|
+
if self._spa_config is None:
|
|
153
|
+
if page_data is not None:
|
|
154
|
+
json_data = encode_json(page_data).decode("utf-8")
|
|
155
|
+
html = set_data_attribute(html, "#app", "data-page", json_data)
|
|
156
|
+
return html
|
|
157
|
+
|
|
158
|
+
if self._spa_config.inject_csrf and csrf_token:
|
|
159
|
+
script = f'window.{self._spa_config.csrf_var_name} = "{csrf_token}";'
|
|
160
|
+
html = inject_head_script(html, script, escape=False)
|
|
161
|
+
|
|
162
|
+
if page_data is not None:
|
|
163
|
+
json_data = encode_json(page_data).decode("utf-8")
|
|
164
|
+
html = set_data_attribute(html, self._spa_config.app_selector, "data-page", json_data)
|
|
165
|
+
|
|
166
|
+
return html
|
|
167
|
+
|
|
168
|
+
async def _load_index_html_async(self) -> None:
|
|
169
|
+
"""Load and cache index.html asynchronously.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ImproperlyConfiguredException: If index.html cannot be located.
|
|
173
|
+
"""
|
|
174
|
+
resolved_path: Path | None = None
|
|
175
|
+
for candidate in self._config.candidate_index_html_paths():
|
|
176
|
+
candidate_path = Path(candidate)
|
|
177
|
+
if candidate_path.exists():
|
|
178
|
+
resolved_path = candidate_path
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if resolved_path is None:
|
|
182
|
+
self._raise_index_not_found()
|
|
183
|
+
|
|
184
|
+
raw_bytes = await anyio.Path(resolved_path).read_bytes()
|
|
185
|
+
html = raw_bytes.decode("utf-8")
|
|
186
|
+
html = self._transform_asset_urls_in_html(html)
|
|
187
|
+
|
|
188
|
+
self._cached_html = html
|
|
189
|
+
self._cached_bytes = html.encode("utf-8")
|
|
190
|
+
|
|
191
|
+
def _load_index_html_sync(self) -> None:
|
|
192
|
+
"""Load and cache index.html synchronously.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ImproperlyConfiguredException: If index.html cannot be located.
|
|
196
|
+
"""
|
|
197
|
+
resolved_path: Path | None = None
|
|
198
|
+
for candidate in self._config.candidate_index_html_paths():
|
|
199
|
+
candidate_path = Path(candidate)
|
|
200
|
+
if candidate_path.exists():
|
|
201
|
+
resolved_path = candidate_path
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
if resolved_path is None:
|
|
205
|
+
self._raise_index_not_found()
|
|
206
|
+
|
|
207
|
+
raw_bytes = resolved_path.read_bytes()
|
|
208
|
+
html = raw_bytes.decode("utf-8")
|
|
209
|
+
html = self._transform_asset_urls_in_html(html)
|
|
210
|
+
|
|
211
|
+
self._cached_html = html
|
|
212
|
+
self._cached_bytes = html.encode("utf-8")
|
|
213
|
+
|
|
214
|
+
def _raise_index_not_found(self) -> NoReturn:
|
|
215
|
+
"""Raise an exception when index.html is not found.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ImproperlyConfiguredException: Always raised.
|
|
219
|
+
"""
|
|
220
|
+
joined_paths = ", ".join(str(path) for path in self._config.candidate_index_html_paths())
|
|
221
|
+
msg = (
|
|
222
|
+
"index.html not found. "
|
|
223
|
+
f"Checked: {joined_paths}. "
|
|
224
|
+
"SPA mode requires index.html in one of the expected locations. "
|
|
225
|
+
"Did you forget to build your assets?"
|
|
226
|
+
)
|
|
227
|
+
raise ImproperlyConfiguredException(msg)
|
|
228
|
+
|
|
229
|
+
def _get_manifest_path(self) -> Path:
|
|
230
|
+
"""Get the path to the Vite manifest file.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Absolute path to the manifest file location.
|
|
234
|
+
"""
|
|
235
|
+
bundle_dir = self._config.bundle_dir
|
|
236
|
+
if not bundle_dir.is_absolute():
|
|
237
|
+
bundle_dir = self._config.root_dir / bundle_dir
|
|
238
|
+
return bundle_dir / self._config.manifest_name
|
|
239
|
+
|
|
240
|
+
async def _load_manifest_async(self) -> None:
|
|
241
|
+
"""Asynchronously load the Vite manifest for asset URL transformation."""
|
|
242
|
+
manifest_path = anyio.Path(self._get_manifest_path())
|
|
243
|
+
try:
|
|
244
|
+
if await manifest_path.exists():
|
|
245
|
+
content = await manifest_path.read_bytes()
|
|
246
|
+
self._manifest = decode_json(content)
|
|
247
|
+
else:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Vite manifest not found at %s. "
|
|
250
|
+
"Asset URLs in index.html will not be transformed. "
|
|
251
|
+
"Run 'litestar assets build' to generate the manifest.",
|
|
252
|
+
manifest_path,
|
|
253
|
+
)
|
|
254
|
+
except OSError as exc:
|
|
255
|
+
logger.warning("Failed to read Vite manifest file: %s", exc)
|
|
256
|
+
except SerializationException as exc:
|
|
257
|
+
logger.warning("Failed to parse Vite manifest JSON: %s", exc)
|
|
258
|
+
|
|
259
|
+
def _load_manifest_sync(self) -> None:
|
|
260
|
+
"""Synchronously load the Vite manifest for asset URL transformation."""
|
|
261
|
+
manifest_path = self._get_manifest_path()
|
|
262
|
+
try:
|
|
263
|
+
if manifest_path.exists():
|
|
264
|
+
content = manifest_path.read_bytes()
|
|
265
|
+
self._manifest = decode_json(content)
|
|
266
|
+
else:
|
|
267
|
+
logger.warning(
|
|
268
|
+
"Vite manifest not found at %s. "
|
|
269
|
+
"Asset URLs in index.html will not be transformed. "
|
|
270
|
+
"Run 'litestar assets build' to generate the manifest.",
|
|
271
|
+
manifest_path,
|
|
272
|
+
)
|
|
273
|
+
except OSError as exc:
|
|
274
|
+
logger.warning("Failed to read Vite manifest file: %s", exc)
|
|
275
|
+
except SerializationException as exc:
|
|
276
|
+
logger.warning("Failed to parse Vite manifest JSON: %s", exc)
|
|
277
|
+
|
|
278
|
+
def _transform_asset_urls_in_html(self, html: str) -> str:
|
|
279
|
+
"""Transform source asset URLs to production hashed URLs using manifest.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
html: The HTML to transform.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The transformed HTML (or original HTML when no manifest is loaded).
|
|
286
|
+
"""
|
|
287
|
+
if not self._manifest:
|
|
288
|
+
return html
|
|
289
|
+
return transform_asset_urls(
|
|
290
|
+
html, self._manifest, asset_url=self._config.asset_url, base_url=self._config.base_url
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _get_csrf_token(self, request: "Request[Any, Any, Any]") -> "str | None":
|
|
294
|
+
"""Extract CSRF token from the request scope.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
request: Incoming request.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
The CSRF token, or None if not present.
|
|
301
|
+
"""
|
|
302
|
+
from litestar.utils.empty import value_or_default
|
|
303
|
+
from litestar.utils.scope.state import ScopeState
|
|
304
|
+
|
|
305
|
+
return value_or_default(ScopeState.from_scope(request.scope).csrf_token, None)
|
|
306
|
+
|
|
307
|
+
async def get_html(self, request: "Request[Any, Any, Any]", *, page_data: "dict[str, Any] | None" = None) -> str:
|
|
308
|
+
"""Get the HTML for the SPA with optional transformations.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
request: Incoming request.
|
|
312
|
+
page_data: Optional page data to inject (e.g., Inertia page props).
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
The rendered HTML.
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
ImproperlyConfiguredException: If the handler is not initialized.
|
|
319
|
+
"""
|
|
320
|
+
if not self._initialized:
|
|
321
|
+
msg = "AppHandler not initialized. Call initialize() during app startup."
|
|
322
|
+
raise ImproperlyConfiguredException(msg)
|
|
323
|
+
|
|
324
|
+
needs_transform = self._spa_config is not None or page_data is not None
|
|
325
|
+
needs_csrf = self._spa_config is not None and self._spa_config.inject_csrf
|
|
326
|
+
csrf_token = self._get_csrf_token(request) if needs_csrf else None
|
|
327
|
+
|
|
328
|
+
if self._config.is_dev_mode and self._config.hot_reload:
|
|
329
|
+
html = await self._proxy_to_dev_server(request)
|
|
330
|
+
if needs_transform:
|
|
331
|
+
html = self._transform_html(html, page_data, csrf_token)
|
|
332
|
+
return html
|
|
333
|
+
|
|
334
|
+
if self._cached_html is None:
|
|
335
|
+
await self._load_index_html_async()
|
|
336
|
+
|
|
337
|
+
base_html = self._cached_html or ""
|
|
338
|
+
|
|
339
|
+
if not needs_transform:
|
|
340
|
+
return base_html
|
|
341
|
+
|
|
342
|
+
if page_data is not None or csrf_token is not None:
|
|
343
|
+
return self._transform_html(base_html, page_data, csrf_token)
|
|
344
|
+
|
|
345
|
+
if self._spa_config is not None and self._spa_config.cache_transformed_html:
|
|
346
|
+
if self._cached_transformed_html is not None:
|
|
347
|
+
return self._cached_transformed_html
|
|
348
|
+
self._cached_transformed_html = self._transform_html(base_html, None, None)
|
|
349
|
+
return self._cached_transformed_html
|
|
350
|
+
|
|
351
|
+
return self._transform_html(base_html, None, None)
|
|
352
|
+
|
|
353
|
+
def get_html_sync(self, *, page_data: "dict[str, Any] | None" = None, csrf_token: "str | None" = None) -> str:
|
|
354
|
+
"""Get the HTML for the SPA synchronously.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
page_data: Optional page data to inject (e.g., Inertia page props).
|
|
358
|
+
csrf_token: Optional CSRF token to inject.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The rendered HTML.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
ImproperlyConfiguredException: If dev-mode HTTP clients are not initialized or Vite URL is unresolved.
|
|
365
|
+
"""
|
|
366
|
+
if not self._initialized:
|
|
367
|
+
logger.warning(
|
|
368
|
+
"AppHandler lazy init triggered - lifespan may not have run. "
|
|
369
|
+
"Consider calling initialize_sync() explicitly during app startup."
|
|
370
|
+
)
|
|
371
|
+
self.initialize_sync()
|
|
372
|
+
|
|
373
|
+
needs_transform = self._spa_config is not None or page_data is not None
|
|
374
|
+
if not needs_transform:
|
|
375
|
+
if self._config.is_dev_mode and self._config.hot_reload:
|
|
376
|
+
return self._proxy_to_dev_server_sync()
|
|
377
|
+
return self._cached_html or ""
|
|
378
|
+
|
|
379
|
+
if self._config.is_dev_mode and self._config.hot_reload:
|
|
380
|
+
html = self._proxy_to_dev_server_sync()
|
|
381
|
+
return self._transform_html(html, page_data, csrf_token)
|
|
382
|
+
|
|
383
|
+
base_html = self._cached_html or ""
|
|
384
|
+
return self._transform_html(base_html, page_data, csrf_token)
|
|
385
|
+
|
|
386
|
+
async def get_bytes(self) -> bytes:
|
|
387
|
+
"""Get cached index.html bytes (production).
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Cached HTML bytes.
|
|
391
|
+
|
|
392
|
+
Raises:
|
|
393
|
+
ImproperlyConfiguredException: If index.html cannot be located.
|
|
394
|
+
"""
|
|
395
|
+
if not self._initialized:
|
|
396
|
+
logger.warning(
|
|
397
|
+
"AppHandler lazy init triggered - lifespan may not have run. "
|
|
398
|
+
"Consider calling initialize_sync() explicitly during app startup."
|
|
399
|
+
)
|
|
400
|
+
await self.initialize_async()
|
|
401
|
+
|
|
402
|
+
if self._cached_bytes is None:
|
|
403
|
+
await self._load_index_html_async()
|
|
404
|
+
|
|
405
|
+
return self._cached_bytes or b""
|
|
406
|
+
|
|
407
|
+
async def _proxy_to_dev_server(self, request: "Request[Any, Any, Any]") -> str:
|
|
408
|
+
"""Proxy request to Vite dev server and return HTML.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
request: Incoming request.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
HTML from the Vite dev server.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ImproperlyConfiguredException: If the dev server cannot be reached or proxying is not configured.
|
|
418
|
+
"""
|
|
419
|
+
if self._http_client is None:
|
|
420
|
+
msg = "HTTP client not initialized. Ensure initialize_async() was called for dev mode."
|
|
421
|
+
raise ImproperlyConfiguredException(msg)
|
|
422
|
+
|
|
423
|
+
if self._vite_url is None:
|
|
424
|
+
msg = "Vite URL not resolved. Ensure initialize_sync() or initialize_async() was called."
|
|
425
|
+
raise ImproperlyConfiguredException(msg)
|
|
426
|
+
|
|
427
|
+
target_url = f"{self._vite_url}/"
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
response = await self._http_client.get(target_url, follow_redirects=True)
|
|
431
|
+
response.raise_for_status()
|
|
432
|
+
except httpx.HTTPError as e:
|
|
433
|
+
msg = f"Failed to proxy request to Vite server at {target_url}. Is the dev server running? Error: {e!s}"
|
|
434
|
+
raise ImproperlyConfiguredException(msg) from e
|
|
435
|
+
else:
|
|
436
|
+
return response.text
|
|
437
|
+
|
|
438
|
+
def _proxy_to_dev_server_sync(self) -> str:
|
|
439
|
+
"""Proxy request to Vite dev server synchronously.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
HTML from the Vite dev server.
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
ImproperlyConfiguredException: If the dev server cannot be reached or proxying is not configured.
|
|
446
|
+
"""
|
|
447
|
+
if self._http_client_sync is None:
|
|
448
|
+
msg = "HTTP client not initialized. Ensure initialize_sync() was called for dev mode."
|
|
449
|
+
raise ImproperlyConfiguredException(msg)
|
|
450
|
+
|
|
451
|
+
if self._vite_url is None:
|
|
452
|
+
msg = "Vite URL not resolved. Ensure initialize_sync() or initialize_async() was called."
|
|
453
|
+
raise ImproperlyConfiguredException(msg)
|
|
454
|
+
|
|
455
|
+
target_url = f"{self._vite_url}/"
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
response = self._http_client_sync.get(target_url, follow_redirects=True)
|
|
459
|
+
response.raise_for_status()
|
|
460
|
+
except httpx.HTTPError as e:
|
|
461
|
+
msg = f"Failed to proxy request to Vite server at {target_url}. Is the dev server running? Error: {e!s}"
|
|
462
|
+
raise ImproperlyConfiguredException(msg) from e
|
|
463
|
+
else:
|
|
464
|
+
return response.text
|
|
465
|
+
|
|
466
|
+
def _resolve_vite_url(self) -> str:
|
|
467
|
+
"""Resolve the Vite server URL from hotfile or config.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
The base Vite URL without a trailing slash.
|
|
471
|
+
"""
|
|
472
|
+
hotfile = self._config.bundle_dir / self._config.hot_file
|
|
473
|
+
if not hotfile.is_absolute():
|
|
474
|
+
hotfile = self._config.root_dir / hotfile
|
|
475
|
+
|
|
476
|
+
if hotfile.exists():
|
|
477
|
+
try:
|
|
478
|
+
url = hotfile.read_text().strip()
|
|
479
|
+
if url:
|
|
480
|
+
return url.rstrip("/")
|
|
481
|
+
except OSError:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
return f"{self._config.protocol}://{self._config.host}:{self._config.port}"
|
|
485
|
+
|
|
486
|
+
def create_route_handler(self) -> Any:
|
|
487
|
+
"""Create a Litestar route handler for the SPA.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
A Litestar route handler suitable for registering on an application.
|
|
491
|
+
"""
|
|
492
|
+
is_dev = self._config.is_dev_mode and self._config.hot_reload
|
|
493
|
+
|
|
494
|
+
opt: dict[str, Any] = {}
|
|
495
|
+
if self._config.exclude_static_from_auth:
|
|
496
|
+
opt["exclude_from_auth"] = True
|
|
497
|
+
opt["_vite_spa_handler"] = self
|
|
498
|
+
|
|
499
|
+
guards: "list[Guard] | None" = list(self._config.guards) if self._config.guards else None # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType,reportUnknownArgumentType]
|
|
500
|
+
|
|
501
|
+
asset_url = self._config.asset_url
|
|
502
|
+
spa_path = self._config.spa_path
|
|
503
|
+
effective_spa_path = spa_path if spa_path is not None else "/"
|
|
504
|
+
include_root = self._config.include_root_spa_paths
|
|
505
|
+
|
|
506
|
+
if effective_spa_path and effective_spa_path != "/":
|
|
507
|
+
base = effective_spa_path.rstrip("/")
|
|
508
|
+
paths: list[str] = [f"{base}/", f"{base}/{{path:path}}"]
|
|
509
|
+
if include_root:
|
|
510
|
+
paths.extend(["/", "/{path:path}"])
|
|
511
|
+
else:
|
|
512
|
+
paths = ["/", "/{path:path}"]
|
|
513
|
+
|
|
514
|
+
needs_exclusion = asset_url and asset_url != "/" and (effective_spa_path == "/" or include_root)
|
|
515
|
+
asset_prefix = asset_url.rstrip("/") if needs_exclusion else None
|
|
516
|
+
if asset_prefix:
|
|
517
|
+
opt["_vite_asset_prefix"] = asset_prefix
|
|
518
|
+
|
|
519
|
+
if is_dev:
|
|
520
|
+
return get(path=paths, name="vite_spa", opt=opt, include_in_schema=False, guards=guards)(spa_handler_dev)
|
|
521
|
+
|
|
522
|
+
return get(path=paths, name="vite_spa", opt=opt, include_in_schema=False, cache=3600, guards=guards)(
|
|
523
|
+
spa_handler_prod
|
|
524
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""SPA route handlers and routing helpers."""
|
|
2
|
+
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
from litestar import Response
|
|
7
|
+
from litestar.exceptions import ImproperlyConfiguredException, NotFoundException
|
|
8
|
+
|
|
9
|
+
from litestar_vite.plugin import is_litestar_route
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from litestar.connection import Request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_HTML_MEDIA_TYPE = "text/html; charset=utf-8"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_static_asset_path(request_path: str, asset_prefix: str | None) -> bool:
|
|
19
|
+
"""Check if a request path targets static assets rather than SPA routes.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
request_path: Incoming request path.
|
|
23
|
+
asset_prefix: Normalized asset URL prefix (e.g., ``/static``) or None.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True when ``request_path`` matches the asset prefix (or a descendant path), otherwise False.
|
|
27
|
+
"""
|
|
28
|
+
if not asset_prefix:
|
|
29
|
+
return False
|
|
30
|
+
return request_path == asset_prefix or request_path.startswith(f"{asset_prefix}/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_route_opt(request: "Request[Any, Any, Any]") -> "dict[str, Any] | None":
|
|
34
|
+
"""Return the current route handler opt dict when available.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The route handler ``opt`` mapping, or None if unavailable.
|
|
38
|
+
"""
|
|
39
|
+
route_handler = request.scope.get("route_handler") # pyright: ignore[reportUnknownMemberType]
|
|
40
|
+
with suppress(AttributeError):
|
|
41
|
+
opt_any = cast("Any", route_handler).opt
|
|
42
|
+
return cast("dict[str, Any] | None", opt_any)
|
|
43
|
+
return None # pragma: no cover
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_route_asset_prefix(request: "Request[Any, Any, Any]") -> str | None:
|
|
47
|
+
"""Get the static asset prefix for the current SPA route handler.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The asset URL prefix for this SPA route, or None if not configured.
|
|
51
|
+
"""
|
|
52
|
+
opt = get_route_opt(request)
|
|
53
|
+
if opt is None:
|
|
54
|
+
return None
|
|
55
|
+
asset_prefix = opt.get("_vite_asset_prefix")
|
|
56
|
+
if isinstance(asset_prefix, str) and asset_prefix:
|
|
57
|
+
return asset_prefix
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_spa_handler_from_request(request: "Request[Any, Any, Any]") -> Any:
|
|
62
|
+
"""Resolve the SPA handler instance for the current request.
|
|
63
|
+
|
|
64
|
+
This is stored on the SPA route handler's ``opt`` when the route is created.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
request: Incoming request.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The configured SPA handler instance.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ImproperlyConfiguredException: If the SPA handler is not available on the route metadata.
|
|
74
|
+
"""
|
|
75
|
+
opt = get_route_opt(request)
|
|
76
|
+
handler = opt.get("_vite_spa_handler") if opt is not None else None
|
|
77
|
+
if handler is not None:
|
|
78
|
+
try:
|
|
79
|
+
_ = handler.get_html
|
|
80
|
+
_ = handler.get_bytes
|
|
81
|
+
except AttributeError:
|
|
82
|
+
pass
|
|
83
|
+
else:
|
|
84
|
+
return handler
|
|
85
|
+
msg = "SPA handler is not available for this route. Ensure AppHandler.create_route_handler() was used."
|
|
86
|
+
raise ImproperlyConfiguredException(msg)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def spa_handler_dev(request: "Request[Any, Any, Any]") -> Response[str]:
|
|
90
|
+
"""Serve the SPA HTML (dev mode - proxied from Vite).
|
|
91
|
+
|
|
92
|
+
Checks if the request path matches a static asset or Litestar route before serving.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
NotFoundException: If the path matches a static asset or Litestar route.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The HTML response from the Vite dev server.
|
|
99
|
+
"""
|
|
100
|
+
path = request.url.path
|
|
101
|
+
asset_prefix = get_route_asset_prefix(request)
|
|
102
|
+
if is_static_asset_path(path, asset_prefix):
|
|
103
|
+
raise NotFoundException(detail=f"Static asset path: {path}")
|
|
104
|
+
if path != "/" and is_litestar_route(path, request.app):
|
|
105
|
+
raise NotFoundException(detail=f"Not an SPA route: {path}")
|
|
106
|
+
|
|
107
|
+
spa_handler = get_spa_handler_from_request(request)
|
|
108
|
+
html = await spa_handler.get_html(request)
|
|
109
|
+
return Response(content=html, status_code=200, media_type="text/html")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def spa_handler_prod(request: "Request[Any, Any, Any]") -> Response[bytes]:
|
|
113
|
+
"""Serve the SPA HTML (production - cached).
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
NotFoundException: If the path matches a static asset or Litestar route.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
HTML bytes response from the cached SPA handler.
|
|
120
|
+
"""
|
|
121
|
+
path = request.url.path
|
|
122
|
+
asset_prefix = get_route_asset_prefix(request)
|
|
123
|
+
if is_static_asset_path(path, asset_prefix):
|
|
124
|
+
raise NotFoundException(detail=f"Static asset path: {path}")
|
|
125
|
+
if path != "/" and is_litestar_route(path, request.app):
|
|
126
|
+
raise NotFoundException(detail=f"Not an SPA route: {path}")
|
|
127
|
+
|
|
128
|
+
spa_handler = get_spa_handler_from_request(request)
|
|
129
|
+
body = await spa_handler.get_bytes()
|
|
130
|
+
return Response(content=body, status_code=200, media_type=_HTML_MEDIA_TYPE)
|