Skip to content

Commit 0642b65

Browse files
committed
Fix single server proxy, add streaming
1 parent 3005998 commit 0642b65

File tree

6 files changed

+70
-177
lines changed

6 files changed

+70
-177
lines changed

bun.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
},
9595
"packages/pulse/js": {
9696
"name": "pulse-ui-client",
97-
"version": "0.1.39",
97+
"version": "0.1.40",
9898
"dependencies": {
9999
"socket.io-client": "^4.8.1",
100100
},

packages/pulse/js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pulse-ui-client",
3-
"version": "0.1.39",
3+
"version": "0.1.40",
44
"license": "MIT",
55
"type": "module",
66
"sideEffects": false,

packages/pulse/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pulse-framework"
3-
version = "0.1.39"
3+
version = "0.1.40"
44
description = "Pulse - Full-stack framework for building real-time React applications in Python"
55
readme = "README.md"
66
requires-python = ">=3.11"

packages/pulse/python/src/pulse/app.py

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
Redirect,
7272
)
7373
from pulse.plugin import Plugin
74-
from pulse.proxy import PulseProxy
74+
from pulse.proxy import ReactProxyHandler
7575
from pulse.react_component import ReactComponent, registered_react_components
7676
from pulse.render_session import RenderSession
7777
from pulse.request import PulseRequest
@@ -335,11 +335,6 @@ def asgi_factory(self):
335335
self.setup(server_address)
336336
self.status = AppStatus.running
337337

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-
)
343338
return self.asgi
344339

345340
def run(
@@ -379,28 +374,6 @@ def setup(self, server_address: str):
379374
**cors_config,
380375
)
381376

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-
404377
# Mount PulseContext for all FastAPI routes (no route info). Other API
405378
# routes / middleware should be added at the module-level, which means
406379
# this middleware will wrap all of them.
@@ -419,8 +392,6 @@ async def session_middleware( # pyright: ignore[reportUnusedFunction]
419392
)
420393
render_id = request.headers.get("x-pulse-render-id")
421394
render = self._get_render_for_session(render_id, session)
422-
if render:
423-
print(f"Reusing render session {render_id}")
424395
with PulseContext.update(session=session, render=render):
425396
res: Response = await call_next(request)
426397
session.handle_response(res)
@@ -568,6 +539,26 @@ async def handle_form_submit( # pyright: ignore[reportUnusedFunction]
568539
for plugin in self.plugins:
569540
plugin.on_setup(self)
570541

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+
571562
@self.sio.event
572563
async def connect( # pyright: ignore[reportUnusedFunction]
573564
sid: str, environ: dict[str, Any], auth: dict[str, str] | None
@@ -902,7 +893,6 @@ async def close(self):
902893
self._cancel_render_cleanup(rid)
903894

904895
# Close all render sessions
905-
print("Closing app")
906896
for rid in list(self.render_sessions.keys()):
907897
self.close_render(rid)
908898

packages/pulse/python/src/pulse/proxy.py

Lines changed: 45 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,34 @@
11
"""
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.
33
"""
44

55
import logging
6-
from collections.abc import Iterable
7-
from typing import Callable, cast
6+
from typing import Callable
87

98
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
1213

1314
logger = logging.getLogger(__name__)
1415

1516

16-
class PulseProxy:
17+
class ReactProxyHandler:
1718
"""
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.
2220
"""
2321

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
3224

25+
def __init__(self, get_react_server_address: Callable[[], str | None]):
26+
"""
3327
Args:
34-
app: The ASGI application to wrap (socketio.ASGIApp)
3528
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")
3729
"""
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
4432

4533
@property
4634
def client(self) -> httpx.AsyncClient:
@@ -52,141 +40,56 @@ def client(self) -> httpx.AsyncClient:
5240
)
5341
return self._client
5442

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:
8644
"""
8745
Forward HTTP request to React Router server and stream response back.
8846
"""
8947
# Get the React server address
9048
react_server_address = self.get_react_server_address()
9149
if react_server_address is None:
9250
# 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
9953
)
100-
await send(
101-
{
102-
"type": "http.response.body",
103-
"body": b"Service Unavailable: React server not ready",
104-
}
105-
)
106-
return
10754

10855
# 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"}
14362

14463
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,
15068
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(),
16470
)
16571

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,
17287
)
17388

17489
except httpx.RequestError as e:
17590
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
19093
)
19194

19295
async def close(self):

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)