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
litestar_vite/plugin.py CHANGED
@@ -1,45 +1,2184 @@
1
- from __future__ import annotations
1
+ """Vite Plugin for Litestar.
2
2
 
3
- from typing import TYPE_CHECKING
3
+ This module provides the VitePlugin class for integrating Vite with Litestar.
4
+ The plugin handles:
4
5
 
5
- from litesatr_vite.config import ViteTemplateConfig
6
- from litesatr_vite.template_engine import ViteTemplateEngine
7
- from litestar.plugins import CLIPluginProtocol, InitPluginProtocol
6
+ - Static file serving configuration
7
+ - Jinja2 template callable registration
8
+ - Vite dev server process management
9
+ - Async asset loader initialization
10
+ - Development proxies for Vite HTTP and HMR WebSockets (with hop-by-hop header filtering)
11
+
12
+ Example::
13
+
14
+ from litestar import Litestar
15
+ from litestar_vite import VitePlugin, ViteConfig
16
+
17
+ app = Litestar(
18
+ plugins=[VitePlugin(config=ViteConfig(dev_mode=True))],
19
+ )
20
+ """
21
+
22
+ import importlib.metadata
23
+ import logging
24
+ import os
25
+ import signal
26
+ import subprocess
27
+ import sys
28
+ import threading
29
+ from contextlib import asynccontextmanager, contextmanager, suppress
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING, Any, Protocol, cast
33
+
34
+ import anyio
35
+ import httpx
36
+ import websockets
37
+ from litestar import Response
38
+ from litestar.enums import ScopeType
39
+ from litestar.exceptions import NotFoundException, WebSocketDisconnect
40
+ from litestar.middleware import AbstractMiddleware, DefineMiddleware
41
+ from litestar.plugins import CLIPlugin, InitPluginProtocol
42
+ from litestar.static_files import create_static_files_router # pyright: ignore[reportUnknownVariableType]
43
+ from rich.console import Console
44
+ from websockets.typing import Subprotocol
45
+
46
+ from litestar_vite.config import JINJA_INSTALLED, TRUE_VALUES, ExternalDevServer, TypeGenConfig, ViteConfig
47
+ from litestar_vite.exceptions import ViteProcessError
48
+ from litestar_vite.loader import ViteAssetLoader
8
49
 
9
50
  if TYPE_CHECKING:
51
+ from collections.abc import AsyncIterator, Callable, Iterator, Sequence
52
+
10
53
  from click import Group
11
- from litesatr_vite.config import ViteConfig
54
+ from litestar import Litestar
12
55
  from litestar.config.app import AppConfig
56
+ from litestar.connection import Request
57
+ from litestar.datastructures import CacheControlHeader
58
+ from litestar.openapi.spec import SecurityRequirement
59
+ from litestar.types import (
60
+ AfterRequestHookHandler, # pyright: ignore[reportUnknownVariableType]
61
+ AfterResponseHookHandler, # pyright: ignore[reportUnknownVariableType]
62
+ ASGIApp,
63
+ BeforeRequestHookHandler, # pyright: ignore[reportUnknownVariableType]
64
+ ExceptionHandlersMap,
65
+ Guard, # pyright: ignore[reportUnknownVariableType]
66
+ Middleware,
67
+ Receive,
68
+ Scope,
69
+ Send,
70
+ )
71
+ from websockets.typing import Subprotocol
72
+
73
+ from litestar_vite._handler import AppHandler
74
+ from litestar_vite.executor import JSExecutor
75
+
76
+ _DISCONNECT_EXCEPTIONS = (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed)
77
+ _TICK = "[bold green]✓[/]"
78
+ _INFO = "[cyan]•[/]"
79
+ _WARN = "[yellow]![/]"
80
+ _FAIL = "[red]x[/]"
81
+
82
+ console = Console()
83
+
84
+
85
+ def _fmt_path(path: Path) -> str:
86
+ """Return a path relative to CWD when possible to keep logs short.
87
+
88
+ Returns:
89
+ The relative path string when possible, otherwise the absolute path string.
90
+ """
91
+ try:
92
+ return str(path.relative_to(Path.cwd()))
93
+ except ValueError:
94
+ return str(path)
95
+
96
+
97
+ def _write_if_changed(path: Path, content: bytes | str, encoding: str = "utf-8") -> bool:
98
+ """Write content to file only if it differs from the existing content.
99
+
100
+ Uses hash comparison to avoid unnecessary writes that would trigger
101
+ file watchers and unnecessary rebuilds.
102
+
103
+ Args:
104
+ path: The file path to write to.
105
+ content: The content to write (bytes or str).
106
+ encoding: Encoding for string content.
107
+
108
+ Returns:
109
+ True if file was written (content changed), False if skipped (unchanged).
110
+ """
111
+ import hashlib
112
+
113
+ content_bytes = content.encode(encoding) if isinstance(content, str) else content
114
+
115
+ if path.exists():
116
+ try:
117
+ existing_hash = hashlib.md5(path.read_bytes()).hexdigest() # noqa: S324
118
+ new_hash = hashlib.md5(content_bytes).hexdigest() # noqa: S324
119
+ if existing_hash == new_hash:
120
+ return False
121
+ except OSError:
122
+ pass
123
+
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+ if isinstance(content, str):
126
+ path.write_text(content, encoding=encoding)
127
+ else:
128
+ path.write_bytes(content)
129
+ return True
130
+
131
+
132
+ _vite_proxy_debug: bool | None = None
133
+
134
+
135
+ def _is_proxy_debug() -> bool:
136
+ """Check if VITE_PROXY_DEBUG is enabled (cached).
137
+
138
+ Returns:
139
+ True if VITE_PROXY_DEBUG is set to a truthy value, else False.
140
+ """
141
+ global _vite_proxy_debug # noqa: PLW0603
142
+ if _vite_proxy_debug is None:
143
+ _vite_proxy_debug = os.environ.get("VITE_PROXY_DEBUG", "").lower() in {"1", "true", "yes"}
144
+ return _vite_proxy_debug
145
+
146
+
147
+ def _configure_proxy_logging() -> None:
148
+ """Suppress verbose proxy-related logging unless debug is enabled.
149
+
150
+ Suppresses INFO-level logs from:
151
+ - httpx: logs every HTTP request
152
+ - websockets: logs connection events
153
+ - uvicorn.protocols.websockets: logs "connection open/closed"
154
+
155
+ Only show these logs when VITE_PROXY_DEBUG is enabled.
156
+ """
157
+
158
+ if not _is_proxy_debug():
159
+ for logger_name in ("httpx", "websockets", "uvicorn.protocols.websockets"):
160
+ logging.getLogger(logger_name).setLevel(logging.WARNING)
161
+
162
+
163
+ _configure_proxy_logging()
164
+
165
+
166
+ def _infer_port_from_argv() -> str | None:
167
+ """Best-effort extraction of `--port/-p` from process argv.
168
+
169
+ Returns:
170
+ The port as a string if found, else None.
171
+ """
172
+
173
+ argv = sys.argv[1:]
174
+ for i, arg in enumerate(argv):
175
+ if arg in {"-p", "--port"} and i + 1 < len(argv) and argv[i + 1].isdigit():
176
+ return argv[i + 1]
177
+ if arg.startswith("--port="):
178
+ _, _, value = arg.partition("=")
179
+ if value.isdigit():
180
+ return value
181
+ return None
182
+
183
+
184
+ def _is_non_serving_assets_cli() -> bool:
185
+ """Return True when running CLI assets commands that don't start a server.
186
+
187
+ This suppresses dev-proxy setup/logging for commands like `assets build`
188
+ where only a Vite build is performed and no proxy should be initialized.
189
+
190
+ Returns:
191
+ True when the current process is running a non-serving `litestar assets ...` command, otherwise False.
192
+ """
193
+
194
+ argv_str = " ".join(sys.argv)
195
+ non_serving_commands = (
196
+ " assets build",
197
+ " assets install",
198
+ " assets deploy",
199
+ " assets doctor",
200
+ " assets generate-types",
201
+ " assets export-routes",
202
+ " assets status",
203
+ " assets init",
204
+ )
205
+ return any(cmd in argv_str for cmd in non_serving_commands)
206
+
207
+
208
+ def _log_success(message: str) -> None:
209
+ """Print a success message with consistent styling."""
210
+
211
+ console.print(f"{_TICK} {message}")
212
+
213
+
214
+ def _log_info(message: str) -> None:
215
+ """Print an informational message with consistent styling."""
216
+
217
+ console.print(f"{_INFO} {message}")
218
+
219
+
220
+ def _log_warn(message: str) -> None:
221
+ """Print a warning message with consistent styling."""
222
+
223
+ console.print(f"{_WARN} {message}")
224
+
225
+
226
+ def _log_fail(message: str) -> None:
227
+ """Print an error message with consistent styling."""
228
+
229
+ console.print(f"{_FAIL} {message}")
230
+
231
+
232
+ def _write_runtime_config_file(config: ViteConfig) -> str:
233
+ """Write a JSON handoff file for the Vite plugin and return its path.
234
+
235
+ The runtime config file is read by the JS plugin. We serialize with Litestar's JSON encoder for
236
+ consistency and format output deterministically for easier debugging.
237
+
238
+ Returns:
239
+ The path to the written config file.
240
+ """
241
+
242
+ root = config.root_dir or Path.cwd()
243
+ path = Path(root) / ".litestar.json"
244
+ types = config.types if isinstance(config.types, TypeGenConfig) else None
245
+ resource_dir = config.resource_dir
246
+ resource_dir_value = str(resource_dir)
247
+ bundle_dir_value = str(config.bundle_dir)
248
+ ssr_out_dir_value = str(config.ssr_output_dir) if config.ssr_output_dir else None
249
+
250
+ litestar_version = os.environ.get("LITESTAR_VERSION") or resolve_litestar_version()
251
+
252
+ payload = {
253
+ "assetUrl": config.asset_url,
254
+ "bundleDir": bundle_dir_value,
255
+ "hotFile": config.hot_file,
256
+ "resourceDir": resource_dir_value,
257
+ "staticDir": str(config.static_dir),
258
+ "manifest": config.manifest_name,
259
+ "mode": config.mode,
260
+ "proxyMode": config.proxy_mode,
261
+ "port": config.port,
262
+ "host": config.host,
263
+ "ssrEnabled": config.ssr_enabled,
264
+ "ssrOutDir": ssr_out_dir_value,
265
+ "types": {
266
+ "enabled": True,
267
+ "output": str(types.output),
268
+ "openapiPath": str(types.openapi_path),
269
+ "routesPath": str(types.routes_path),
270
+ "pagePropsPath": str(types.page_props_path),
271
+ "generateZod": types.generate_zod,
272
+ "generateSdk": types.generate_sdk,
273
+ "generateRoutes": types.generate_routes,
274
+ "generatePageProps": types.generate_page_props,
275
+ "globalRoute": types.global_route,
276
+ }
277
+ if types
278
+ else None,
279
+ "logging": {
280
+ "level": config.logging_config.level,
281
+ "showPathsAbsolute": config.logging_config.show_paths_absolute,
282
+ "suppressNpmOutput": config.logging_config.suppress_npm_output,
283
+ "suppressViteBanner": config.logging_config.suppress_vite_banner,
284
+ "timestamps": config.logging_config.timestamps,
285
+ },
286
+ "executor": config.runtime.executor,
287
+ "litestarVersion": litestar_version,
288
+ }
289
+
290
+ import msgspec
291
+ from litestar.serialization import encode_json
292
+
293
+ path.write_bytes(msgspec.json.format(encode_json(payload), indent=2))
294
+ return str(path)
295
+
296
+
297
+ def set_environment(config: ViteConfig, asset_url_override: str | None = None) -> None:
298
+ """Configure environment variables for Vite integration.
299
+
300
+ Sets environment variables that can be used by both the Python backend
301
+ and the Vite frontend during development.
302
+
303
+ Args:
304
+ config: The Vite configuration.
305
+ asset_url_override: Optional asset URL to force (e.g., CDN base during build).
306
+ """
307
+ litestar_version = os.environ.get("LITESTAR_VERSION") or resolve_litestar_version()
308
+ asset_url = asset_url_override or config.asset_url
309
+ base_url = config.base_url or asset_url
310
+ if asset_url:
311
+ os.environ.setdefault("ASSET_URL", asset_url)
312
+ if base_url:
313
+ os.environ.setdefault("VITE_BASE_URL", base_url)
314
+ os.environ.setdefault("VITE_ALLOW_REMOTE", str(True))
315
+
316
+ backend_host = os.environ.get("LITESTAR_HOST") or "127.0.0.1"
317
+ backend_port = os.environ.get("LITESTAR_PORT") or os.environ.get("PORT") or _infer_port_from_argv() or "8000"
318
+ os.environ["LITESTAR_HOST"] = backend_host
319
+ os.environ["LITESTAR_PORT"] = str(backend_port)
320
+ os.environ.setdefault("APP_URL", f"http://{backend_host}:{backend_port}")
321
+
322
+ os.environ.setdefault("VITE_PROTOCOL", config.protocol)
323
+ if config.proxy_mode is not None:
324
+ os.environ.setdefault("VITE_PROXY_MODE", config.proxy_mode)
325
+
326
+ os.environ.setdefault("VITE_HOST", config.host)
327
+ os.environ.setdefault("VITE_PORT", str(config.port))
328
+ os.environ.setdefault("NUXT_HOST", config.host)
329
+ os.environ.setdefault("NUXT_PORT", str(config.port))
330
+ os.environ.setdefault("NITRO_HOST", config.host)
331
+ os.environ.setdefault("NITRO_PORT", str(config.port))
332
+ os.environ.setdefault("HOST", config.host)
333
+ os.environ.setdefault("PORT", str(config.port))
334
+
335
+ os.environ["LITESTAR_VERSION"] = litestar_version
336
+ os.environ.setdefault("LITESTAR_VITE_RUNTIME", config.runtime.executor or "node")
337
+ os.environ.setdefault("LITESTAR_VITE_INSTALL_CMD", " ".join(config.install_command))
338
+
339
+ if config.is_dev_mode:
340
+ os.environ.setdefault("VITE_DEV_MODE", str(config.is_dev_mode))
341
+
342
+ config_path = _write_runtime_config_file(config)
343
+ os.environ["LITESTAR_VITE_CONFIG_PATH"] = config_path
344
+
345
+
346
+ def set_app_environment(app: "Litestar") -> None:
347
+ """Set environment variables derived from the Litestar app instance.
348
+
349
+ This is called after set_environment() once the app is available,
350
+ to export app-specific configuration like OpenAPI paths.
351
+
352
+ Args:
353
+ app: The Litestar application instance.
354
+ """
355
+ openapi_config = app.openapi_config
356
+ if openapi_config is not None and isinstance(openapi_config.path, str) and openapi_config.path:
357
+ os.environ.setdefault("LITESTAR_OPENAPI_PATH", openapi_config.path)
358
+
359
+
360
+ def resolve_litestar_version() -> str:
361
+ """Return the installed Litestar version string.
362
+
363
+ Returns:
364
+ The installed Litestar version, or "unknown" when unavailable.
365
+ """
366
+ try:
367
+ return importlib.metadata.version("litestar")
368
+ except importlib.metadata.PackageNotFoundError:
369
+ return "unknown"
370
+
371
+
372
+ def _pick_free_port() -> int:
373
+ import socket
374
+
375
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
376
+ sock.bind(("127.0.0.1", 0))
377
+ return sock.getsockname()[1]
378
+
379
+
380
+ _PROXY_ALLOW_PREFIXES: tuple[str, ...] = (
381
+ "/@vite",
382
+ "/@id/",
383
+ "/@fs/",
384
+ "/@react-refresh",
385
+ "/@vite/client",
386
+ "/@vite/env",
387
+ "/vite-hmr",
388
+ "/__vite_ping",
389
+ "/node_modules/.vite/",
390
+ "/@analogjs/",
391
+ )
392
+
393
+ _HOP_BY_HOP_HEADERS = frozenset({
394
+ "connection",
395
+ "keep-alive",
396
+ "proxy-authenticate",
397
+ "proxy-authorization",
398
+ "te",
399
+ "trailers",
400
+ "transfer-encoding",
401
+ "upgrade",
402
+ "content-length",
403
+ "content-encoding",
404
+ })
405
+
406
+
407
+ def _normalize_prefix(prefix: str) -> str:
408
+ if not prefix.startswith("/"):
409
+ prefix = f"/{prefix}"
410
+ if not prefix.endswith("/"):
411
+ prefix = f"{prefix}/"
412
+ return prefix
413
+
414
+
415
+ class _RoutePrefixesState(Protocol):
416
+ litestar_vite_route_prefixes: tuple[str, ...]
417
+
418
+
419
+ def get_litestar_route_prefixes(app: "Litestar") -> tuple[str, ...]:
420
+ """Build a cached list of Litestar route prefixes for the given app.
421
+
422
+ This function collects all registered route paths from the Litestar application
423
+ and caches them for efficient lookup. The cache is stored in app.state to ensure
424
+ it's automatically cleaned up when the app is garbage collected.
425
+
426
+ Includes:
427
+ - All registered Litestar route paths
428
+ - OpenAPI schema path (customizable via openapi_config.path)
429
+ - Common API prefixes as fallback (/api, /schema, /docs)
430
+
431
+ Args:
432
+ app: The Litestar application instance.
433
+
434
+ Returns:
435
+ A tuple of route prefix strings (without trailing slashes).
436
+ """
437
+ state = cast("_RoutePrefixesState", app.state)
438
+ try:
439
+ return state.litestar_vite_route_prefixes
440
+ except AttributeError:
441
+ pass
442
+
443
+ prefixes: list[str] = []
444
+ for route in app.routes:
445
+ prefix = route.path.rstrip("/")
446
+ if prefix:
447
+ prefixes.append(prefix)
448
+
449
+ openapi_config = app.openapi_config
450
+ if openapi_config is not None:
451
+ schema_path = openapi_config.path
452
+ if schema_path:
453
+ prefixes.append(schema_path.rstrip("/"))
454
+
455
+ prefixes.extend(["/api", "/schema", "/docs"])
456
+
457
+ unique_prefixes = sorted(set(prefixes), key=len, reverse=True)
458
+ result = tuple(unique_prefixes)
459
+
460
+ state.litestar_vite_route_prefixes = result
461
+
462
+ if _is_proxy_debug():
463
+ console.print(f"[dim][route-detection] Cached prefixes: {result}[/]")
464
+
465
+ return result
466
+
467
+
468
+ def is_litestar_route(path: str, app: "Litestar") -> bool:
469
+ """Check if a path matches a registered Litestar route.
470
+
471
+ This function determines if a request path should be handled by Litestar
472
+ rather than proxied to the Vite dev server or served as SPA content.
473
+
474
+ A path matches if it equals a registered prefix or starts with prefix + "/".
475
+
476
+ Args:
477
+ path: The request path to check (e.g., "/schema", "/api/users").
478
+ app: The Litestar application instance.
479
+
480
+ Returns:
481
+ True if the path matches a Litestar route, False otherwise.
482
+ """
483
+ excluded = get_litestar_route_prefixes(app)
484
+ return any(path == prefix or path.startswith(f"{prefix}/") for prefix in excluded)
485
+
486
+
487
+ def _static_not_found_handler(
488
+ _request: "Request[Any, Any, Any]", _exc: NotFoundException
489
+ ) -> Response[bytes]: # pragma: no cover - trivial
490
+ """Return an empty 404 response for static files routing misses.
491
+
492
+ Returns:
493
+ An empty 404 response.
494
+ """
495
+ return Response(status_code=404, content=b"")
496
+
497
+
498
+ def _vite_not_found_handler(request: "Request[Any, Any, Any]", exc: NotFoundException) -> Response[Any]:
499
+ """Return a consistent 404 response for missing static assets / routes.
500
+
501
+ Inertia requests are delegated to the Inertia exception handler to support
502
+ redirect_404 configuration.
503
+
504
+ Args:
505
+ request: Incoming request.
506
+ exc: NotFound exception raised by routing.
507
+
508
+ Returns:
509
+ Response instance for the 404.
510
+ """
511
+ if request.headers.get("x-inertia", "").lower() == "true":
512
+ from litestar_vite.inertia.exception_handler import exception_to_http_response
513
+
514
+ return exception_to_http_response(request, exc)
515
+ return Response(status_code=404, content=b"")
516
+
13
517
 
