litestar-vite 0.1.1__py3-none-any.whl → 0.15.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litestar_vite/__init__.py +54 -4
- litestar_vite/__metadata__.py +12 -7
- litestar_vite/cli.py +1048 -10
- litestar_vite/codegen/__init__.py +48 -0
- litestar_vite/codegen/_export.py +229 -0
- litestar_vite/codegen/_inertia.py +619 -0
- litestar_vite/codegen/_openapi.py +280 -0
- litestar_vite/codegen/_routes.py +720 -0
- litestar_vite/codegen/_ts.py +235 -0
- litestar_vite/codegen/_utils.py +141 -0
- litestar_vite/commands.py +73 -0
- litestar_vite/config/__init__.py +997 -0
- litestar_vite/config/_constants.py +97 -0
- litestar_vite/config/_deploy.py +70 -0
- litestar_vite/config/_inertia.py +241 -0
- litestar_vite/config/_paths.py +63 -0
- litestar_vite/config/_runtime.py +235 -0
- litestar_vite/config/_spa.py +93 -0
- litestar_vite/config/_types.py +94 -0
- litestar_vite/deploy.py +366 -0
- litestar_vite/doctor.py +1181 -0
- litestar_vite/exceptions.py +78 -0
- litestar_vite/executor.py +360 -0
- litestar_vite/handler/__init__.py +9 -0
- litestar_vite/handler/_app.py +612 -0
- litestar_vite/handler/_routing.py +130 -0
- litestar_vite/html_transform.py +569 -0
- litestar_vite/inertia/__init__.py +77 -0
- litestar_vite/inertia/_utils.py +119 -0
- litestar_vite/inertia/exception_handler.py +178 -0
- litestar_vite/inertia/helpers.py +1571 -0
- litestar_vite/inertia/middleware.py +54 -0
- litestar_vite/inertia/plugin.py +199 -0
- litestar_vite/inertia/precognition.py +274 -0
- litestar_vite/inertia/request.py +334 -0
- litestar_vite/inertia/response.py +802 -0
- litestar_vite/inertia/types.py +335 -0
- litestar_vite/loader.py +464 -123
- litestar_vite/plugin/__init__.py +687 -0
- litestar_vite/plugin/_process.py +185 -0
- litestar_vite/plugin/_proxy.py +689 -0
- litestar_vite/plugin/_proxy_headers.py +244 -0
- litestar_vite/plugin/_static.py +37 -0
- litestar_vite/plugin/_utils.py +489 -0
- 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 +36 -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 +28 -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 +39 -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 +47 -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 +50 -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.0.dist-info/METADATA +230 -0
- litestar_vite-0.15.0.dist-info/RECORD +164 -0
- {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
- litestar_vite/config.py +0 -100
- litestar_vite/plugin.py +0 -45
- 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.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""HTTP/WebSocket proxy middleware and HMR handlers."""
|
|
2
|
+
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import httpx
|
|
9
|
+
import websockets
|
|
10
|
+
from litestar.enums import ScopeType
|
|
11
|
+
from litestar.exceptions import WebSocketDisconnect
|
|
12
|
+
from litestar.middleware import AbstractMiddleware
|
|
13
|
+
|
|
14
|
+
from litestar_vite.plugin._utils import console, is_litestar_route, is_proxy_debug, normalize_prefix
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from litestar.connection import Request
|
|
20
|
+
from litestar.types import ASGIApp, Receive, Scope, Send
|
|
21
|
+
from websockets.typing import Subprotocol
|
|
22
|
+
|
|
23
|
+
from litestar_vite.plugin import VitePlugin
|
|
24
|
+
|
|
25
|
+
_DISCONNECT_EXCEPTIONS = (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed)
|
|
26
|
+
|
|
27
|
+
_PROXY_ALLOW_PREFIXES: tuple[str, ...] = (
|
|
28
|
+
"/@vite",
|
|
29
|
+
"/@id/",
|
|
30
|
+
"/@fs/",
|
|
31
|
+
"/@react-refresh",
|
|
32
|
+
"/@vite/client",
|
|
33
|
+
"/@vite/env",
|
|
34
|
+
"/vite-hmr",
|
|
35
|
+
"/__vite_ping",
|
|
36
|
+
"/node_modules/.vite/",
|
|
37
|
+
"/@analogjs/",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_HOP_BY_HOP_HEADERS = frozenset({
|
|
41
|
+
"connection",
|
|
42
|
+
"keep-alive",
|
|
43
|
+
"proxy-authenticate",
|
|
44
|
+
"proxy-authorization",
|
|
45
|
+
"te",
|
|
46
|
+
"trailers",
|
|
47
|
+
"transfer-encoding",
|
|
48
|
+
"upgrade",
|
|
49
|
+
"content-length",
|
|
50
|
+
"content-encoding",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_proxy_response(upstream_resp: "httpx.Response") -> tuple[int, list[tuple[bytes, bytes]], bytes]:
|
|
55
|
+
"""Extract status, headers, and body from an httpx response for proxying.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A tuple of (status_code, headers, body).
|
|
59
|
+
"""
|
|
60
|
+
headers = [
|
|
61
|
+
(k.encode(), v.encode()) for k, v in upstream_resp.headers.items() if k.lower() not in _HOP_BY_HOP_HEADERS
|
|
62
|
+
]
|
|
63
|
+
return upstream_resp.status_code, headers, upstream_resp.content
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ViteProxyMiddleware(AbstractMiddleware):
|
|
67
|
+
"""ASGI middleware to proxy Vite dev HTTP traffic to internal Vite server.
|
|
68
|
+
|
|
69
|
+
HTTP requests use httpx.AsyncClient with optional HTTP/2 support for better
|
|
70
|
+
connection multiplexing. WebSocket traffic (used by Vite HMR) is handled by
|
|
71
|
+
a dedicated WebSocket route handler created by create_vite_hmr_handler().
|
|
72
|
+
|
|
73
|
+
The middleware reads the Vite server URL from the hotfile dynamically,
|
|
74
|
+
ensuring it always connects to the correct Vite server even if the port changes.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
scopes = {ScopeType.HTTP}
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
app: "ASGIApp",
|
|
82
|
+
hotfile_path: Path,
|
|
83
|
+
asset_url: "str | None" = None,
|
|
84
|
+
resource_dir: "Path | None" = None,
|
|
85
|
+
bundle_dir: "Path | None" = None,
|
|
86
|
+
root_dir: "Path | None" = None,
|
|
87
|
+
http2: bool = True,
|
|
88
|
+
plugin: "VitePlugin | None" = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__(app)
|
|
91
|
+
self.hotfile_path = hotfile_path
|
|
92
|
+
self._cached_target: str | None = None
|
|
93
|
+
self._cache_initialized = False
|
|
94
|
+
self.asset_prefix = normalize_prefix(asset_url) if asset_url else "/"
|
|
95
|
+
self.http2 = http2
|
|
96
|
+
self._plugin = plugin
|
|
97
|
+
self._proxy_allow_prefixes = normalize_proxy_prefixes(
|
|
98
|
+
base_prefixes=_PROXY_ALLOW_PREFIXES,
|
|
99
|
+
asset_url=asset_url,
|
|
100
|
+
resource_dir=resource_dir,
|
|
101
|
+
bundle_dir=bundle_dir,
|
|
102
|
+
root_dir=root_dir,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _get_target_base_url(self) -> str | None:
|
|
106
|
+
"""Read the Vite server URL from the hotfile with permanent caching.
|
|
107
|
+
|
|
108
|
+
The hotfile is read once and cached for the lifetime of the server.
|
|
109
|
+
Server restart refreshes the cache automatically.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The Vite server URL or None if unavailable.
|
|
113
|
+
"""
|
|
114
|
+
if self._cache_initialized:
|
|
115
|
+
return self._cached_target.rstrip("/") if self._cached_target else None
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
url = self.hotfile_path.read_text().strip()
|
|
119
|
+
self._cached_target = url
|
|
120
|
+
self._cache_initialized = True
|
|
121
|
+
if is_proxy_debug():
|
|
122
|
+
console.print(f"[dim][vite-proxy] Target: {url}[/]")
|
|
123
|
+
return url.rstrip("/")
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
self._cached_target = None
|
|
126
|
+
self._cache_initialized = True
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
|
|
130
|
+
scope_dict = cast("dict[str, Any]", scope)
|
|
131
|
+
path = scope_dict.get("path", "")
|
|
132
|
+
should = self._should_proxy(path, scope)
|
|
133
|
+
if is_proxy_debug():
|
|
134
|
+
console.print(f"[dim][vite-proxy] {path} → proxy={should}[/]")
|
|
135
|
+
if should:
|
|
136
|
+
await self._proxy_http(scope_dict, receive, send)
|
|
137
|
+
return
|
|
138
|
+
await self.app(scope, receive, send)
|
|
139
|
+
|
|
140
|
+
def _should_proxy(self, path: str, scope: "Scope") -> bool:
|
|
141
|
+
try:
|
|
142
|
+
from urllib.parse import unquote
|
|
143
|
+
except ImportError: # pragma: no cover
|
|
144
|
+
decoded = path
|
|
145
|
+
matches_prefix = path.startswith(self._proxy_allow_prefixes)
|
|
146
|
+
else:
|
|
147
|
+
decoded = unquote(path)
|
|
148
|
+
matches_prefix = decoded.startswith(self._proxy_allow_prefixes) or path.startswith(
|
|
149
|
+
self._proxy_allow_prefixes
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if not matches_prefix:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
app = scope.get("app") # pyright: ignore[reportUnknownMemberType]
|
|
156
|
+
return not (app and is_litestar_route(path, app))
|
|
157
|
+
|
|
158
|
+
async def _proxy_http(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
159
|
+
"""Proxy a single HTTP request to the Vite dev server.
|
|
160
|
+
|
|
161
|
+
The upstream response is buffered inside the httpx client context manager and only sent
|
|
162
|
+
after the context exits. This avoids ASGI errors when httpx raises during cleanup after the
|
|
163
|
+
response has started.
|
|
164
|
+
"""
|
|
165
|
+
target_base_url = self._get_target_base_url()
|
|
166
|
+
if target_base_url is None:
|
|
167
|
+
await send({"type": "http.response.start", "status": 503, "headers": [(b"content-type", b"text/plain")]})
|
|
168
|
+
await send({"type": "http.response.body", "body": b"Vite dev server not running", "more_body": False})
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
method = scope.get("method", "GET")
|
|
172
|
+
raw_path = scope.get("raw_path", b"").decode()
|
|
173
|
+
query_string = scope.get("query_string", b"").decode()
|
|
174
|
+
proxied_path = raw_path
|
|
175
|
+
if self.asset_prefix != "/" and not raw_path.startswith(self.asset_prefix):
|
|
176
|
+
proxied_path = f"{self.asset_prefix.rstrip('/')}{raw_path}"
|
|
177
|
+
|
|
178
|
+
url = f"{target_base_url}{proxied_path}"
|
|
179
|
+
if query_string:
|
|
180
|
+
url = f"{url}?{query_string}"
|
|
181
|
+
|
|
182
|
+
headers = [(k.decode(), v.decode()) for k, v in scope.get("headers", [])]
|
|
183
|
+
body = b""
|
|
184
|
+
more_body = True
|
|
185
|
+
while more_body:
|
|
186
|
+
event = await receive()
|
|
187
|
+
if event["type"] != "http.request":
|
|
188
|
+
continue
|
|
189
|
+
body += event.get("body", b"")
|
|
190
|
+
more_body = event.get("more_body", False)
|
|
191
|
+
|
|
192
|
+
response_status = 502
|
|
193
|
+
response_headers: list[tuple[bytes, bytes]] = [(b"content-type", b"text/plain")]
|
|
194
|
+
response_body = b"Bad gateway"
|
|
195
|
+
got_full_body = False
|
|
196
|
+
|
|
197
|
+
# Use shared client from plugin when available (connection pooling)
|
|
198
|
+
client = self._plugin.proxy_client if self._plugin is not None else None
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
if client is not None:
|
|
202
|
+
# Use shared client (connection pooling, HTTP/2 multiplexing)
|
|
203
|
+
upstream_resp = await client.request(method, url, headers=headers, content=body, timeout=10.0)
|
|
204
|
+
response_status, response_headers, response_body = _extract_proxy_response(upstream_resp)
|
|
205
|
+
got_full_body = True
|
|
206
|
+
else:
|
|
207
|
+
# Fallback: per-request client (graceful degradation)
|
|
208
|
+
http2_enabled = check_http2_support(self.http2)
|
|
209
|
+
async with httpx.AsyncClient(http2=http2_enabled) as fallback_client:
|
|
210
|
+
upstream_resp = await fallback_client.request(
|
|
211
|
+
method, url, headers=headers, content=body, timeout=10.0
|
|
212
|
+
)
|
|
213
|
+
response_status, response_headers, response_body = _extract_proxy_response(upstream_resp)
|
|
214
|
+
got_full_body = True
|
|
215
|
+
except Exception as exc: # noqa: BLE001 # pragma: no cover - catch all cleanup errors
|
|
216
|
+
if not got_full_body:
|
|
217
|
+
response_body = f"Upstream error: {exc}".encode()
|
|
218
|
+
|
|
219
|
+
await send({"type": "http.response.start", "status": response_status, "headers": response_headers})
|
|
220
|
+
await send({"type": "http.response.body", "body": response_body, "more_body": False})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_hmr_target_url(hotfile_path: Path, scope: dict[str, Any], hmr_path: str, asset_url: str) -> "str | None":
|
|
224
|
+
"""Build the target WebSocket URL for Vite HMR proxy.
|
|
225
|
+
|
|
226
|
+
Vite's HMR WebSocket listens at {base}{hmr.path}, so we preserve
|
|
227
|
+
the full path including the asset prefix (e.g., /static/vite-hmr).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
The target WebSocket URL or None if the hotfile is not found.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
vite_url = hotfile_path.read_text(encoding="utf-8").strip()
|
|
234
|
+
except FileNotFoundError:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
ws_url = vite_url.replace("http://", "ws://").replace("https://", "wss://")
|
|
238
|
+
original_path = scope.get("path", hmr_path)
|
|
239
|
+
query_string = scope.get("query_string", b"").decode()
|
|
240
|
+
|
|
241
|
+
target = f"{ws_url}{original_path}"
|
|
242
|
+
if query_string:
|
|
243
|
+
target = f"{target}?{query_string}"
|
|
244
|
+
|
|
245
|
+
if is_proxy_debug():
|
|
246
|
+
console.print(f"[dim][vite-hmr] Connecting: {target}[/]")
|
|
247
|
+
|
|
248
|
+
return target
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def extract_forward_headers(scope: dict[str, Any]) -> list[tuple[str, str]]:
|
|
252
|
+
"""Extract headers to forward, excluding WebSocket handshake headers.
|
|
253
|
+
|
|
254
|
+
Excludes protocol-specific headers that websockets library handles itself.
|
|
255
|
+
The sec-websocket-protocol header is also excluded since we handle subprotocols separately.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
A list of (header_name, header_value) tuples.
|
|
259
|
+
"""
|
|
260
|
+
skip_headers = (
|
|
261
|
+
b"host",
|
|
262
|
+
b"upgrade",
|
|
263
|
+
b"connection",
|
|
264
|
+
b"sec-websocket-key",
|
|
265
|
+
b"sec-websocket-version",
|
|
266
|
+
b"sec-websocket-protocol",
|
|
267
|
+
b"sec-websocket-extensions",
|
|
268
|
+
)
|
|
269
|
+
return [(k.decode(), v.decode()) for k, v in scope.get("headers", []) if k.lower() not in skip_headers]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def extract_subprotocols(scope: dict[str, Any]) -> list[str]:
|
|
273
|
+
"""Extract WebSocket subprotocols from the request headers.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
A list of subprotocol strings.
|
|
277
|
+
"""
|
|
278
|
+
for key, value in scope.get("headers", []):
|
|
279
|
+
if key.lower() == b"sec-websocket-protocol":
|
|
280
|
+
return [p.strip() for p in value.decode().split(",")]
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _run_websocket_proxy(socket: Any, upstream: Any) -> None:
|
|
285
|
+
"""Run bidirectional WebSocket proxy between client and upstream.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
socket: The client WebSocket connection (Litestar WebSocket).
|
|
289
|
+
upstream: The upstream WebSocket connection (websockets client).
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
async def client_to_upstream() -> None:
|
|
293
|
+
"""Forward messages from browser to Vite."""
|
|
294
|
+
try:
|
|
295
|
+
while True:
|
|
296
|
+
data = await socket.receive_text()
|
|
297
|
+
await upstream.send(data)
|
|
298
|
+
except (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed):
|
|
299
|
+
pass
|
|
300
|
+
finally:
|
|
301
|
+
with suppress(websockets.ConnectionClosed):
|
|
302
|
+
await upstream.close()
|
|
303
|
+
|
|
304
|
+
async def upstream_to_client() -> None:
|
|
305
|
+
"""Forward messages from Vite to browser."""
|
|
306
|
+
try:
|
|
307
|
+
async for msg in upstream:
|
|
308
|
+
if isinstance(msg, str):
|
|
309
|
+
await socket.send_text(msg)
|
|
310
|
+
else:
|
|
311
|
+
await socket.send_bytes(msg)
|
|
312
|
+
except (WebSocketDisconnect, anyio.ClosedResourceError, websockets.ConnectionClosed):
|
|
313
|
+
pass
|
|
314
|
+
finally:
|
|
315
|
+
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
|
|
316
|
+
await socket.close()
|
|
317
|
+
|
|
318
|
+
async with anyio.create_task_group() as tg:
|
|
319
|
+
tg.start_soon(client_to_upstream)
|
|
320
|
+
tg.start_soon(upstream_to_client)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def create_vite_hmr_handler(hotfile_path: Path, hmr_path: str = "/static/vite-hmr", asset_url: str = "/static/") -> Any:
|
|
324
|
+
"""Create a WebSocket route handler for Vite HMR proxy.
|
|
325
|
+
|
|
326
|
+
This handler proxies WebSocket connections from the browser to the Vite
|
|
327
|
+
dev server for Hot Module Replacement (HMR) functionality.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
hotfile_path: Path to the hotfile written by the Vite plugin.
|
|
331
|
+
hmr_path: The path to register the WebSocket handler at.
|
|
332
|
+
asset_url: The asset URL prefix to strip when connecting to Vite.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
A WebsocketRouteHandler that proxies HMR connections.
|
|
336
|
+
"""
|
|
337
|
+
from litestar import WebSocket, websocket
|
|
338
|
+
|
|
339
|
+
@websocket(path=hmr_path, opt={"exclude_from_auth": True})
|
|
340
|
+
async def vite_hmr_proxy(socket: "WebSocket[Any, Any, Any]") -> None:
|
|
341
|
+
"""Proxy WebSocket messages between browser and Vite dev server.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
BaseException: Re-raises unexpected exceptions to allow the ASGI server to log them.
|
|
345
|
+
"""
|
|
346
|
+
scope_dict = dict(socket.scope)
|
|
347
|
+
target = build_hmr_target_url(hotfile_path, scope_dict, hmr_path, asset_url)
|
|
348
|
+
if target is None:
|
|
349
|
+
console.print("[yellow][vite-hmr] Vite dev server not running[/]")
|
|
350
|
+
await socket.close(code=1011, reason="Vite dev server not running")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
headers = extract_forward_headers(scope_dict)
|
|
354
|
+
subprotocols = extract_subprotocols(scope_dict)
|
|
355
|
+
typed_subprotocols: list[Subprotocol] = [cast("Subprotocol", p) for p in subprotocols]
|
|
356
|
+
await socket.accept(subprotocols=typed_subprotocols[0] if typed_subprotocols else None)
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
async with websockets.connect(
|
|
360
|
+
target, additional_headers=headers, open_timeout=10, subprotocols=typed_subprotocols or None
|
|
361
|
+
) as upstream:
|
|
362
|
+
if is_proxy_debug():
|
|
363
|
+
console.print("[dim][vite-hmr] ✓ Connected[/]")
|
|
364
|
+
await _run_websocket_proxy(socket, upstream)
|
|
365
|
+
except TimeoutError:
|
|
366
|
+
if is_proxy_debug():
|
|
367
|
+
console.print("[yellow][vite-hmr] Connection timeout[/]")
|
|
368
|
+
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
|
|
369
|
+
await socket.close(code=1011, reason="Vite HMR connection timeout")
|
|
370
|
+
except OSError as exc:
|
|
371
|
+
if is_proxy_debug():
|
|
372
|
+
console.print(f"[yellow][vite-hmr] Connection failed: {exc}[/]")
|
|
373
|
+
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
|
|
374
|
+
await socket.close(code=1011, reason="Vite HMR connection failed")
|
|
375
|
+
except WebSocketDisconnect:
|
|
376
|
+
pass
|
|
377
|
+
except BaseException as exc:
|
|
378
|
+
exceptions: list[BaseException] | tuple[BaseException, ...] | None
|
|
379
|
+
try:
|
|
380
|
+
exceptions = cast("list[BaseException] | tuple[BaseException, ...]", exc.exceptions) # type: ignore[attr-defined]
|
|
381
|
+
except AttributeError:
|
|
382
|
+
exceptions = None
|
|
383
|
+
|
|
384
|
+
if exceptions is not None:
|
|
385
|
+
if any(not isinstance(err, _DISCONNECT_EXCEPTIONS) for err in exceptions):
|
|
386
|
+
raise
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
if not isinstance(exc, _DISCONNECT_EXCEPTIONS):
|
|
390
|
+
raise
|
|
391
|
+
|
|
392
|
+
return vite_hmr_proxy
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def check_http2_support(enable: bool) -> bool:
|
|
396
|
+
"""Check if HTTP/2 support is available.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
True if HTTP/2 is enabled and the h2 package is installed, else False.
|
|
400
|
+
"""
|
|
401
|
+
if not enable:
|
|
402
|
+
return False
|
|
403
|
+
try:
|
|
404
|
+
import h2 # noqa: F401 # pyright: ignore[reportMissingImports,reportUnusedImport]
|
|
405
|
+
except ImportError:
|
|
406
|
+
return False
|
|
407
|
+
else:
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def build_proxy_url(target_url: str, path: str, query: str) -> str:
|
|
412
|
+
"""Build the full proxy URL from target, path, and query string.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
The full URL as a string.
|
|
416
|
+
"""
|
|
417
|
+
url = f"{target_url}{path}"
|
|
418
|
+
return f"{url}?{query}" if query else url
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def create_target_url_getter(
|
|
422
|
+
target: "str | None", hotfile_path: "Path | None", cached_target: list["str | None"]
|
|
423
|
+
) -> "Callable[[], str | None]":
|
|
424
|
+
"""Create a function that returns the current target URL with permanent caching.
|
|
425
|
+
|
|
426
|
+
The hotfile is read once and cached for the lifetime of the server.
|
|
427
|
+
Server restart refreshes the cache automatically.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
A callable that returns the target URL or None if unavailable.
|
|
431
|
+
"""
|
|
432
|
+
cache_initialized: list[bool] = [False]
|
|
433
|
+
|
|
434
|
+
def _get_target_url() -> str | None:
|
|
435
|
+
if target is not None:
|
|
436
|
+
return target.rstrip("/")
|
|
437
|
+
if hotfile_path is None:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
if cache_initialized[0]:
|
|
441
|
+
return cached_target[0].rstrip("/") if cached_target[0] else None
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
url = hotfile_path.read_text().strip()
|
|
445
|
+
cached_target[0] = url
|
|
446
|
+
cache_initialized[0] = True
|
|
447
|
+
if is_proxy_debug():
|
|
448
|
+
console.print(f"[dim][ssr-proxy] Dynamic target: {url}[/]")
|
|
449
|
+
return url.rstrip("/")
|
|
450
|
+
except FileNotFoundError:
|
|
451
|
+
cached_target[0] = None
|
|
452
|
+
cache_initialized[0] = True
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
return _get_target_url
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def create_hmr_target_getter(
|
|
459
|
+
hotfile_path: "Path | None", cached_hmr_target: list["str | None"]
|
|
460
|
+
) -> "Callable[[], str | None]":
|
|
461
|
+
"""Create a function that returns the HMR target URL from hotfile with permanent caching.
|
|
462
|
+
|
|
463
|
+
The hotfile is read once and cached for the lifetime of the server.
|
|
464
|
+
Server restart refreshes the cache automatically.
|
|
465
|
+
|
|
466
|
+
The JS side writes HMR URLs to a sibling file at ``<hotfile>.hmr``.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
A callable that returns the HMR target URL or None if unavailable.
|
|
470
|
+
"""
|
|
471
|
+
cache_initialized: list[bool] = [False]
|
|
472
|
+
|
|
473
|
+
def _get_hmr_target_url() -> str | None:
|
|
474
|
+
if hotfile_path is None:
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
if cache_initialized[0]:
|
|
478
|
+
return cached_hmr_target[0].rstrip("/") if cached_hmr_target[0] else None
|
|
479
|
+
|
|
480
|
+
hmr_path = Path(f"{hotfile_path}.hmr")
|
|
481
|
+
try:
|
|
482
|
+
url = hmr_path.read_text(encoding="utf-8").strip()
|
|
483
|
+
cached_hmr_target[0] = url
|
|
484
|
+
cache_initialized[0] = True
|
|
485
|
+
if is_proxy_debug():
|
|
486
|
+
console.print(f"[dim][ssr-proxy] HMR target: {url}[/]")
|
|
487
|
+
return url.rstrip("/")
|
|
488
|
+
except FileNotFoundError:
|
|
489
|
+
cached_hmr_target[0] = None
|
|
490
|
+
cache_initialized[0] = True
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
return _get_hmr_target_url
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
async def _handle_ssr_websocket_proxy(
|
|
497
|
+
socket: Any, ws_url: str, headers: list[tuple[str, str]], typed_subprotocols: "list[Subprotocol]"
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Handle the WebSocket proxy connection to SSR framework.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
socket: The client WebSocket connection.
|
|
503
|
+
ws_url: The upstream WebSocket URL.
|
|
504
|
+
headers: Headers to forward.
|
|
505
|
+
typed_subprotocols: WebSocket subprotocols.
|
|
506
|
+
"""
|
|
507
|
+
try:
|
|
508
|
+
async with websockets.connect(
|
|
509
|
+
ws_url, additional_headers=headers, open_timeout=10, subprotocols=typed_subprotocols or None
|
|
510
|
+
) as upstream:
|
|
511
|
+
if is_proxy_debug():
|
|
512
|
+
console.print("[dim][ssr-proxy-ws] ✓ Connected[/]")
|
|
513
|
+
await _run_websocket_proxy(socket, upstream)
|
|
514
|
+
except TimeoutError:
|
|
515
|
+
if is_proxy_debug():
|
|
516
|
+
console.print("[yellow][ssr-proxy-ws] Connection timeout[/]")
|
|
517
|
+
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
|
|
518
|
+
await socket.close(code=1011, reason="SSR HMR connection timeout")
|
|
519
|
+
except OSError as exc:
|
|
520
|
+
if is_proxy_debug():
|
|
521
|
+
console.print(f"[yellow][ssr-proxy-ws] Connection failed: {exc}[/]")
|
|
522
|
+
with suppress(anyio.ClosedResourceError, WebSocketDisconnect):
|
|
523
|
+
await socket.close(code=1011, reason="SSR HMR connection failed")
|
|
524
|
+
except (WebSocketDisconnect, websockets.ConnectionClosed, anyio.ClosedResourceError):
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def create_ssr_proxy_controller(
|
|
529
|
+
target: "str | None" = None,
|
|
530
|
+
hotfile_path: "Path | None" = None,
|
|
531
|
+
http2: bool = True,
|
|
532
|
+
plugin: "VitePlugin | None" = None,
|
|
533
|
+
) -> type:
|
|
534
|
+
"""Create a Controller that proxies to an SSR framework dev server.
|
|
535
|
+
|
|
536
|
+
This controller is used for SSR frameworks (Astro, Nuxt, SvelteKit) where all
|
|
537
|
+
non-API requests should be proxied to the framework's dev server for rendering.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
target: Static target URL to proxy to. If None, uses hotfile for dynamic discovery.
|
|
541
|
+
hotfile_path: Path to the hotfile for dynamic target discovery.
|
|
542
|
+
http2: Enable HTTP/2 for proxy connections.
|
|
543
|
+
plugin: Optional VitePlugin reference for accessing shared proxy client.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
A Litestar Controller class with HTTP and WebSocket handlers for SSR proxy.
|
|
547
|
+
"""
|
|
548
|
+
from litestar import Controller, HttpMethod, Response, WebSocket, route, websocket
|
|
549
|
+
|
|
550
|
+
cached_target: list[str | None] = [target]
|
|
551
|
+
get_target_url = create_target_url_getter(target, hotfile_path, cached_target)
|
|
552
|
+
get_hmr_target_url = create_hmr_target_getter(hotfile_path, [None])
|
|
553
|
+
|
|
554
|
+
class SSRProxyController(Controller):
|
|
555
|
+
"""Controller that proxies requests to an SSR framework dev server."""
|
|
556
|
+
|
|
557
|
+
include_in_schema = False
|
|
558
|
+
opt = {"exclude_from_auth": True}
|
|
559
|
+
|
|
560
|
+
@route(
|
|
561
|
+
path=["/", "/{path:path}"],
|
|
562
|
+
http_method=[
|
|
563
|
+
HttpMethod.GET,
|
|
564
|
+
HttpMethod.POST,
|
|
565
|
+
HttpMethod.PUT,
|
|
566
|
+
HttpMethod.PATCH,
|
|
567
|
+
HttpMethod.DELETE,
|
|
568
|
+
HttpMethod.HEAD,
|
|
569
|
+
HttpMethod.OPTIONS,
|
|
570
|
+
],
|
|
571
|
+
name="ssr_proxy",
|
|
572
|
+
)
|
|
573
|
+
async def http_proxy(self, request: "Request[Any, Any, Any]") -> "Response[bytes]":
|
|
574
|
+
"""Proxy all HTTP requests to the SSR framework dev server.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
A Response with the proxied content from the SSR server.
|
|
578
|
+
"""
|
|
579
|
+
target_url = get_target_url()
|
|
580
|
+
if target_url is None:
|
|
581
|
+
return Response(content=b"SSR dev server not running", status_code=503, media_type="text/plain")
|
|
582
|
+
|
|
583
|
+
req_path: str = request.url.path
|
|
584
|
+
url = build_proxy_url(target_url, req_path, request.url.query or "")
|
|
585
|
+
|
|
586
|
+
if is_proxy_debug():
|
|
587
|
+
console.print(f"[dim][ssr-proxy] {request.method} {req_path} → {url}[/]")
|
|
588
|
+
|
|
589
|
+
headers_to_forward = [
|
|
590
|
+
(k, v) for k, v in request.headers.items() if k.lower() not in {"host", "connection", "keep-alive"}
|
|
591
|
+
]
|
|
592
|
+
body = await request.body()
|
|
593
|
+
|
|
594
|
+
# Use shared client from plugin when available (connection pooling)
|
|
595
|
+
client = plugin.proxy_client if plugin is not None else None
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
if client is not None:
|
|
599
|
+
# Use shared client (connection pooling, HTTP/2 multiplexing)
|
|
600
|
+
upstream_resp = await client.request(
|
|
601
|
+
request.method,
|
|
602
|
+
url,
|
|
603
|
+
headers=headers_to_forward,
|
|
604
|
+
content=body,
|
|
605
|
+
follow_redirects=False,
|
|
606
|
+
timeout=30.0,
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
# Fallback: per-request client (graceful degradation)
|
|
610
|
+
http2_enabled = check_http2_support(http2)
|
|
611
|
+
async with httpx.AsyncClient(http2=http2_enabled, timeout=30.0) as fallback_client:
|
|
612
|
+
upstream_resp = await fallback_client.request(
|
|
613
|
+
request.method, url, headers=headers_to_forward, content=body, follow_redirects=False
|
|
614
|
+
)
|
|
615
|
+
except httpx.ConnectError:
|
|
616
|
+
return Response(
|
|
617
|
+
content=f"SSR dev server not running at {target_url}".encode(),
|
|
618
|
+
status_code=503,
|
|
619
|
+
media_type="text/plain",
|
|
620
|
+
)
|
|
621
|
+
except httpx.HTTPError as exc:
|
|
622
|
+
return Response(content=str(exc).encode(), status_code=502, media_type="text/plain")
|
|
623
|
+
|
|
624
|
+
return Response(
|
|
625
|
+
content=upstream_resp.content,
|
|
626
|
+
status_code=upstream_resp.status_code,
|
|
627
|
+
headers=dict(upstream_resp.headers.items()),
|
|
628
|
+
media_type=upstream_resp.headers.get("content-type"),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
@websocket(path=["/", "/{path:path}"], name="ssr_proxy_ws")
|
|
632
|
+
async def ws_proxy(self, socket: "WebSocket[Any, Any, Any]") -> None:
|
|
633
|
+
"""Proxy WebSocket connections to the SSR framework dev server (for HMR)."""
|
|
634
|
+
target_url = get_hmr_target_url() or get_target_url()
|
|
635
|
+
|
|
636
|
+
if target_url is None:
|
|
637
|
+
await socket.close(code=1011, reason="SSR dev server not running")
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
ws_target = target_url.replace("http://", "ws://").replace("https://", "wss://")
|
|
641
|
+
scope_dict = dict(socket.scope)
|
|
642
|
+
ws_path = str(scope_dict.get("path", "/"))
|
|
643
|
+
query_bytes = cast("bytes", scope_dict.get("query_string", b""))
|
|
644
|
+
ws_url = build_proxy_url(ws_target, ws_path, query_bytes.decode("utf-8") if query_bytes else "")
|
|
645
|
+
|
|
646
|
+
if is_proxy_debug():
|
|
647
|
+
console.print(f"[dim][ssr-proxy-ws] {ws_path} → {ws_url}[/]")
|
|
648
|
+
|
|
649
|
+
headers = extract_forward_headers(scope_dict)
|
|
650
|
+
subprotocols = extract_subprotocols(scope_dict)
|
|
651
|
+
typed_subprotocols: list[Subprotocol] = [cast("Subprotocol", p) for p in subprotocols]
|
|
652
|
+
|
|
653
|
+
await socket.accept(subprotocols=typed_subprotocols[0] if typed_subprotocols else None)
|
|
654
|
+
await _handle_ssr_websocket_proxy(socket, ws_url, headers, typed_subprotocols)
|
|
655
|
+
|
|
656
|
+
return SSRProxyController
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def normalize_proxy_prefixes(
|
|
660
|
+
base_prefixes: tuple[str, ...],
|
|
661
|
+
asset_url: "str | None" = None,
|
|
662
|
+
resource_dir: "Path | None" = None,
|
|
663
|
+
bundle_dir: "Path | None" = None,
|
|
664
|
+
root_dir: "Path | None" = None,
|
|
665
|
+
) -> tuple[str, ...]:
|
|
666
|
+
prefixes: list[str] = list(base_prefixes)
|
|
667
|
+
|
|
668
|
+
if asset_url:
|
|
669
|
+
prefixes.append(normalize_prefix(asset_url))
|
|
670
|
+
|
|
671
|
+
def _add_path(path: Path | str | None) -> None:
|
|
672
|
+
if path is None:
|
|
673
|
+
return
|
|
674
|
+
p = Path(path)
|
|
675
|
+
if root_dir and p.is_absolute():
|
|
676
|
+
with suppress(ValueError):
|
|
677
|
+
p = p.relative_to(root_dir)
|
|
678
|
+
prefixes.append(normalize_prefix(str(p).replace("\\", "/")))
|
|
679
|
+
|
|
680
|
+
_add_path(resource_dir)
|
|
681
|
+
_add_path(bundle_dir)
|
|
682
|
+
|
|
683
|
+
seen: set[str] = set()
|
|
684
|
+
unique: list[str] = []
|
|
685
|
+
for p in prefixes:
|
|
686
|
+
if p not in seen:
|
|
687
|
+
unique.append(p)
|
|
688
|
+
seen.add(p)
|
|
689
|
+
return tuple(unique)
|