pulse-framework 0.1.38a9__py3-none-any.whl → 0.1.40__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.
- pulse/app.py +22 -32
- pulse/middleware.py +8 -6
- pulse/proxy.py +45 -142
- {pulse_framework-0.1.38a9.dist-info → pulse_framework-0.1.40.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.38a9.dist-info → pulse_framework-0.1.40.dist-info}/RECORD +7 -7
- {pulse_framework-0.1.38a9.dist-info → pulse_framework-0.1.40.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.38a9.dist-info → pulse_framework-0.1.40.dist-info}/entry_points.txt +0 -0
pulse/app.py
CHANGED
|
@@ -71,7 +71,7 @@ from pulse.middleware import (
|
|
|
71
71
|
Redirect,
|
|
72
72
|
)
|
|
73
73
|
from pulse.plugin import Plugin
|
|
74
|
-
from pulse.proxy import
|
|
74
|
+
from pulse.proxy import ReactProxyHandler
|
|
75
75
|
from pulse.react_component import ReactComponent, registered_react_components
|
|
76
76
|
from pulse.render_session import RenderSession
|
|
77
77
|
from pulse.request import PulseRequest
|
|
@@ -335,11 +335,6 @@ class App:
|
|
|
335
335
|
self.setup(server_address)
|
|
336
336
|
self.status = AppStatus.running
|
|
337
337
|
|
|
338
|
-
# In single-server mode, the Pulse server acts as reverse proxy to the React server
|
|
339
|
-
if self.mode == "single-server":
|
|
340
|
-
return PulseProxy(
|
|
341
|
-
self.asgi, lambda: envvars.react_server_address, self.api_prefix
|
|
342
|
-
)
|
|
343
338
|
return self.asgi
|
|
344
339
|
|
|
345
340
|
def run(
|
|
@@ -379,28 +374,6 @@ class App:
|
|
|
379
374
|
**cors_config,
|
|
380
375
|
)
|
|
381
376
|
|
|
382
|
-
# Debug middleware to log CORS-related request details
|
|
383
|
-
# @self.fastapi.middleware("http")
|
|
384
|
-
# async def cors_debug_middleware(
|
|
385
|
-
# request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
386
|
-
# ):
|
|
387
|
-
# origin = request.headers.get("origin")
|
|
388
|
-
# method = request.method
|
|
389
|
-
# path = request.url.path
|
|
390
|
-
# print(
|
|
391
|
-
# f"[CORS Debug] {method} {path} | Origin: {origin} | "
|
|
392
|
-
# + f"Mode: {self.mode} | Server: {self.server_address}"
|
|
393
|
-
# )
|
|
394
|
-
# response = await call_next(request)
|
|
395
|
-
# allow_origin = response.headers.get("access-control-allow-origin")
|
|
396
|
-
# if allow_origin:
|
|
397
|
-
# print(f"[CORS Debug] Response allows origin: {allow_origin}")
|
|
398
|
-
# elif origin:
|
|
399
|
-
# logger.warning(
|
|
400
|
-
# f"[CORS Debug] Origin {origin} present but no Access-Control-Allow-Origin header set"
|
|
401
|
-
# )
|
|
402
|
-
# return response
|
|
403
|
-
|
|
404
377
|
# Mount PulseContext for all FastAPI routes (no route info). Other API
|
|
405
378
|
# routes / middleware should be added at the module-level, which means
|
|
406
379
|
# this middleware will wrap all of them.
|
|
@@ -419,8 +392,6 @@ class App:
|
|
|
419
392
|
)
|
|
420
393
|
render_id = request.headers.get("x-pulse-render-id")
|
|
421
394
|
render = self._get_render_for_session(render_id, session)
|
|
422
|
-
if render:
|
|
423
|
-
print(f"Reusing render session {render_id}")
|
|
424
395
|
with PulseContext.update(session=session, render=render):
|
|
425
396
|
res: Response = await call_next(request)
|
|
426
397
|
session.handle_response(res)
|
|
@@ -568,6 +539,26 @@ class App:
|
|
|
568
539
|
for plugin in self.plugins:
|
|
569
540
|
plugin.on_setup(self)
|
|
570
541
|
|
|
542
|
+
# In single-server mode, add catch-all route to proxy unmatched requests to React server
|
|
543
|
+
# This route must be registered last so FastAPI tries all specific routes first
|
|
544
|
+
# FastAPI will match specific routes before this catch-all, but we add an explicit check
|
|
545
|
+
# as a safety measure to ensure API routes are never proxied
|
|
546
|
+
if self.mode == "single-server":
|
|
547
|
+
proxy_handler = ReactProxyHandler(lambda: envvars.react_server_address)
|
|
548
|
+
|
|
549
|
+
@self.fastapi.api_route(
|
|
550
|
+
"/{path:path}",
|
|
551
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
552
|
+
include_in_schema=False,
|
|
553
|
+
)
|
|
554
|
+
async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
|
|
555
|
+
# Skip WebSocket upgrades (handled by Socket.IO)
|
|
556
|
+
if request.headers.get("upgrade", "").lower() == "websocket":
|
|
557
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
558
|
+
|
|
559
|
+
# Proxy all unmatched HTTP requests to React Router
|
|
560
|
+
return await proxy_handler(request)
|
|
561
|
+
|
|
571
562
|
@self.sio.event
|
|
572
563
|
async def connect( # pyright: ignore[reportUnusedFunction]
|
|
573
564
|
sid: str, environ: dict[str, Any], auth: dict[str, str] | None
|
|
@@ -902,7 +893,6 @@ class App:
|
|
|
902
893
|
self._cancel_render_cleanup(rid)
|
|
903
894
|
|
|
904
895
|
# Close all render sessions
|
|
905
|
-
print("Closing app")
|
|
906
896
|
for rid in list(self.render_sessions.keys()):
|
|
907
897
|
self.close_render(rid)
|
|
908
898
|
|
|
@@ -939,7 +929,7 @@ class App:
|
|
|
939
929
|
return # no active render for this user session
|
|
940
930
|
|
|
941
931
|
# We don't want to wait for this to resolve
|
|
942
|
-
create_task(render.call_api(f"
|
|
932
|
+
create_task(render.call_api(f"{self.api_prefix}/set-cookies", method="GET"))
|
|
943
933
|
sess.scheduled_cookie_refresh = True
|
|
944
934
|
|
|
945
935
|
|
pulse/middleware.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable, Sequence
|
|
4
|
-
from typing import
|
|
5
|
-
|
|
6
|
-
from pulse.messages import
|
|
4
|
+
from typing import Any, Generic, TypeVar, overload, override
|
|
5
|
+
|
|
6
|
+
from pulse.messages import (
|
|
7
|
+
ClientMessage,
|
|
8
|
+
PrerenderPayload,
|
|
9
|
+
PrerenderResult,
|
|
10
|
+
ServerInitMessage,
|
|
11
|
+
)
|
|
7
12
|
from pulse.request import PulseRequest
|
|
8
13
|
from pulse.routing import RouteInfo
|
|
9
14
|
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from pulse.app import PrerenderPayload, PrerenderResult
|
|
12
|
-
|
|
13
15
|
T = TypeVar("T")
|
|
14
16
|
|
|
15
17
|
|
pulse/proxy.py
CHANGED
|
@@ -1,46 +1,34 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Proxy
|
|
2
|
+
Proxy handler for forwarding requests to React Router server in single-server mode.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from
|
|
7
|
-
from typing import Callable, cast
|
|
6
|
+
from typing import Callable
|
|
8
7
|
|
|
9
8
|
import httpx
|
|
10
|
-
from
|
|
11
|
-
from starlette.
|
|
9
|
+
from fastapi.responses import StreamingResponse
|
|
10
|
+
from starlette.background import BackgroundTask
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import PlainTextResponse, Response
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
class
|
|
17
|
+
class ReactProxyHandler:
|
|
17
18
|
"""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
In single-server mode, Python FastAPI handles /_pulse/* routes and
|
|
21
|
-
proxies everything else to the React Router server running on an internal port.
|
|
19
|
+
Handles proxying HTTP requests to React Router server.
|
|
22
20
|
"""
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
app: ASGIApp,
|
|
27
|
-
get_react_server_address: Callable[[], str | None],
|
|
28
|
-
api_prefix: str = "/_pulse",
|
|
29
|
-
):
|
|
30
|
-
"""
|
|
31
|
-
Initialize proxy ASGI app.
|
|
22
|
+
get_react_server_address: Callable[[], str | None]
|
|
23
|
+
_client: httpx.AsyncClient | None
|
|
32
24
|
|
|
25
|
+
def __init__(self, get_react_server_address: Callable[[], str | None]):
|
|
26
|
+
"""
|
|
33
27
|
Args:
|
|
34
|
-
app: The ASGI application to wrap (socketio.ASGIApp)
|
|
35
28
|
get_react_server_address: Callable that returns the React Router server full URL (or None if not started)
|
|
36
|
-
api_prefix: Prefix for API routes that should NOT be proxied (default: "/_pulse")
|
|
37
29
|
"""
|
|
38
|
-
self.
|
|
39
|
-
self.
|
|
40
|
-
get_react_server_address
|
|
41
|
-
)
|
|
42
|
-
self.api_prefix: str = api_prefix
|
|
43
|
-
self._client: httpx.AsyncClient | None = None
|
|
30
|
+
self.get_react_server_address = get_react_server_address
|
|
31
|
+
self._client = None
|
|
44
32
|
|
|
45
33
|
@property
|
|
46
34
|
def client(self) -> httpx.AsyncClient:
|
|
@@ -52,37 +40,7 @@ class PulseProxy:
|
|
|
52
40
|
)
|
|
53
41
|
return self._client
|
|
54
42
|
|
|
55
|
-
async def __call__(self,
|
|
56
|
-
"""
|
|
57
|
-
ASGI application handler.
|
|
58
|
-
|
|
59
|
-
Routes starting with api_prefix or WebSocket connections go to FastAPI.
|
|
60
|
-
Everything else is proxied to React Router.
|
|
61
|
-
"""
|
|
62
|
-
if scope["type"] != "http":
|
|
63
|
-
# Pass through non-HTTP requests (WebSocket, lifespan, etc.)
|
|
64
|
-
await self.app(scope, receive, send)
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
path = scope["path"]
|
|
68
|
-
|
|
69
|
-
# Check if path starts with API prefix or is a WebSocket upgrade
|
|
70
|
-
if path.startswith(self.api_prefix):
|
|
71
|
-
# This is an API route, pass through to FastAPI
|
|
72
|
-
await self.app(scope, receive, send)
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
# Check if this is a WebSocket upgrade request (even if not prefixed)
|
|
76
|
-
headers = Headers(scope=scope)
|
|
77
|
-
if headers.get("upgrade", "").lower() == "websocket":
|
|
78
|
-
# WebSocket request, pass through to FastAPI
|
|
79
|
-
await self.app(scope, receive, send)
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
# Proxy to React Router server
|
|
83
|
-
await self._proxy_request(scope, receive, send)
|
|
84
|
-
|
|
85
|
-
async def _proxy_request(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
43
|
+
async def __call__(self, request: Request) -> Response:
|
|
86
44
|
"""
|
|
87
45
|
Forward HTTP request to React Router server and stream response back.
|
|
88
46
|
"""
|
|
@@ -90,103 +48,48 @@ class PulseProxy:
|
|
|
90
48
|
react_server_address = self.get_react_server_address()
|
|
91
49
|
if react_server_address is None:
|
|
92
50
|
# React server not started yet, return error
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"type": "http.response.start",
|
|
96
|
-
"status": 503,
|
|
97
|
-
"headers": [(b"content-type", b"text/plain")],
|
|
98
|
-
}
|
|
51
|
+
return PlainTextResponse(
|
|
52
|
+
"Service Unavailable: React server not ready", status_code=503
|
|
99
53
|
)
|
|
100
|
-
await send(
|
|
101
|
-
{
|
|
102
|
-
"type": "http.response.body",
|
|
103
|
-
"body": b"Service Unavailable: React server not ready",
|
|
104
|
-
}
|
|
105
|
-
)
|
|
106
|
-
return
|
|
107
54
|
|
|
108
55
|
# Build target URL
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if
|
|
115
|
-
target_path += f"?{query_string}"
|
|
116
|
-
|
|
117
|
-
# Extract headers
|
|
118
|
-
headers: dict[str, str] = {}
|
|
119
|
-
for name, value in cast(Iterable[tuple[bytes, bytes]], scope["headers"]):
|
|
120
|
-
name = name.decode("latin1")
|
|
121
|
-
value = value.decode("latin1")
|
|
122
|
-
|
|
123
|
-
# Skip host header (will be set by httpx)
|
|
124
|
-
if name.lower() == "host":
|
|
125
|
-
continue
|
|
126
|
-
|
|
127
|
-
# Collect headers (handle multiple values)
|
|
128
|
-
existing = headers.get(name)
|
|
129
|
-
if existing:
|
|
130
|
-
headers[name] = f"{existing},{value}"
|
|
131
|
-
else:
|
|
132
|
-
headers[name] = value
|
|
133
|
-
|
|
134
|
-
# Read request body
|
|
135
|
-
body_parts: list[bytes] = []
|
|
136
|
-
while True:
|
|
137
|
-
message = await receive()
|
|
138
|
-
if message["type"] == "http.request":
|
|
139
|
-
body_parts.append(message.get("body", b""))
|
|
140
|
-
if not message.get("more_body", False):
|
|
141
|
-
break
|
|
142
|
-
body = b"".join(body_parts)
|
|
56
|
+
url = react_server_address.rstrip("/") + request.url.path
|
|
57
|
+
if request.url.query:
|
|
58
|
+
url += "?" + request.url.query
|
|
59
|
+
|
|
60
|
+
# Extract headers, skip host header (will be set by httpx)
|
|
61
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
|
143
62
|
|
|
144
63
|
try:
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
url=target_path,
|
|
64
|
+
# Build request
|
|
65
|
+
req = self.client.build_request(
|
|
66
|
+
method=request.method,
|
|
67
|
+
url=url,
|
|
150
68
|
headers=headers,
|
|
151
|
-
content=
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
# Send response status
|
|
155
|
-
await send(
|
|
156
|
-
{
|
|
157
|
-
"type": "http.response.start",
|
|
158
|
-
"status": response.status_code,
|
|
159
|
-
"headers": [
|
|
160
|
-
(name.encode("latin1"), value.encode("latin1"))
|
|
161
|
-
for name, value in response.headers.items()
|
|
162
|
-
],
|
|
163
|
-
}
|
|
69
|
+
content=request.stream(),
|
|
164
70
|
)
|
|
165
71
|
|
|
166
|
-
#
|
|
167
|
-
await send(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
72
|
+
# Send request with streaming
|
|
73
|
+
r = await self.client.send(req, stream=True)
|
|
74
|
+
|
|
75
|
+
# Filter out headers that shouldn't be present in streaming responses
|
|
76
|
+
response_headers = {
|
|
77
|
+
k: v
|
|
78
|
+
for k, v in r.headers.items()
|
|
79
|
+
# if k.lower() not in ("content-length", "transfer-encoding")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return StreamingResponse(
|
|
83
|
+
r.aiter_raw(),
|
|
84
|
+
background=BackgroundTask(r.aclose),
|
|
85
|
+
status_code=r.status_code,
|
|
86
|
+
headers=response_headers,
|
|
172
87
|
)
|
|
173
88
|
|
|
174
89
|
except httpx.RequestError as e:
|
|
175
90
|
logger.error(f"Proxy request failed: {e}")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await send(
|
|
179
|
-
{
|
|
180
|
-
"type": "http.response.start",
|
|
181
|
-
"status": 502,
|
|
182
|
-
"headers": [(b"content-type", b"text/plain")],
|
|
183
|
-
}
|
|
184
|
-
)
|
|
185
|
-
await send(
|
|
186
|
-
{
|
|
187
|
-
"type": "http.response.body",
|
|
188
|
-
"body": b"Bad Gateway: Could not reach React Router server",
|
|
189
|
-
}
|
|
91
|
+
return PlainTextResponse(
|
|
92
|
+
"Bad Gateway: Could not reach React Router server", status_code=502
|
|
190
93
|
)
|
|
191
94
|
|
|
192
95
|
async def close(self):
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
pulse/__init__.py,sha256=P2CLvFxP8IPiUsy3Z-hZD-1tbsvhd4eVlMNYg5WOPjE,31709
|
|
2
|
-
pulse/app.py,sha256=
|
|
2
|
+
pulse/app.py,sha256=VwalJMondhPfhCaqXpEQH6vBezX7jmm9e24GZNr21M0,29726
|
|
3
3
|
pulse/channel.py,sha256=DuD1mg_xWvkpAWSKZ-EtBYdUzJ8IuKH0fxdgGOvFXpg,13041
|
|
4
4
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
5
|
pulse/cli/cmd.py,sha256=jA1kgw6Dibj8jC_amcRL4mCzfboszLTnVAQtJA3_G-4,14011
|
|
@@ -46,9 +46,9 @@ pulse/html/svg.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
46
46
|
pulse/html/tags.py,sha256=dyG4BY9qthBbO-ihcy9F8mLY6WqQxKFXfpqNYcSMKN0,5182
|
|
47
47
|
pulse/html/tags.pyi,sha256=I8dFoft9w4RvneZ3li1weAdijY1krj9jfO_p2SU6e04,13953
|
|
48
48
|
pulse/messages.py,sha256=Vz6pXUcBlQxHVEzP8jtA4ZBwn0P30oJzU07lzESP2JI,3625
|
|
49
|
-
pulse/middleware.py,sha256=
|
|
49
|
+
pulse/middleware.py,sha256=aMcfkQ2XUT-SMFNa51GYlQp5tnXQwkfbcGlqO4DGcKk,7874
|
|
50
50
|
pulse/plugin.py,sha256=T1HLucOJekRfWMGF17arI3z7qfH-rBw_zPOQEV8v2mw,640
|
|
51
|
-
pulse/proxy.py,sha256=
|
|
51
|
+
pulse/proxy.py,sha256=XR2jV5NTLTdiYuJLMBWx2_aMB7Odoa63XQRHSOgA7jM,2709
|
|
52
52
|
pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
53
|
pulse/query.py,sha256=u0KVFNt0d36sfmoxpQXvJ8DjctIHZaM_szlEonRiyRg,12849
|
|
54
54
|
pulse/react_component.py,sha256=Rw1J6cHOX8-K3BnkswVOu2COgneVvRz1OYmyXkX17RM,25993
|
|
@@ -65,7 +65,7 @@ pulse/types/event_handler.py,sha256=OF7sOgYBb6iUs59RH1vQIH7aOrGPfs3nAaF7how-4PQ,
|
|
|
65
65
|
pulse/user_session.py,sha256=kCZtQpYZe2keDXzusd6jsjjw075am0dXrb25jKLg5JU,7578
|
|
66
66
|
pulse/vdom.py,sha256=KTNBh2dVvDy9eXRzhneBJgk7F35MyWec8R_puQ4tSRY,12420
|
|
67
67
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
68
|
-
pulse_framework-0.1.
|
|
69
|
-
pulse_framework-0.1.
|
|
70
|
-
pulse_framework-0.1.
|
|
71
|
-
pulse_framework-0.1.
|
|
68
|
+
pulse_framework-0.1.40.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
69
|
+
pulse_framework-0.1.40.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
70
|
+
pulse_framework-0.1.40.dist-info/METADATA,sha256=j481f-W5TtovwCzdxBioeaG-rTHLmY7uhMpKc4RP_WU,580
|
|
71
|
+
pulse_framework-0.1.40.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|