518
+ class ViteProxyMiddleware(AbstractMiddleware):
519
+ """ASGI middleware to proxy Vite dev HTTP traffic to internal Vite server.
14
520
 
15
- class VitePlugin(InitPluginProtocol, CLIPluginProtocol):
16
- """Vite plugin."""
521
+ HTTP requests use httpx.AsyncClient with optional HTTP/2 support for better
522
+ connection multiplexing. WebSocket traffic (used by Vite HMR) is handled by
523
+ a dedicated WebSocket route handler created by create_vite_hmr_handler().
17
524
 
18
- __slots__ = ("_config",)
525
+ The middleware reads the Vite server URL from the hotfile dynamically,
526
+ ensuring it always connects to the correct Vite server even if the port changes.
527
+ """
19
528
 
20
- def __init__(self, config: ViteConfig) -> None:
21
- """Initialize ``Vite``.
529
+ scopes = {ScopeType.HTTP}
530
+
531
+ def __init__(
532
+ self,
533
+ app: "ASGIApp",
534
+ hotfile_path: Path,
535
+ asset_url: "str | None" = None,
536
+ resource_dir: "Path | None" = None,
537
+ bundle_dir: "Path | None" = None,
538
+ root_dir: "Path | None" = None,
539
+ http2: bool = True,
540
+ ) -> None:
541
+ super().__init__(app)
542
+ self.hotfile_path = hotfile_path
543
+ self._cached_target: str | None = None
544
+ self._cache_initialized = False
545
+ self.asset_prefix = _normalize_prefix(asset_url) if asset_url else "/"
546
+ self.http2 = http2
547
+ self._proxy_allow_prefixes = _normalize_proxy_prefixes(
548
+ base_prefixes=_PROXY_ALLOW_PREFIXES,
549
+ asset_url=asset_url,
550
+ resource_dir=resource_dir,
551
+ bundle_dir=bundle_dir,
552
+ root_dir=root_dir,
553
+ )
554
+
555
+ def _get_target_base_url(self) -> str | None:
556
+ """Read the Vite server URL from the hotfile with permanent caching.
557
+
558
+ The hotfile is read once and cached for the lifetime of the server.
559
+ Server restart refreshes the cache automatically.
560
+
561
+ Returns:
562
+ The Vite server URL or None if unavailable.
563
+ """
564
+ if self._cache_initialized:
565
+ return self._cached_target.rstrip("/") if self._cached_target else None
566
+
567
+ try:
568
+ url = self.hotfile_path.read_text().strip()
569
+ self._cached_target = url
570
+ self._cache_initialized = True
571
+ if _is_proxy_debug():
572
+ console.print(f"[dim][vite-proxy] Target: {url}[/]")
573
+ return url.rstrip("/")
574
+ except FileNotFoundError:
575
+ self._cached_target = None
576
+ self._cache_initialized = True
577
+ return None
578
+
579
+ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
580
+ scope_dict = cast("dict[str, Any]", scope)
581
+ path = scope_dict.get("path", "")
582
+ should = self._should_proxy(path, scope)
583
+ if _is_proxy_debug():
584
+ console.print(f"[dim][vite-proxy] {path} → proxy={should}[/]")
585
+ if should:
586
+ await self._proxy_http(scope_dict, receive, send)
587
+ return
588
+ await self.app(scope, receive, send)
589
+
590
+ def _should_proxy(self, path: str, scope: "Scope") -> bool:
591
+ try:
592
+ from urllib.parse import unquote
593
+ except ImportError: # pragma: no cover
594
+ decoded = path
595
+ matches_prefix = path.startswith(self._proxy_allow_prefixes)
596
+ else:
597
+ decoded = unquote(path)
598
+ matches_prefix = decoded.startswith(self._proxy_allow_prefixes) or path.startswith(
599
+ self._proxy_allow_prefixes
600
+ )
601
+
602
+ if not matches_prefix:
603
+ return False
604
+
605
+ app = scope.get("app") # pyright: ignore[reportUnknownMemberType]
606
+ return not (app and is_litestar_route(path, app))
607
+
608
+ async def _proxy_http(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
609
+ """Proxy a single HTTP request to the Vite dev server.
610
+
611
+ The upstream response is buffered inside the httpx client context manager and only sent
612
+ after the context exits. This avoids ASGI errors when httpx raises during cleanup after the
613
+ response has started.
614
+ """
615
+ target_base_url = self._get_target_base_url()
616
+ if target_base_url is None:
617
+ await send({"type": "http.response.start", "status": 503, "headers": [(b"content-type", b"text/plain")]})
618
+ await send({"type": "http.response.body", "body": b"Vite dev server not running", "more_body": False})
619
+ return
620
+
621
+ method = scope.get("method", "GET")
622
+ raw_path = scope.get("raw_path", b"").decode()
623
+ query_string = scope.get("query_string", b"").decode()
624
+ proxied_path = raw_path
625
+ if self.asset_prefix != "/" and not raw_path.startswith(self.asset_prefix):
626
+ proxied_path = f"{self.asset_prefix.rstrip('/')}{raw_path}"
627
+
628
+ url = f"{target_base_url}{proxied_path}"
629
+ if query_string:
630
+ url = f"{url}?{query_string}"
631
+
632
+ headers = [(k.decode(), v.decode()) for k, v in scope.get("headers", [])]
633
+ body = b""
634
+ more_body = True
635
+ while more_body:
636
+ event = await receive()
637
+ if event["type"] != "http.request":
638
+ continue
639
+ body += event.get("body", b"")
640
+ more_body = event.get("more_body", False)
641
+
642
+ http2_enabled = self.http2
643
+ if http2_enabled:
644
+ try:
645
+ import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
646
+ except ImportError:
647
+ http2_enabled = False
648
+
649
+ response_status = 502
650
+ response_headers: list[tuple[bytes, bytes]] = [(b"content-type", b"text/plain")]
651
+ response_body = b"Bad gateway"
652
+ got_full_body = False
653
+
654
+ try:
655
+ async with httpx.AsyncClient(http2=http2_enabled) as client:
656
+ upstream_resp = await client.request(method, url, headers=headers, content=body, timeout=10.0)
657
+ response_status = upstream_resp.status_code
658
+ response_headers = [
659
+ (k.encode(), v.encode())
660
+ for k, v in upstream_resp.headers.items()
661
+ if k.lower() not in _HOP_BY_HOP_HEADERS
662
+ ]
663
+ response_body = upstream_resp.content
664
+ got_full_body = True
665
+ except Exception as exc: # pragma: no cover - catch all cleanup errors
666
+ if not got_full_body:
667
+ response_body = f"Upstream error: {exc}".encode()
668
+
669
+ await send({"type": "http.response.start", "status": response_status, "headers": response_headers})
670
+ await send({"type": "http.response.body", "body": response_body, "more_body": False})
671
+
672
+
673
+ class ExternalDevServerProxyMiddleware(AbstractMiddleware):
674
+ """ASGI middleware to proxy requests to an external dev server (deny list mode).
675
+
676
+ This middleware proxies all requests that don't match Litestar-registered routes
677
+ to the target dev server. It supports two modes:
678
+
679
+ 1. **Static target**: Provide a fixed URL (e.g., "http://localhost:4200" for Angular CLI)
680
+ 2. **Dynamic target**: Leave target as None and provide hotfile_path - the proxy reads
681
+ the target URL from the Vite hotfile (for SSR frameworks like Astro, Nuxt, SvelteKit)
682
+
683
+ Unlike ViteProxyMiddleware (allow list), this middleware:
684
+ - Uses deny list approach: proxies everything EXCEPT Litestar routes
685
+ - Supports both static and dynamic target URLs
686
+ - Auto-excludes Litestar routes, static mounts, and schema paths
687
+ """
688
+
689
+ scopes = {ScopeType.HTTP}
690
+
691
+ def __init__(
692
+ self,
693
+ app: "ASGIApp",
694
+ target: "str | None" = None,
695
+ hotfile_path: "Path | None" = None,
696
+ http2: bool = False,
697
+ litestar_app: "Litestar | None" = None,
698
+ ) -> None:
699
+ """Initialize the external dev server proxy middleware.
22
700
 
