litestar-vite 0.1.1__py3-none-any.whl → 0.15.0rc2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litestar_vite/__init__.py +54 -4
- litestar_vite/__metadata__.py +12 -7
- litestar_vite/_codegen/__init__.py +26 -0
- litestar_vite/_codegen/inertia.py +407 -0
- litestar_vite/_codegen/openapi.py +233 -0
- litestar_vite/_codegen/routes.py +653 -0
- litestar_vite/_codegen/ts.py +235 -0
- litestar_vite/_handler/__init__.py +8 -0
- litestar_vite/_handler/app.py +524 -0
- litestar_vite/_handler/routing.py +130 -0
- litestar_vite/cli.py +1147 -10
- litestar_vite/codegen.py +39 -0
- litestar_vite/commands.py +79 -0
- litestar_vite/config.py +1594 -70
- litestar_vite/deploy.py +355 -0
- litestar_vite/doctor.py +1179 -0
- litestar_vite/exceptions.py +78 -0
- litestar_vite/executor.py +316 -0
- litestar_vite/handler.py +9 -0
- litestar_vite/html_transform.py +426 -0
- litestar_vite/inertia/__init__.py +53 -0
- litestar_vite/inertia/_utils.py +114 -0
- litestar_vite/inertia/exception_handler.py +172 -0
- litestar_vite/inertia/helpers.py +1043 -0
- litestar_vite/inertia/middleware.py +54 -0
- litestar_vite/inertia/plugin.py +133 -0
- litestar_vite/inertia/request.py +286 -0
- litestar_vite/inertia/response.py +706 -0
- litestar_vite/inertia/types.py +316 -0
- litestar_vite/loader.py +462 -121
- litestar_vite/plugin.py +2160 -21
- litestar_vite/py.typed +0 -0
- litestar_vite/scaffolding/__init__.py +20 -0
- litestar_vite/scaffolding/generator.py +270 -0
- litestar_vite/scaffolding/templates.py +437 -0
- litestar_vite/templates/__init__.py +0 -0
- litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
- litestar_vite/templates/angular/index.html.j2 +12 -0
- litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/angular/package.json.j2 +35 -0
- litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
- litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
- litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
- litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
- litestar_vite/templates/angular/src/main.ts.j2 +9 -0
- litestar_vite/templates/angular/src/styles.css.j2 +9 -0
- litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
- litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
- litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
- litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
- litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
- litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/angular-cli/package.json.j2 +27 -0
- litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
- litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
- litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
- litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
- litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
- litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
- litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
- litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
- litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
- litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
- litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
- litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
- litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
- litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
- litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
- litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
- litestar_vite/templates/base/.gitignore.j2 +42 -0
- litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/base/package.json.j2 +38 -0
- litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
- litestar_vite/templates/base/tsconfig.json.j2 +37 -0
- litestar_vite/templates/htmx/src/main.js.j2 +8 -0
- litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
- litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
- litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
- litestar_vite/templates/nuxt/app.vue.j2 +29 -0
- litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
- litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
- litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
- litestar_vite/templates/react/index.html.j2 +13 -0
- litestar_vite/templates/react/src/App.css.j2 +56 -0
- litestar_vite/templates/react/src/App.tsx.j2 +19 -0
- litestar_vite/templates/react/src/main.tsx.j2 +10 -0
- litestar_vite/templates/react/vite.config.ts.j2 +39 -0
- litestar_vite/templates/react-inertia/index.html.j2 +14 -0
- litestar_vite/templates/react-inertia/package.json.j2 +46 -0
- litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
- litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
- litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
- litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
- litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
- litestar_vite/templates/react-router/index.html.j2 +12 -0
- litestar_vite/templates/react-router/src/App.css.j2 +17 -0
- litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
- litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
- litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
- litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
- litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
- litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
- litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
- litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
- litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
- litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
- litestar_vite/templates/svelte/index.html.j2 +13 -0
- litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
- litestar_vite/templates/svelte/src/app.css.j2 +45 -0
- litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
- litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
- litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
- litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
- litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
- litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
- litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
- litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
- litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
- litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
- litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
- litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
- litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
- litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
- litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
- litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
- litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
- litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
- litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
- litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
- litestar_vite/templates/vue/env.d.ts.j2 +7 -0
- litestar_vite/templates/vue/index.html.j2 +13 -0
- litestar_vite/templates/vue/src/App.vue.j2 +28 -0
- litestar_vite/templates/vue/src/main.ts.j2 +5 -0
- litestar_vite/templates/vue/src/style.css.j2 +45 -0
- litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
- litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
- litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
- litestar_vite/templates/vue-inertia/package.json.j2 +49 -0
- litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
- litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
- litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
- litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
- litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
- litestar_vite-0.15.0rc2.dist-info/METADATA +230 -0
- litestar_vite-0.15.0rc2.dist-info/RECORD +151 -0
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/WHEEL +1 -1
- litestar_vite/template_engine.py +0 -103
- litestar_vite-0.1.1.dist-info/METADATA +0 -68
- litestar_vite-0.1.1.dist-info/RECORD +0 -11
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/licenses/LICENSE +0 -0
litestar_vite/plugin.py
CHANGED
|
@@ -1,45 +1,2184 @@
|
|
|
1
|
-
|
|
1
|
+
"""Vite Plugin for Litestar.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This module provides the VitePlugin class for integrating Vite with Litestar.
|
|
4
|
+
The plugin handles:
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
"""
|
|
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
|
|
1731
|
+
app_config: The Litestar application configuration.
|
|
39
1732
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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)
|