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
55import logging
6- from collections .abc import Iterable
7- from typing import Callable , cast
6+ from typing import Callable
87
98import 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
1314logger = 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 ):
0 commit comments