23
701
  Args:
24
- config: configure and start Vite.
702
+ app: The ASGI application to wrap.
703
+ target: Static target URL to proxy to (e.g., "http://localhost:4200").
704
+ If None, uses hotfile_path for dynamic target discovery.
705
+ hotfile_path: Path to the Vite hotfile for dynamic target discovery.
706
+ Used when target is None (SSR frameworks with dynamic ports).
707
+ http2: Enable HTTP/2 for proxy connections.
708
+ litestar_app: Optional Litestar app instance for route exclusion.
709
+ """
710
+ super().__init__(app)
711
+ self._static_target = target.rstrip("/") if target else None
712
+ self._hotfile_path = hotfile_path
713
+ self._cached_target: str | None = None
714
+ self._cache_initialized = False
715
+ self.http2 = http2
716
+ self._litestar_app = litestar_app
717
+ self._deny_prefixes: tuple[str, ...] | None = None
718
+
719
+ def _get_target(self) -> str | None:
720
+ """Get the proxy target URL with permanent caching.
721
+
722
+ Returns static target if configured, otherwise reads from hotfile.
723
+ The hotfile is read once and cached for the lifetime of the server.
724
+
725
+ Returns:
726
+ The target URL or None if unavailable.
727
+ """
728
+ if self._static_target:
729
+ return self._static_target
730
+
731
+ if self._hotfile_path:
732
+ if self._cache_initialized:
733
+ return self._cached_target.rstrip("/") if self._cached_target else None
734
+
735
+ try:
736
+ url = self._hotfile_path.read_text().strip()
737
+ self._cached_target = url
738
+ self._cache_initialized = True
739
+ if _is_proxy_debug():
740
+ console.print(f"[dim][proxy] Dynamic target: {url}[/]")
741
+ return url.rstrip("/")
742
+ except FileNotFoundError:
743
+ self._cached_target = None
744
+ self._cache_initialized = True
745
+ return None
746
+
747
+ return None
748
+
749
+ def _get_deny_prefixes(self, scope: "Scope") -> tuple[str, ...]:
750
+ """Build list of path prefixes to deny from proxying (deny list).
751
+
752
+ Uses the shared get_litestar_route_prefixes() function for route detection.
753
+ Results are cached per middleware instance. If the Litestar app cannot be resolved (not
754
+ provided during init and missing from the scope), falls back to common API prefixes.
755
+
756
+ Returns:
757
+ A tuple of path prefixes that should NOT be proxied.
758
+ """
759
+ if self._deny_prefixes is not None:
760
+ return self._deny_prefixes
761
+
762
+ app: "Litestar | None" = self._litestar_app or scope.get("app") # pyright: ignore[reportUnknownMemberType]
763
+ if app:
764
+ self._deny_prefixes = get_litestar_route_prefixes(app)
765
+ else:
766
+ self._deny_prefixes = ("/api", "/schema", "/docs")
767
+
768
+ if _is_proxy_debug():
769
+ console.print(f"[dim][external-proxy] Deny prefixes: {self._deny_prefixes}[/]")
770
+
771
+ return self._deny_prefixes
772
+
773
+ def _should_proxy(self, path: str, scope: "Scope") -> bool:
774
+ """Determine if the request should be proxied to the external server.
775
+
776
+ Returns True if the path does NOT match any Litestar route (deny list approach).
777
+
778
+ Returns:
779
+ True if the request should be proxied, else False.
780
+ """
781
+ deny_prefixes = self._get_deny_prefixes(scope)
782
+ return all(not (path == prefix or path.startswith(f"{prefix}/")) for prefix in deny_prefixes)
783
+
784
+ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
785
+ scope_dict = cast("dict[str, Any]", scope)
786
+ path = scope_dict.get("path", "")
787
+
788
+ should = self._should_proxy(path, scope)
789
+ if _is_proxy_debug():
790
+ console.print(f"[dim][external-proxy] {path} → proxy={should}[/]")
791
+
792
+ if should:
793
+ await self._proxy_request(scope_dict, receive, send)
794
+ return
795
+
796
+ await self.app(scope, receive, send)
797
+
798
+ async def _proxy_request(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
799
+ """Proxy the HTTP request to the external dev server.
800
+
801
+ The upstream response is buffered inside the httpx client context manager and only sent
802
+ after the context exits. This avoids ASGI errors when httpx raises during cleanup after the
803
+ response has started.
25
804
  """
805
+ target = self._get_target()
806
+ if target is None:
807
+ await send({"type": "http.response.start", "status": 503, "headers": [(b"content-type", b"text/plain")]})
808
+ await send({"type": "http.response.body", "body": b"Dev server not running", "more_body": False})
809
+ return
810
+
811
+ method = scope.get("method", "GET")
812
+ raw_path = scope.get("raw_path", b"").decode()
813
+ query_string = scope.get("query_string", b"").decode()
814
+
815
+ url = f"{target}{raw_path}"
816
+ if query_string:
817
+ url = f"{url}?{query_string}"
818
+
819
+ headers = [
820
+ (k.decode(), v.decode())
821
+ for k, v in scope.get("headers", [])
822
+ if k.lower() not in {b"host", b"connection", b"keep-alive"}
823
+ ]
824
+
825
+ body = b""
826
+ more_body = True
827
+ while more_body:
828
+ event = await receive()
829
+ if event["type"] != "http.request":
830
+ continue
831
+ body += event.get("body", b"")
832
+ more_body = event.get("more_body", False)
833
+
834
+ http2_enabled = self.http2
835
+ if http2_enabled:
836
+ try:
837
+ import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
838
+ except ImportError:
839
+ http2_enabled = False
840
+
841
+ response_status = 502
842
+ response_headers: list[tuple[bytes, bytes]] = [(b"content-type", b"text/plain")]
843
+ response_body = b"Bad gateway"
844
+ got_full_body = False
845
+
846
+ try:
847
+ async with httpx.AsyncClient(http2=http2_enabled, timeout=30.0) as client:
848
+ upstream_resp = await client.request(method, url, headers=headers, content=body)
849
+ response_status = upstream_resp.status_code
850
+ response_headers = [
851
+ (k.encode(), v.encode())
852
+ for k, v in upstream_resp.headers.items()
853
+ if k.lower() not in _HOP_BY_HOP_HEADERS
854
+ ]
855
+ response_body = upstream_resp.content
856
+ got_full_body = True
857
+ except httpx.ConnectError:
858
+ response_status = 503
859
+ response_body = f"Dev server not running at {target}".encode()
860
+ except httpx.HTTPError as exc: # pragma: no cover
861
+ if not got_full_body:
862
+ response_body = f"Upstream error: {exc}".encode()
863
+
864
+ await send({"type": "http.response.start", "status": response_status, "headers": response_headers})
865
+ await send({"type": "http.response.body", "body": response_body, "more_body": False})
866
+
867
+
868
+ def _build_hmr_target_url(hotfile_path: Path, scope: dict[str, Any], hmr_path: str, asset_url: str) -> "str | None":
869
+ """Build the target WebSocket URL for Vite HMR proxy.
870
+
871
+ Vite's HMR WebSocket listens at {base}{hmr.path}, so we preserve
872
+ the full path including the asset prefix (e.g., /static/vite-hmr).
873
+
874
+ Returns:
875
+ The target WebSocket URL or None if the hotfile is not found.
876
+ """
877
+ try:
878
+ vite_url = hotfile_path.read_text(encoding="utf-8").strip()
879
+ except FileNotFoundError:
880
+ return None
881
+
882
+ ws_url = vite_url.replace("http://", "ws://").replace("https://", "wss://")
883
+ original_path = scope.get("path", hmr_path)
884
+ query_string = scope.get("query_string", b"").decode()
885
+
886
+ target = f"{ws_url}{original_path}"
887
+ if query_string:
888
+ target = f"{target}?{query_string}"
889
+
890
+ if _is_proxy_debug():
891
+ console.print(f"[dim][vite-hmr] Connecting: {target}[/]")
892
+
893
+ return target
894
+
895
+
896
+ def _extract_forward_headers(scope: dict[str, Any]) -> list[tuple[str, str]]:
897
+ """Extract headers to forward, excluding WebSocket handshake headers.
898
+
899
+ Excludes protocol-specific headers that websockets library handles itself.
900
+ The sec-websocket-protocol header is also excluded since we handle subprotocols separately.
901
+
902
+ Returns:
903
+ A list of (header_name, header_value) tuples.
904
+ """
905
+ skip_headers = (
906
+ b"host",
907
+ b"upgrade",
908
+ b"connection",
909
+ b"sec-websocket-key",
910
+ b"sec-websocket-version",
911
+ b"sec-websocket-protocol",
912
+ b"sec-websocket-extensions",
913
+ )
914
+ return [(k.decode(), v.decode()) for k, v in scope.get("headers", []) if k.lower() not in skip_headers]
915
+
916
+
917
+ def _extract_subprotocols(scope: dict[str, Any]) -> list[str]:
918
+ """Extract WebSocket subprotocols from the request headers.
919
+
920
+ Returns:
921
+ A list of subprotocol strings.
922
+ """
923
+ for key, value in scope.get("headers", []):
924
+ if key.lower() == b"sec-websocket-protocol":
925
+ return [p.strip() for p in value.decode().split(",")]
926
+ return []
927
+
928
+
929
+ async def _run_websocket_proxy(socket: Any, upstream: Any) -> None:
930
+ """Run bidirectional WebSocket proxy between client and upstream.
931
+
932
+ Args:
933
+ socket: The client WebSocket connection (Litestar WebSocket).
934
+ upstream: The upstream WebSocket connection (websockets client).
935
+ """
936
+
937
+ async def client_to_upstream() -> None:
938
+ """Forward messages from browser to Vite."""
939
+ try:
940
+ while True:
941
+ data = await socket.receive_text()
942
+ await upstream.send(data)
943
+ except (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed):
944
+ pass
945
+ finally:
946
+ with suppress(websockets.ConnectionClosed):
947
+ await upstream.close()
948
+
949
+ async def upstream_to_client() -> None:
950
+ """Forward messages from Vite to browser."""
951
+ try:
952
+ async for msg in upstream:
953
+ if isinstance(msg, str):
954
+ await socket.send_text(msg)
955
+ else:
956
+ await socket.send_bytes(msg)
957
+ except (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed):
958
+ pass
959
+ finally:
960
+ with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
961
+ await socket.close()
962
+
963
+ async with anyio.create_task_group() as tg:
964
+ tg.start_soon(client_to_upstream)
965
+ tg.start_soon(upstream_to_client)
966
+
967
+
968
+ def create_vite_hmr_handler(hotfile_path: Path, hmr_path: str = "/static/vite-hmr", asset_url: str = "/static/") -> Any:
969
+ """Create a WebSocket route handler for Vite HMR proxy.
970
+
971
+ This handler proxies WebSocket connections from the browser to the Vite
972
+ dev server for Hot Module Replacement (HMR) functionality.
973
+
974
+ Args:
975
+ hotfile_path: Path to the hotfile written by the Vite plugin.
976
+ hmr_path: The path to register the WebSocket handler at.
977
+ asset_url: The asset URL prefix to strip when connecting to Vite.
978
+
979
+ Returns:
980
+ A WebsocketRouteHandler that proxies HMR connections.
981
+ """
982
+ from litestar import WebSocket, websocket
983
+
984
+ @websocket(path=hmr_path, opt={"exclude_from_auth": True})
985
+ async def vite_hmr_proxy(socket: "WebSocket[Any, Any, Any]") -> None:
986
+ """Proxy WebSocket messages between browser and Vite dev server.
987
+
988
+ Raises:
989
+ BaseException: Re-raises unexpected exceptions to allow the ASGI server to log them.
990
+ """
991
+ scope_dict = dict(socket.scope)
992
+ target = _build_hmr_target_url(hotfile_path, scope_dict, hmr_path, asset_url)
993
+ if target is None:
994
+ console.print("[yellow][vite-hmr] Vite dev server not running[/]")
995
+ await socket.close(code=1011, reason="Vite dev server not running")
996
+ return
997
+
998
+ headers = _extract_forward_headers(scope_dict)
999
+ subprotocols = _extract_subprotocols(scope_dict)
1000
+ typed_subprotocols: list[Subprotocol] = [cast("Subprotocol", p) for p in subprotocols]
1001
+ await socket.accept(subprotocols=typed_subprotocols[0] if typed_subprotocols else None)
1002
+
1003
+ try:
1004
+ async with websockets.connect(
1005
+ target, additional_headers=headers, open_timeout=10, subprotocols=typed_subprotocols or None
1006
+ ) as upstream:
1007
+ if _is_proxy_debug():
1008
+ console.print("[dim][vite-hmr] ✓ Connected[/]")
1009
+ await _run_websocket_proxy(socket, upstream)
1010
+ except TimeoutError:
1011
+ if _is_proxy_debug():
1012
+ console.print("[yellow][vite-hmr] Connection timeout[/]")
1013
+ with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
1014
+ await socket.close(code=1011, reason="Vite HMR connection timeout")
1015
+ except OSError as exc:
1016
+ if _is_proxy_debug():
1017
+ console.print(f"[yellow][vite-hmr] Connection failed: {exc}[/]")
1018
+ with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
1019
+ await socket.close(code=1011, reason="Vite HMR connection failed")
1020
+ except WebSocketDisconnect:
1021
+ pass
1022
+ except BaseException as exc:
1023
+ exceptions: list[BaseException] | tuple[BaseException, ...] | None
1024
+ try:
1025
+ exceptions = cast("list[BaseException] | tuple[BaseException, ...]", exc.exceptions) # type: ignore[attr-defined]
1026
+ except AttributeError:
1027
+ exceptions = None
1028
+
1029
+ if exceptions is not None:
1030
+ if any(not isinstance(err, _DISCONNECT_EXCEPTIONS) for err in exceptions):
1031
+ raise
1032
+ return
1033
+
1034
+ if not isinstance(exc, _DISCONNECT_EXCEPTIONS):
1035
+ raise
1036
+
1037
+ return vite_hmr_proxy
1038
+
1039
+
1040
+ def _check_http2_support(enable: bool) -> bool:
1041
+ """Check if HTTP/2 support is available.
1042
+
1043
+ Returns:
1044
+ True if HTTP/2 is enabled and the h2 package is installed, else False.
1045
+ """
1046
+ if not enable:
1047
+ return False
1048
+ try:
1049
+ import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
1050
+ except ImportError:
1051
+ return False
1052
+ else:
1053
+ return True
1054
+
1055
+
1056
+ def _build_proxy_url(target_url: str, path: str, query: str) -> str:
1057
+ """Build the full proxy URL from target, path, and query string.
1058
+
1059
+ Returns:
1060
+ The full URL as a string.
1061
+ """
1062
+ url = f"{target_url}{path}"
1063
+ return f"{url}?{query}" if query else url
1064
+
1065
+
1066
+ def _create_target_url_getter(
1067
+ target: "str | None", hotfile_path: "Path | None", cached_target: list["str | None"]
1068
+ ) -> "Callable[[], str | None]":
1069
+ """Create a function that returns the current target URL with permanent caching.
1070
+
1071
+ The hotfile is read once and cached for the lifetime of the server.
1072
+ Server restart refreshes the cache automatically.
1073
+
1074
+ Returns:
1075
+ A callable that returns the target URL or None if unavailable.
1076
+ """
1077
+ cache_initialized: list[bool] = [False]
1078
+
1079
+ def _get_target_url() -> str | None:
1080
+ if target is not None:
1081
+ return target.rstrip("/")
1082
+ if hotfile_path is None:
1083
+ return None
1084
+
1085
+ if cache_initialized[0]:
1086
+ return cached_target[0].rstrip("/") if cached_target[0] else None
1087
+
1088
+ try:
1089
+ url = hotfile_path.read_text().strip()
1090
+ cached_target[0] = url
1091
+ cache_initialized[0] = True
1092
+ if _is_proxy_debug():
1093
+ console.print(f"[dim][ssr-proxy] Dynamic target: {url}[/]")
1094
+ return url.rstrip("/")
1095
+ except FileNotFoundError:
1096
+ cached_target[0] = None
1097
+ cache_initialized[0] = True
1098
+ return None
1099
+
1100
+ return _get_target_url
1101
+
1102
+
1103
+ def _create_hmr_target_getter(
1104
+ hotfile_path: "Path | None", cached_hmr_target: list["str | None"]
1105
+ ) -> "Callable[[], str | None]":
1106
+ """Create a function that returns the HMR target URL from hotfile with permanent caching.
1107
+
1108
+ The hotfile is read once and cached for the lifetime of the server.
1109
+ Server restart refreshes the cache automatically.
1110
+
1111
+ The JS side writes HMR URLs to a sibling file at ``<hotfile>.hmr``.
1112
+
1113
+ Returns:
1114
+ A callable that returns the HMR target URL or None if unavailable.
1115
+ """
1116
+ cache_initialized: list[bool] = [False]
1117
+
1118
+ def _get_hmr_target_url() -> str | None:
1119
+ if hotfile_path is None:
1120
+ return None
1121
+
1122
+ if cache_initialized[0]:
1123
+ return cached_hmr_target[0].rstrip("/") if cached_hmr_target[0] else None
1124
+
1125
+ hmr_path = Path(f"{hotfile_path}.hmr")
1126
+ try:
1127
+ url = hmr_path.read_text(encoding="utf-8").strip()
1128
+ cached_hmr_target[0] = url
1129
+ cache_initialized[0] = True
1130
+ if _is_proxy_debug():
1131
+ console.print(f"[dim][ssr-proxy] HMR target: {url}[/]")
1132
+ return url.rstrip("/")
1133
+ except FileNotFoundError:
1134
+ cached_hmr_target[0] = None
1135
+ cache_initialized[0] = True
1136
+ return None
1137
+
1138
+ return _get_hmr_target_url
1139
+
1140
+
1141
+ async def _handle_ssr_websocket_proxy(
1142
+ socket: Any, ws_url: str, headers: list[tuple[str, str]], typed_subprotocols: "list[Subprotocol]"
1143
+ ) -> None:
1144
+ """Handle the WebSocket proxy connection to SSR framework.
1145
+
1146
+ Args:
1147
+ socket: The client WebSocket connection.
1148
+ ws_url: The upstream WebSocket URL.
1149
+ headers: Headers to forward.
1150
+ typed_subprotocols: WebSocket subprotocols.
1151
+ """
1152
+ try:
1153
+ async with websockets.connect(
1154
+ ws_url, additional_headers=headers, open_timeout=10, subprotocols=typed_subprotocols or None
1155
+ ) as upstream:
1156
+ if _is_proxy_debug():
1157
+ console.print("[dim][ssr-proxy-ws] ✓ Connected[/]")
1158
+ await _run_websocket_proxy(socket, upstream)
1159
+ except TimeoutError:
1160
+ if _is_proxy_debug():
1161
+ console.print("[yellow][ssr-proxy-ws] Connection timeout[/]")
1162
+ with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
1163
+ await socket.close(code=1011, reason="SSR HMR connection timeout")
1164
+ except OSError as exc:
1165
+ if _is_proxy_debug():
1166
+ console.print(f"[yellow][ssr-proxy-ws] Connection failed: {exc}[/]")
1167
+ with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
1168
+ await socket.close(code=1011, reason="SSR HMR connection failed")
1169
+ except (WebSocketDisconnect, websockets.ConnectionClosed, anyio.ClosedResourceError):
1170
+ pass
1171
+
1172
+
1173
+ def create_ssr_proxy_controller(
1174
+ target: "str | None" = None, hotfile_path: "Path | None" = None, http2: bool = True
1175
+ ) -> type:
1176
+ """Create a Controller that proxies to an SSR framework dev server.
1177
+
1178
+ This controller is used for SSR frameworks (Astro, Nuxt, SvelteKit) where all
1179
+ non-API requests should be proxied to the framework's dev server for rendering.
1180
+
1181
+ Args:
1182
+ target: Static target URL to proxy to. If None, uses hotfile for dynamic discovery.
1183
+ hotfile_path: Path to the hotfile for dynamic target discovery.
1184
+ http2: Enable HTTP/2 for proxy connections.
1185
+
1186
+ Returns:
1187
+ A Litestar Controller class with HTTP and WebSocket handlers for SSR proxy.
1188
+ """
1189
+ from litestar import Controller, HttpMethod, Response, WebSocket, route, websocket
1190
+
1191
+ cached_target: list[str | None] = [target]
1192
+ get_target_url = _create_target_url_getter(target, hotfile_path, cached_target)
1193
+ get_hmr_target_url = _create_hmr_target_getter(hotfile_path, [None])
1194
+
1195
+ class SSRProxyController(Controller):
1196
+ """Controller that proxies requests to an SSR framework dev server."""
1197
+
1198
+ include_in_schema = False
1199
+ opt = {"exclude_from_auth": True}
1200
+
1201
+ @route(
1202
+ path=["/", "/{path:path}"],
1203
+ http_method=[
1204
+ HttpMethod.GET,
1205
+ HttpMethod.POST,
1206
+ HttpMethod.PUT,
1207
+ HttpMethod.PATCH,
1208
+ HttpMethod.DELETE,
1209
+ HttpMethod.HEAD,
1210
+ HttpMethod.OPTIONS,
1211
+ ],
1212
+ name="ssr_proxy",
1213
+ )
1214
+ async def http_proxy(self, request: "Request[Any, Any, Any]") -> "Response[bytes]":
1215
+ """Proxy all HTTP requests to the SSR framework dev server.
1216
+
1217
+ Returns:
1218
+ A Response with the proxied content from the SSR server.
1219
+ """
1220
+ target_url = get_target_url()
1221
+ if target_url is None:
1222
+ return Response(content=b"SSR dev server not running", status_code=503, media_type="text/plain")
1223
+
1224
+ req_path: str = request.url.path
1225
+ url = _build_proxy_url(target_url, req_path, request.url.query or "")
1226
+
1227
+ if _is_proxy_debug():
1228
+ console.print(f"[dim][ssr-proxy] {request.method} {req_path} → {url}[/]")
1229
+
1230
+ headers_to_forward = [
1231
+ (k, v) for k, v in request.headers.items() if k.lower() not in {"host", "connection", "keep-alive"}
1232
+ ]
1233
+ body = await request.body()
1234
+ http2_enabled = _check_http2_support(http2)
1235
+
1236
+ async with httpx.AsyncClient(http2=http2_enabled, timeout=30.0) as client:
1237
+ try:
1238
+ upstream_resp = await client.request(
1239
+ request.method, url, headers=headers_to_forward, content=body, follow_redirects=False
1240
+ )
1241
+ except httpx.ConnectError:
1242
+ return Response(
1243
+ content=f"SSR dev server not running at {target_url}".encode(),
1244
+ status_code=503,
1245
+ media_type="text/plain",
1246
+ )
1247
+ except httpx.HTTPError as exc:
1248
+ return Response(content=str(exc).encode(), status_code=502, media_type="text/plain")
1249
+
1250
+ return Response(
1251
+ content=upstream_resp.content,
1252
+ status_code=upstream_resp.status_code,
1253
+ headers=dict(upstream_resp.headers.items()),
1254
+ media_type=upstream_resp.headers.get("content-type"),
1255
+ )
1256
+
1257
+ @websocket(path=["/", "/{path:path}"], name="ssr_proxy_ws")
1258
+ async def ws_proxy(self, socket: "WebSocket[Any, Any, Any]") -> None:
1259
+ """Proxy WebSocket connections to the SSR framework dev server (for HMR)."""
1260
+ target_url = get_hmr_target_url() or get_target_url()
1261
+
1262
+ if target_url is None:
1263
+ await socket.close(code=1011, reason="SSR dev server not running")
1264
+ return
1265
+
1266
+ ws_target = target_url.replace("http://", "ws://").replace("https://", "wss://")
1267
+ scope_dict = dict(socket.scope)
1268
+ ws_path = str(scope_dict.get("path", "/"))
1269
+ query_bytes = cast("bytes", scope_dict.get("query_string", b""))
1270
+ ws_url = _build_proxy_url(ws_target, ws_path, query_bytes.decode("utf-8") if query_bytes else "")
1271
+
1272
+ if _is_proxy_debug():
1273
+ console.print(f"[dim][ssr-proxy-ws] {ws_path} → {ws_url}[/]")
1274
+
1275
+ headers = _extract_forward_headers(scope_dict)
1276
+ subprotocols = _extract_subprotocols(scope_dict)
1277
+ typed_subprotocols: list[Subprotocol] = [cast("Subprotocol", p) for p in subprotocols]
1278
+
1279
+ await socket.accept(subprotocols=typed_subprotocols[0] if typed_subprotocols else None)
1280
+ await _handle_ssr_websocket_proxy(socket, ws_url, headers, typed_subprotocols)
1281
+
1282
+ return SSRProxyController
1283
+
1284
+
1285
+ @dataclass
1286
+ class StaticFilesConfig:
1287
+ """Configuration for static file serving.
1288
+
1289
+ This configuration is passed to Litestar's static files router.
1290
+ """
1291
+
1292
+ after_request: "AfterRequestHookHandler | None" = None
1293
+ after_response: "AfterResponseHookHandler | None" = None
1294
+ before_request: "BeforeRequestHookHandler | None" = None
1295
+ cache_control: "CacheControlHeader | None" = None
1296
+ exception_handlers: "ExceptionHandlersMap | None" = None
1297
+ guards: "list[Guard] | None" = None # pyright: ignore[reportUnknownVariableType]
1298
+ middleware: "Sequence[Middleware] | None" = None
1299
+ opt: "dict[str, Any] | None" = None
1300
+ security: "Sequence[SecurityRequirement] | None" = None
1301
+ tags: "Sequence[str] | None" = None
1302
+
1303
+
1304
+ class ViteProcess:
1305
+ """Manages the Vite development server process.
1306
+
1307
+ This class handles starting and stopping the Vite dev server process,
1308
+ with proper thread safety and graceful shutdown. It registers signal
1309
+ handlers for SIGTERM and SIGINT to ensure child processes are terminated
1310
+ even if Python is killed externally.
1311
+ """
1312
+
1313
+ _instances: "list[ViteProcess]" = []
1314
+ _signals_registered: bool = False
1315
+ _original_handlers: "dict[int, Any]" = {}
1316
+
1317
+ def __init__(self, executor: "JSExecutor") -> None:
1318
+ """Initialize the Vite process manager.
1319
+
1320
+ Args:
1321
+ executor: The JavaScript executor to use for running Vite.
1322
+ """
1323
+ self.process: "subprocess.Popen[Any] | None" = None
1324
+ self._lock = threading.Lock()
1325
+ self._executor = executor
1326
+
1327
+ ViteProcess._instances.append(self)
1328
+
1329
+ if not ViteProcess._signals_registered:
1330
+ self._register_signal_handlers()
1331
+ ViteProcess._signals_registered = True
1332
+
1333
+ import atexit
1334
+
1335
+ atexit.register(ViteProcess._cleanup_all_instances)
1336
+
1337
+ @classmethod
1338
+ def _register_signal_handlers(cls) -> None:
1339
+ """Register signal handlers for graceful shutdown on SIGTERM/SIGINT."""
1340
+ for sig in (signal.SIGTERM, signal.SIGINT):
1341
+ try:
1342
+ original = signal.signal(sig, cls._signal_handler)
1343
+ cls._original_handlers[sig] = original
1344
+ except (OSError, ValueError):
1345
+ pass
1346
+
1347
+ @classmethod
1348
+ def _signal_handler(cls, signum: int, frame: Any) -> None:
1349
+ """Handle termination signals by stopping all Vite processes first."""
1350
+ cls._cleanup_all_instances()
1351
+
1352
+ original = cls._original_handlers.get(signum, signal.SIG_DFL)
1353
+ if callable(original) and original not in {signal.SIG_IGN, signal.SIG_DFL}:
1354
+ original(signum, frame)
1355
+ elif original == signal.SIG_DFL:
1356
+ signal.signal(signum, signal.SIG_DFL)
1357
+ os.kill(os.getpid(), signum)
1358
+
1359
+ @classmethod
1360
+ def _cleanup_all_instances(cls) -> None:
1361
+ """Stop all tracked ViteProcess instances."""
1362
+ for instance in cls._instances:
1363
+ with suppress(Exception):
1364
+ instance.stop()
1365
+
1366
+ def start(self, command: list[str], cwd: "Path | str | None") -> None:
1367
+ """Start the Vite process.
1368
+
1369
+ Args:
1370
+ command: The command to run (e.g., ["npm", "run", "dev"]).
1371
+ cwd: The working directory for the process.
1372
+
1373
+ If the process exits immediately, this method captures stdout/stderr and raises a
1374
+ ViteProcessError with diagnostic details.
1375
+
1376
+ Raises:
1377
+ ViteProcessError: If the process fails to start.
1378
+ """
1379
+ if cwd is not None and isinstance(cwd, str):
1380
+ cwd = Path(cwd)
1381
+
1382
+ try:
1383
+ with self._lock:
1384
+ if self.process and self.process.poll() is None:
1385
+ return
1386
+
1387
+ if cwd:
1388
+ self.process = self._executor.run(command, cwd)
1389
+ if self.process and self.process.poll() is not None:
1390
+ stdout, stderr = self.process.communicate()
1391
+ out_str = stdout.decode(errors="ignore") if stdout else ""
1392
+ err_str = stderr.decode(errors="ignore") if stderr else ""
1393
+ console.print(
1394
+ "[red]Vite process exited immediately.[/]\n"
1395
+ f"[red]Command:[/] {' '.join(command)}\n"
1396
+ f"[red]Exit code:[/] {self.process.returncode}\n"
1397
+ f"[red]Stdout:[/]\n{out_str or '<empty>'}\n"
1398
+ f"[red]Stderr:[/]\n{err_str or '<empty>'}\n"
1399
+ "[yellow]Hint: Run `litestar assets doctor` to diagnose configuration issues.[/]"
1400
+ )
1401
+ msg = f"Vite process failed to start (exit {self.process.returncode})"
1402
+ raise ViteProcessError( # noqa: TRY301
1403
+ msg, command=command, exit_code=self.process.returncode, stderr=err_str, stdout=out_str
1404
+ )
1405
+ except Exception as e:
1406
+ if isinstance(e, ViteProcessError):
1407
+ raise
1408
+ console.print(f"[red]Failed to start Vite process: {e!s}[/]")
1409
+ msg = f"Failed to start Vite process: {e!s}"
1410
+ raise ViteProcessError(msg) from e
1411
+
1412
+ def stop(self, timeout: float = 5.0) -> None:
1413
+ """Stop the Vite process and all its child processes.
1414
+
1415
+ Uses process groups to ensure child processes (node, astro, nuxt, vite, etc.)
1416
+ are terminated along with the parent npm/npx process.
1417
+
1418
+ Args:
1419
+ timeout: Seconds to wait for graceful shutdown before killing.
1420
+
1421
+ Raises:
1422
+ ViteProcessError: If the process fails to stop.
1423
+ """
1424
+ try:
1425
+ with self._lock:
1426
+ self._terminate_process_group(timeout)
1427
+ except Exception as e:
1428
+ console.print(f"[red]Failed to stop Vite process: {e!s}[/]")
1429
+ msg = f"Failed to stop Vite process: {e!s}"
1430
+ raise ViteProcessError(msg) from e
1431
+
1432
+ def _terminate_process_group(self, timeout: float) -> None:
1433
+ """Terminate the process group, waiting and killing if needed.
1434
+
1435
+ When available, uses process group termination to ensure all child processes are stopped
1436
+ (e.g., Vite spawning Node/SSR framework processes). The process is started with
1437
+ ``start_new_session=True`` so the process id is the group id.
1438
+ """
1439
+ if not self.process or self.process.poll() is not None:
1440
+ return
1441
+ pid = self.process.pid
1442
+ try:
1443
+ os.killpg(pid, signal.SIGTERM)
1444
+ except AttributeError:
1445
+ self.process.terminate()
1446
+ except ProcessLookupError:
1447
+ pass
1448
+ try:
1449
+ self.process.wait(timeout=timeout)
1450
+ except subprocess.TimeoutExpired:
1451
+ self._force_kill_process_group()
1452
+ self.process.wait(timeout=1.0)
1453
+ finally:
1454
+ self.process = None
1455
+
1456
+ def _force_kill_process_group(self) -> None:
1457
+ """Force kill the process group if still alive."""
1458
+ if not self.process:
1459
+ return
1460
+ pid = self.process.pid
1461
+ try:
1462
+ os.killpg(pid, signal.SIGKILL)
1463
+ except AttributeError:
1464
+ self.process.kill()
1465
+ except ProcessLookupError:
1466
+ pass
1467
+
1468
+ def _atexit_stop(self) -> None:
1469
+ """Best-effort stop on interpreter exit."""
1470
+ with suppress(Exception):
1471
+ self.stop()
1472
+
1473
+
1474
+ class VitePlugin(InitPluginProtocol, CLIPlugin):
1475
+ """Vite plugin for Litestar.
1476
+
1477
+ This plugin integrates Vite with Litestar, providing:
1478
+
1479
+ - Static file serving configuration
1480
+ - Jinja2 template callables for asset tags
1481
+ - Vite dev server process management
1482
+ - Async asset loader initialization
1483
+
1484
+ Example::
1485
+
1486
+ from litestar import Litestar
1487
+ from litestar_vite import VitePlugin, ViteConfig
1488
+
1489
+ app = Litestar(
1490
+ plugins=[
1491
+ VitePlugin(config=ViteConfig(dev_mode=True))
1492
+ ],
1493
+ )
1494
+ """
1495
+
1496
+ __slots__ = ("_asset_loader", "_config", "_proxy_target", "_spa_handler", "_static_files_config", "_vite_process")
1497
+
1498
+ def __init__(
1499
+ self,
1500
+ config: "ViteConfig | None" = None,
1501
+ asset_loader: "ViteAssetLoader | None" = None,
1502
+ static_files_config: "StaticFilesConfig | None" = None,
1503
+ ) -> None:
1504
+ """Initialize the Vite plugin.
1505
+
1506
+ Args:
1507
+ config: Vite configuration. Defaults to ViteConfig() if not provided.
1508
+ asset_loader: Optional pre-initialized asset loader.
1509
+ static_files_config: Optional configuration for static file serving.
1510
+ """
1511
+ from litestar_vite.config import ViteConfig
1512
+
1513
+ if config is None:
1514
+ config = ViteConfig()
26
1515
  self._config = config
1516
+ self._asset_loader = asset_loader
1517
+ self._vite_process = ViteProcess(executor=config.executor)
1518
+ self._static_files_config: dict[str, Any] = static_files_config.__dict__ if static_files_config else {}
1519
+ self._proxy_target: "str | None" = None
1520
+ self._spa_handler: "AppHandler | None" = None
1521
+
1522
+ @property
1523
+ def config(self) -> "ViteConfig":
1524
+ """Get the Vite configuration.
1525
+
1526
+ Returns:
1527
+ The ViteConfig instance.
1528
+ """
1529
+ return self._config
1530
+
1531
+ @property
1532
+ def asset_loader(self) -> "ViteAssetLoader":
1533
+ """Get the asset loader instance.
1534
+
1535
+ Lazily initializes the loader if not already set.
1536
+
1537
+ Returns:
1538
+ The ViteAssetLoader instance.
1539
+ """
1540
+
1541
+ if self._asset_loader is None:
1542
+ self._asset_loader = ViteAssetLoader.initialize_loader(config=self._config)
1543
+ return self._asset_loader
1544
+
1545
+ @property
1546
+ def spa_handler(self) -> "AppHandler | None":
1547
+ """Return the configured SPA handler when SPA mode is enabled.
1548
+
1549
+ Returns:
1550
+ The AppHandler instance, or None when SPA mode is disabled/not configured.
1551
+ """
1552
+ return self._spa_handler
1553
+
1554
+ def _resolve_bundle_dir(self) -> Path:
1555
+ """Resolve the bundle directory to an absolute path.
1556
+
1557
+ Returns:
1558
+ The absolute path to the bundle directory.
1559
+ """
1560
+ bundle_dir = Path(self._config.bundle_dir)
1561
+ if not bundle_dir.is_absolute():
1562
+ return self._config.root_dir / bundle_dir
1563
+ return bundle_dir
1564
+
1565
+ def _resolve_hotfile_path(self) -> Path:
1566
+ """Resolve the path to the hotfile.
1567
+
1568
+ Returns:
1569
+ The absolute path to the hotfile.
1570
+ """
1571
+ return self._resolve_bundle_dir() / self._config.hot_file
1572
+
1573
+ def _write_hotfile(self, content: str) -> None:
1574
+ """Write content to the hotfile.
1575
+
1576
+ Args:
1577
+ content: The content to write (usually the dev server URL).
1578
+ """
1579
+ hotfile_path = self._resolve_hotfile_path()
1580
+ hotfile_path.parent.mkdir(parents=True, exist_ok=True)
1581
+ hotfile_path.write_text(content, encoding="utf-8")
1582
+
1583
+ def _resolve_dev_command(self) -> "list[str]":
1584
+ """Resolve the command to run for the dev server.
1585
+
1586
+ Returns:
1587
+ The list of command arguments.
1588
+ """
1589
+ ext = self._config.runtime.external_dev_server
1590
+ if isinstance(ext, ExternalDevServer) and ext.enabled:
1591
+ command = ext.command or self._config.executor.start_command
1592
+ _log_info(f"Starting external dev server: {' '.join(command)}")
1593
+ return command
1594
+
1595
+ if self._config.hot_reload:
1596
+ _log_info("Starting Vite dev server (HMR enabled)")
1597
+ return self._config.run_command
1598
+
1599
+ _log_info("Starting Vite watch build process")
1600
+ return self._config.build_watch_command
1601
+
1602
+ def _ensure_proxy_target(self) -> None:
1603
+ """Prepare proxy target URL and port for proxy modes (vite, proxy, ssr).
1604
+
1605
+ For all proxy modes in dev mode:
1606
+ - Auto-selects a free port if VITE_PORT is not explicitly set
1607
+ - Sets the port in runtime config for JS integrations to read
1608
+
1609
+ For 'vite' mode specifically:
1610
+ - Forces loopback host unless VITE_ALLOW_REMOTE is set
1611
+ - Sets _proxy_target directly (JS writes hotfile when server starts)
1612
+
1613
+ For 'proxy'/'ssr' modes:
1614
+ - Port is written to .litestar.json for SSR framework to read
1615
+ - SSR framework writes hotfile with actual URL when ready
1616
+ - Proxy discovers target from hotfile at request time
1617
+ """
1618
+ if not self._config.is_dev_mode:
1619
+ return
1620
+
1621
+ if self._config.proxy_mode is None:
1622
+ return
1623
+
1624
+ if os.getenv("VITE_PORT") is None and self._config.runtime.port == 5173:
1625
+ self._config.runtime.port = _pick_free_port()
1626
+
1627
+ if self._config.proxy_mode == "vite":
1628
+ if self._proxy_target is not None:
1629
+ return
1630
+ if os.getenv("VITE_ALLOW_REMOTE", "False") not in TRUE_VALUES:
1631
+ self._config.runtime.host = "127.0.0.1"
1632
+ self._proxy_target = f"{self._config.protocol}://{self._config.host}:{self._config.port}"
1633
+
1634
+ def _configure_inertia(self, app_config: "AppConfig") -> "AppConfig":
1635
+ """Configure Inertia.js by registering an InertiaPlugin instance.
1636
+
1637
+ This is called automatically when `inertia` config is provided to ViteConfig.
1638
+ Users can still use InertiaPlugin manually for more control.
1639
+
1640
+ Args:
1641
+ app_config: The Litestar application configuration.
1642
+
1643
+ Returns:
1644
+ The modified application configuration.
1645
+ """
1646
+ from litestar_vite.inertia.plugin import InertiaPlugin
1647
+
1648
+ inertia_plugin = InertiaPlugin(config=self._config.inertia) # type: ignore[arg-type]
1649
+ app_config.plugins.append(inertia_plugin)
27
1650
 
28
- def on_cli_init(self, cli: Group) -> None:
1651
+ return app_config
1652
+
1653
+ def on_cli_init(self, cli: "Group") -> None:
1654
+ """Register CLI commands.
1655
+
1656
+ Args:
1657
+ cli: The Click command group to add commands to.
1658
+ """
29
1659
  from litestar_vite.cli import vite_group
30
1660
 
31
1661
  cli.add_command(vite_group)
32
- return super().on_cli_init(cli)
33
1662
 
34
- def on_app_init(self, app_config: AppConfig) -> AppConfig:
35
- """Configure application for use with Vite.
1663
+ def _configure_jinja_callables(self, app_config: "AppConfig") -> None:
1664
+ """Register Jinja2 template callables for Vite asset handling.
1665
+
1666
+ Args:
1667
+ app_config: The Litestar application configuration.
1668
+ """
1669
+ from litestar.contrib.jinja import JinjaTemplateEngine
1670
+
1671
+ from litestar_vite.loader import render_asset_tag, render_hmr_client, render_routes, render_static_asset
1672
+
1673
+ template_config = app_config.template_config # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
1674
+ if template_config and isinstance(
1675
+ template_config.engine_instance, # pyright: ignore[reportUnknownMemberType]
1676
+ JinjaTemplateEngine,
1677
+ ):
1678
+ engine = template_config.engine_instance # pyright: ignore[reportUnknownMemberType]
1679
+ engine.register_template_callable(key="vite_hmr", template_callable=render_hmr_client)
1680
+ engine.register_template_callable(key="vite", template_callable=render_asset_tag)
1681
+ engine.register_template_callable(key="vite_static", template_callable=render_static_asset)
1682
+ engine.register_template_callable(key="vite_routes", template_callable=render_routes)
1683
+
1684
+ def _configure_static_files(self, app_config: "AppConfig") -> None:
1685
+ """Configure static file serving for Vite assets.
1686
+
1687
+ The static files router serves real files (JS, CSS, images). SPA fallback (serving
1688
+ index.html for client-side routes) is handled by the AppHandler.
1689
+
1690
+ Args:
1691
+ app_config: The Litestar application configuration.
1692
+ """
1693
+ bundle_dir = self._resolve_bundle_dir()
1694
+
1695
+ resource_dir = Path(self._config.resource_dir)
1696
+ if not resource_dir.is_absolute():
1697
+ resource_dir = self._config.root_dir / resource_dir
1698
+
1699
+ static_dir = Path(self._config.static_dir)
1700
+ if not static_dir.is_absolute():
1701
+ static_dir = self._config.root_dir / static_dir
1702
+
1703
+ static_dirs = [bundle_dir, resource_dir]
1704
+ if static_dir.exists() and static_dir != bundle_dir:
1705
+ static_dirs.append(static_dir)
1706
+
1707
+ opt: dict[str, Any] = {}
1708
+ if self._config.exclude_static_from_auth:
1709
+ opt["exclude_from_auth"] = True
1710
+ user_opt = self._static_files_config.get("opt", {})
1711
+ if user_opt:
1712
+ opt = {**opt, **user_opt}
1713
+
1714
+ base_config: dict[str, Any] = {
1715
+ "directories": (static_dirs if self._config.is_dev_mode else [bundle_dir]),
1716
+ "path": self._config.asset_url,
1717
+ "name": "vite",
1718
+ "html_mode": False,
1719
+ "include_in_schema": False,
1720
+ "opt": opt,
1721
+ "exception_handlers": {NotFoundException: _static_not_found_handler},
1722
+ }
1723
+ user_config = {k: v for k, v in self._static_files_config.items() if k != "opt"}
1724
+ static_files_config: dict[str, Any] = {**base_config, **user_config}
1725
+ app_config.route_handlers.append(create_static_files_router(**static_files_config))
1726
+
1727
+ def _configure_dev_proxy(self, app_config: "AppConfig") -> None:
1728
+ """Configure dev proxy middleware and handlers based on proxy_mode.
36
1729
 
37
1730
  Args:
38
- app_config: The :class:`AppConfig <.config.app.AppConfig>` instance.
1731
+ app_config: The Litestar application configuration.
39
1732
  """
