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 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 PulseProxy
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"/{self.api_prefix}/set-cookies", method="GET"))
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 TYPE_CHECKING, Any, Generic, TypeVar, overload, override
5
-
6
- from pulse.messages import ClientMessage, ServerInitMessage
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 ASGI app for forwarding requests to React Router server in single-server mode.
2
+ Proxy handler for forwarding requests to React Router server in single-server mode.
3
3
  """
4
4
 
5
5
  import logging
6
- from collections.abc import Iterable
7
- from typing import Callable, cast
6
+ from typing import Callable
8
7
 
9
8
  import httpx
10
- from starlette.datastructures import Headers
11
- from starlette.types import ASGIApp, Receive, Scope, Send
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 PulseProxy:
17
+ class ReactProxyHandler:
17
18
  """
18
- ASGI app that proxies non-API requests to React Router server.
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
- def __init__(
25
- self,
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.app: ASGIApp = app
39
- self.get_react_server_address: Callable[[], str | None] = (
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, scope: Scope, receive: Receive, send: Send) -> None:
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
- await send(
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
- path = scope["path"]
110
- query_string = scope.get("query_string", b"").decode("utf-8")
111
- # Ensure react_server_address doesn't end with /
112
- base_url = react_server_address.rstrip("/")
113
- target_path = f"{base_url}{path}"
114
- if query_string:
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
- # Forward request to React Router
146
- method = scope["method"]
147
- response = await self.client.request(
148
- method=method,
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=body,
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
- # Stream response body
167
- await send(
168
- {
169
- "type": "http.response.body",
170
- "body": response.content,
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
- # Send error response
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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.38a9
3
+ Version: 0.1.40
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -1,5 +1,5 @@
1
1
  pulse/__init__.py,sha256=P2CLvFxP8IPiUsy3Z-hZD-1tbsvhd4eVlMNYg5WOPjE,31709
2
- pulse/app.py,sha256=a-fHuX1MfA0Whjyy8l2JxroRkJd5VG0Aba31xfKcuFY,29871
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=xNPUkeKVCEUJDIHoe5-GT7EvgLxZwWz1rWb6mEchkWY,7921
49
+ pulse/middleware.py,sha256=aMcfkQ2XUT-SMFNa51GYlQp5tnXQwkfbcGlqO4DGcKk,7874
50
50
  pulse/plugin.py,sha256=T1HLucOJekRfWMGF17arI3z7qfH-rBw_zPOQEV8v2mw,640
51
- pulse/proxy.py,sha256=lIt1W8FpItpwl85IxzdzgTwPN8G7kvM5OFYB7YW9iaE,5158
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.38a9.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
69
- pulse_framework-0.1.38a9.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
70
- pulse_framework-0.1.38a9.dist-info/METADATA,sha256=aNlTOGPVKDCkzoYDtRCHP3SppJDYfeP2w4SQpPWxS0E,582
71
- pulse_framework-0.1.38a9.dist-info/RECORD,,
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,,