40
- app_config.template_config = ViteTemplateConfig(
41
- directory=self._config.templates_dir,
42
- engine=ViteTemplateEngine,
43
- config=self._config,
1733
+ proxy_mode = self._config.proxy_mode
1734
+ hotfile_path = self._resolve_hotfile_path()
1735
+
1736
+ if proxy_mode == "vite":
1737
+ self._configure_vite_proxy(app_config, hotfile_path)
1738
+ elif proxy_mode == "proxy":
1739
+ self._configure_ssr_proxy(app_config, hotfile_path)
1740
+
1741
+ def _configure_vite_proxy(self, app_config: "AppConfig", hotfile_path: Path) -> None:
1742
+ """Configure Vite proxy mode (allow list).
1743
+
1744
+ Args:
1745
+ app_config: The Litestar application configuration.
1746
+ hotfile_path: Path to the hotfile.
1747
+ """
1748
+ self._ensure_proxy_target()
1749
+ app_config.middleware.append(
1750
+ DefineMiddleware(
1751
+ ViteProxyMiddleware,
1752
+ hotfile_path=hotfile_path,
1753
+ asset_url=self._config.asset_url,
1754
+ resource_dir=self._config.resource_dir,
1755
+ bundle_dir=self._config.bundle_dir,
1756
+ root_dir=self._config.root_dir,
1757
+ http2=self._config.http2,
1758
+ )
1759
+ )
1760
+ hmr_path = f"{self._config.asset_url.rstrip('/')}/vite-hmr"
1761
+ app_config.route_handlers.append(
1762
+ create_vite_hmr_handler(hotfile_path=hotfile_path, hmr_path=hmr_path, asset_url=self._config.asset_url)
44
1763
  )
1764
+
1765
+ def _configure_ssr_proxy(self, app_config: "AppConfig", hotfile_path: Path) -> None:
1766
+ """Configure SSR proxy mode (deny list).
1767
+
1768
+ Args:
1769
+ app_config: The Litestar application configuration.
1770
+ hotfile_path: Path to the hotfile.
1771
+ """
1772
+ self._ensure_proxy_target()
1773
+ external = self._config.external_dev_server
1774
+ static_target = external.target if external else None
1775
+
1776
+ app_config.route_handlers.append(
1777
+ create_ssr_proxy_controller(
1778
+ target=static_target,
1779
+ hotfile_path=hotfile_path if static_target is None else None,
1780
+ http2=external.http2 if external else True,
1781
+ )
1782
+ )
1783
+ hmr_path = f"{self._config.asset_url.rstrip('/')}/vite-hmr"
1784
+ app_config.route_handlers.append(
1785
+ create_vite_hmr_handler(hotfile_path=hotfile_path, hmr_path=hmr_path, asset_url=self._config.asset_url)
1786
+ )
1787
+
1788
+ def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
1789
+ """Configure the Litestar application for Vite.
1790
+
1791
+ This method wires up supporting configuration for dev/prod operation:
1792
+
1793
+ - Adds types used by generated handlers to the signature namespace.
1794
+ - Ensures a consistent NotFound handler for asset/proxy lookups.
1795
+ - Registers optional Inertia and Jinja integrations.
1796
+ - Configures static file routing when enabled.
1797
+ - Configures dev proxy middleware based on proxy_mode.
1798
+ - Creates/initializes the SPA handler where applicable and registers lifespans.
1799
+
1800
+ Args:
1801
+ app_config: The Litestar application configuration.
1802
+
1803
+ Returns:
1804
+ The modified application configuration.
1805
+ """
1806
+ from litestar import Response
1807
+ from litestar.connection import Request as LitestarRequest
1808
+
1809
+ app_config.signature_namespace["Response"] = Response
1810
+ app_config.signature_namespace["Request"] = LitestarRequest
1811
+
1812
+ handlers: ExceptionHandlersMap = cast("ExceptionHandlersMap", app_config.exception_handlers or {}) # pyright: ignore
1813
+ if NotFoundException not in handlers:
1814
+ handlers[NotFoundException] = _vite_not_found_handler
1815
+ app_config.exception_handlers = handlers # pyright: ignore[reportUnknownMemberType]
1816
+
1817
+ if self._config.inertia is not None:
1818
+ app_config = self._configure_inertia(app_config)
1819
+
1820
+ if JINJA_INSTALLED and self._config.mode in {"template", "htmx"}:
1821
+ self._configure_jinja_callables(app_config)
1822
+
1823
+ skip_static = self._config.mode == "external" and self._config.is_dev_mode
1824
+ if self._config.set_static_folders and not skip_static:
1825
+ self._configure_static_files(app_config)
1826
+
1827
+ if self._config.is_dev_mode and self._config.proxy_mode is not None and not _is_non_serving_assets_cli():
1828
+ self._configure_dev_proxy(app_config)
1829
+
1830
+ use_spa_handler = self._config.spa_handler and self._config.mode in {"spa", "ssr"}
1831
+ use_spa_handler = use_spa_handler or (self._config.mode == "external" and not self._config.is_dev_mode)
1832
+ if use_spa_handler:
1833
+ from litestar_vite._handler import AppHandler
1834
+
1835
+ self._spa_handler = AppHandler(self._config)
1836
+ app_config.route_handlers.append(self._spa_handler.create_route_handler())
1837
+ elif self._config.mode == "hybrid":
1838
+ from litestar_vite._handler import AppHandler
1839
+
1840
+ self._spa_handler = AppHandler(self._config)
1841
+
1842
+ app_config.lifespan.append(self.lifespan) # pyright: ignore[reportUnknownMemberType]
1843
+
45
1844
  return app_config
1845
+
1846
+ def _check_health(self) -> None:
1847
+ """Check if the Vite dev server is running and ready.
1848
+
1849
+ Polls the dev server URL for up to 5 seconds.
1850
+ """
1851
+ import time
1852
+
1853
+ url = f"{self._config.protocol}://{self._config.host}:{self._config.port}/__vite_ping"
1854
+ for _ in range(50):
1855
+ try:
1856
+ httpx.get(url, timeout=0.1)
1857
+ except httpx.HTTPError:
1858
+ time.sleep(0.1)
1859
+ else:
1860
+ _log_success("Vite dev server responded to health check")
1861
+ return
1862
+ _log_fail("Vite server health check failed")
1863
+
1864
+ def _run_health_check(self) -> None:
1865
+ """Run the appropriate health check based on proxy mode."""
1866
+ match self._config.proxy_mode:
1867
+ case "proxy":
1868
+ self._check_ssr_health(self._resolve_hotfile_path())
1869
+ case _:
1870
+ self._check_health()
1871
+
1872
+ def _check_ssr_health(self, hotfile_path: Path, timeout: float = 10.0) -> bool:
1873
+ """Wait for SSR framework to be ready via hotfile.
1874
+
1875
+ Polls intelligently for the hotfile and validates HTTP connectivity.
1876
+ Exits early as soon as the server is confirmed ready.
1877
+
1878
+ Args:
1879
+ hotfile_path: Path to the hotfile written by the SSR framework.
1880
+ timeout: Maximum time to wait in seconds (default 10s).
1881
+
1882
+ Returns:
1883
+ True if SSR server is ready, False if timeout reached.
1884
+ """
1885
+ import time
1886
+
1887
+ start = time.time()
1888
+ last_url = None
1889
+
1890
+ while time.time() - start < timeout:
1891
+ if hotfile_path.exists():
1892
+ try:
1893
+ url = hotfile_path.read_text(encoding="utf-8").strip()
1894
+ if url:
1895
+ last_url = url
1896
+ resp = httpx.get(url, timeout=0.5, follow_redirects=True)
1897
+ if resp.status_code < 500:
1898
+ _log_success(f"SSR server ready at {url}")
1899
+ return True
1900
+ except OSError:
1901
+ pass
1902
+ except httpx.HTTPError:
1903
+ pass
1904
+
1905
+ time.sleep(0.1)
1906
+
1907
+ if last_url:
1908
+ _log_fail(f"SSR server at {last_url} did not respond within {timeout}s")
1909
+ else:
1910
+ _log_fail(f"SSR hotfile not found at {hotfile_path} within {timeout}s")
1911
+ return False
1912
+
1913
+ def _export_types_sync(self, app: "Litestar") -> None:
1914
+ """Export type metadata synchronously on startup.
1915
+
1916
+ This exports OpenAPI schema, route metadata (JSON), and typed routes (TypeScript)
1917
+ when type generation is enabled. The Vite plugin watches these files and triggers
1918
+ @hey-api/openapi-ts when they change.
1919
+
1920
+ Args:
1921
+ app: The Litestar application instance.
1922
+ """
1923
+ from litestar_vite.config import TypeGenConfig
1924
+
1925
+ if not isinstance(self._config.types, TypeGenConfig):
1926
+ return
1927
+ types_config = self._config.types
1928
+
1929
+ try:
1930
+ import msgspec
1931
+ from litestar._openapi.plugin import OpenAPIPlugin
1932
+ from litestar.serialization import encode_json, get_serializer
1933
+
1934
+ from litestar_vite.codegen import generate_routes_json, generate_routes_ts
1935
+
1936
+ openapi_plugin = next((p for p in app.plugins._plugins if isinstance(p, OpenAPIPlugin)), None) # pyright: ignore[reportPrivateUsage]
1937
+ has_openapi = openapi_plugin is not None and openapi_plugin._openapi_config is not None # pyright: ignore[reportPrivateUsage]
1938
+
1939
+ exported_files: list[str] = []
1940
+ unchanged_files: list[str] = []
1941
+
1942
+ openapi_schema = self._export_openapi_schema_sync(
1943
+ app=app,
1944
+ has_openapi=has_openapi,
1945
+ types_config=types_config,
1946
+ msgspec=msgspec,
1947
+ encode_json=encode_json,
1948
+ get_serializer=get_serializer,
1949
+ exported_files=exported_files,
1950
+ unchanged_files=unchanged_files,
1951
+ )
1952
+
1953
+ routes_data = generate_routes_json(app, include_components=True, openapi_schema=openapi_schema)
1954
+ routes_data["litestar_version"] = resolve_litestar_version()
1955
+ self._export_routes_json_sync(
1956
+ msgspec=msgspec,
1957
+ types_config=types_config,
1958
+ encode_json=encode_json,
1959
+ routes_data=routes_data,
1960
+ exported_files=exported_files,
1961
+ unchanged_files=unchanged_files,
1962
+ )
1963
+
1964
+ if types_config.generate_routes:
1965
+ routes_ts_content = generate_routes_ts(app, openapi_schema=openapi_schema)
1966
+ self._export_routes_ts_sync(
1967
+ types_config=types_config,
1968
+ routes_ts_content=routes_ts_content,
1969
+ exported_files=exported_files,
1970
+ unchanged_files=unchanged_files,
1971
+ )
1972
+
1973
+ if exported_files:
1974
+ _log_success(f"Types exported → {', '.join(exported_files)}")
1975
+ except (OSError, TypeError, ValueError, ImportError) as e: # pragma: no cover
1976
+ _log_warn(f"Type export failed: {e}")
1977
+
1978
+ def _export_openapi_schema_sync(
1979
+ self,
1980
+ *,
1981
+ app: "Litestar",
1982
+ has_openapi: bool,
1983
+ types_config: "TypeGenConfig",
1984
+ msgspec: Any,
1985
+ encode_json: Any,
1986
+ get_serializer: Any,
1987
+ exported_files: list[str],
1988
+ unchanged_files: list[str],
1989
+ ) -> "dict[str, Any] | None":
1990
+ if not has_openapi:
1991
+ console.print("[yellow]! OpenAPI schema not available; skipping openapi.json export[/]")
1992
+ return None
1993
+
1994
+ try:
1995
+ encoders: Any
1996
+ try:
1997
+ encoders = app.type_encoders # pyright: ignore[reportUnknownMemberType]
1998
+ except AttributeError:
1999
+ encoders = None
2000
+
2001
+ serializer = get_serializer(encoders if isinstance(encoders, dict) else None)
2002
+ schema_dict = app.openapi_schema.to_schema()
2003
+ schema_content = msgspec.json.format(encode_json(schema_dict, serializer=serializer), indent=2)
2004
+
2005
+ openapi_path = types_config.openapi_path
2006
+ if openapi_path is None:
2007
+ openapi_path = types_config.output / "openapi.json"
2008
+ if _write_if_changed(openapi_path, schema_content):
2009
+ exported_files.append(f"openapi: {_fmt_path(openapi_path)}")
2010
+ else:
2011
+ unchanged_files.append("openapi.json")
2012
+ except (TypeError, ValueError, OSError, AttributeError) as exc: # pragma: no cover
2013
+ console.print(f"[yellow]! OpenAPI export skipped: {exc}[/]")
2014
+ else:
2015
+ return schema_dict
2016
+ return None
2017
+
2018
+ def _export_routes_json_sync(
2019
+ self,
2020
+ *,
2021
+ msgspec: Any,
2022
+ types_config: "TypeGenConfig",
2023
+ encode_json: Any,
2024
+ routes_data: dict[str, Any],
2025
+ exported_files: list[str],
2026
+ unchanged_files: list[str],
2027
+ ) -> None:
2028
+ routes_content = msgspec.json.format(encode_json(routes_data), indent=2)
2029
+ routes_path = types_config.routes_path
2030
+ if routes_path is None:
2031
+ routes_path = types_config.output / "routes.json"
2032
+ if _write_if_changed(routes_path, routes_content):
2033
+ exported_files.append(_fmt_path(routes_path))
2034
+ else:
2035
+ unchanged_files.append("routes.json")
2036
+
2037
+ def _export_routes_ts_sync(
2038
+ self,
2039
+ *,
2040
+ types_config: "TypeGenConfig",
2041
+ routes_ts_content: str,
2042
+ exported_files: list[str],
2043
+ unchanged_files: list[str],
2044
+ ) -> None:
2045
+ routes_ts_path = types_config.routes_ts_path
2046
+ if routes_ts_path is None:
2047
+ routes_ts_path = types_config.output / "routes.ts"
2048
+ if _write_if_changed(routes_ts_path, routes_ts_content):
2049
+ exported_files.append(_fmt_path(routes_ts_path))
2050
+ else:
2051
+ unchanged_files.append("routes.ts")
2052
+
2053
+ @contextmanager
2054
+ def server_lifespan(self, app: "Litestar") -> "Iterator[None]":
2055
+ """Server-level lifespan context manager (runs ONCE per server, before workers).
2056
+
2057
+ This is called by Litestar CLI before workers start. It handles:
2058
+ - Environment variable setup (with logging)
2059
+ - Vite dev server process start/stop (ONE instance for all workers)
2060
+ - Type export on startup
2061
+
2062
+ Note: SPA handler and asset loader initialization happens in the per-worker
2063
+ `lifespan` method, which is auto-registered in `on_app_init`.
2064
+
2065
+ Hotfile behavior: the hotfile is written before starting the dev server to ensure proxy
2066
+ middleware and SPA handlers can resolve a target URL immediately on first request.
2067
+
2068
+ Args:
2069
+ app: The Litestar application instance.
2070
+
2071
+ Yields:
2072
+ None
2073
+ """
2074
+ if self._config.is_dev_mode:
2075
+ self._ensure_proxy_target()
2076
+
2077
+ if self._config.set_environment:
2078
+ set_environment(config=self._config)
2079
+ set_app_environment(app)
2080
+ _log_info("Applied Vite environment variables")
2081
+
2082
+ self._export_types_sync(app)
2083
+
2084
+ if self._config.is_dev_mode and self._config.runtime.start_dev_server:
2085
+ ext = self._config.runtime.external_dev_server
2086
+ is_external = isinstance(ext, ExternalDevServer) and ext.enabled
2087
+
2088
+ command_to_run = self._resolve_dev_command()
2089
+ if is_external and isinstance(ext, ExternalDevServer) and ext.target:
2090
+ self._write_hotfile(ext.target)
2091
+ elif not is_external:
2092
+ target_url = f"{self._config.protocol}://{self._config.host}:{self._config.port}"
2093
+ self._write_hotfile(target_url)
2094
+
2095
+ try:
2096
+ self._vite_process.start(command_to_run, self._config.root_dir)
2097
+ _log_success("Dev server process started")
2098
+ if self._config.health_check and not is_external:
2099
+ self._run_health_check()
2100
+ yield
2101
+ finally:
2102
+ self._vite_process.stop()
2103
+ _log_info("Dev server process stopped.")
2104
+ else:
2105
+ yield
2106
+
2107
+ @asynccontextmanager
2108
+ async def lifespan(self, app: "Litestar") -> "AsyncIterator[None]":
2109
+ """Worker-level lifespan context manager (runs per worker process).
2110
+
2111
+ This is auto-registered in `on_app_init` and handles per-worker initialization:
2112
+ - Environment variable setup (silently - each worker needs process-local env vars)
2113
+ - Asset loader initialization
2114
+ - SPA handler initialization
2115
+ - Route metadata injection
2116
+
2117
+ Note: The Vite dev server process is started in `server_lifespan`, which
2118
+ runs ONCE per server before workers start.
2119
+
2120
+ Args:
2121
+ app: The Litestar application instance.
2122
+
2123
+ Yields:
2124
+ None
2125
+ """
2126
+ from litestar_vite.loader import ViteAssetLoader
2127
+
2128
+ if self._config.set_environment:
2129
+ set_environment(config=self._config)
2130
+ set_app_environment(app)
2131
+
2132
+ if self._asset_loader is None:
2133
+ self._asset_loader = ViteAssetLoader(config=self._config)
2134
+ await self._asset_loader.initialize()
2135
+
2136
+ if self._spa_handler is not None and not self._spa_handler.is_initialized:
2137
+ self._spa_handler.initialize_sync(vite_url=self._proxy_target)
2138
+ _log_success("SPA handler initialized")
2139
+
2140
+ is_ssr_mode = self._config.mode == "ssr" or self._config.proxy_mode == "proxy"
2141
+ if not self._config.is_dev_mode and not self._config.has_built_assets() and not is_ssr_mode:
2142
+ _log_warn(
2143
+ "Vite dev server is disabled (dev_mode=False) but no index.html was found. "
2144
+ "Run your front-end build or set VITE_DEV_MODE=1 to enable HMR."
2145
+ )
2146
+
2147
+ try:
2148
+ yield
2149
+ finally:
2150
+ if self._spa_handler is not None:
2151
+ await self._spa_handler.shutdown_async()
2152
+
2153
+
2154
+ def _normalize_proxy_prefixes(
2155
+ base_prefixes: tuple[str, ...],
2156
+ asset_url: "str | None" = None,
2157
+ resource_dir: "Path | None" = None,
2158
+ bundle_dir: "Path | None" = None,
2159
+ root_dir: "Path | None" = None,
2160
+ ) -> tuple[str, ...]:
2161
+ prefixes: list[str] = list(base_prefixes)
2162
+
2163
+ if asset_url:
2164
+ prefixes.append(_normalize_prefix(asset_url))
2165
+
2166
+ def _add_path(path: Path | str | None) -> None:
2167
+ if path is None:
2168
+ return
2169
+ p = Path(path)
2170
+ if root_dir and p.is_absolute():
2171
+ with suppress(ValueError):
2172
+ p = p.relative_to(root_dir)
2173
+ prefixes.append(_normalize_prefix(str(p).replace("\\", "/")))
2174
+
2175
+ _add_path(resource_dir)
2176
+ _add_path(bundle_dir)
2177
+
2178
+ seen: set[str] = set()
2179
+ unique: list[str] = []
2180
+ for p in prefixes:
2181
+ if p not in seen:
2182
+ unique.append(p)
2183
+ seen.add(p)
2184
+ return tuple(unique)