pulse-framework 0.1.40__py3-none-any.whl → 0.1.42__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/middleware.py CHANGED
@@ -1,12 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Callable, Sequence
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable, Sequence
4
5
  from typing import Any, Generic, TypeVar, overload, override
5
6
 
7
+ from pulse.env import env
6
8
  from pulse.messages import (
7
9
  ClientMessage,
10
+ Prerender,
8
11
  PrerenderPayload,
9
- PrerenderResult,
10
12
  ServerInitMessage,
11
13
  )
12
14
  from pulse.request import PulseRequest
@@ -26,20 +28,21 @@ class NotFound: ...
26
28
 
27
29
 
28
30
  class Ok(Generic[T]):
29
- payload: T | None
31
+ payload: T
30
32
 
31
33
  @overload
32
34
  def __init__(self, payload: T) -> None: ...
33
35
  @overload
34
- def __init__(self, payload: T | None = None) -> None: ...
36
+ def __init__(self, payload: None = None) -> None: ...
35
37
  def __init__(self, payload: T | None = None) -> None:
36
- self.payload = payload
38
+ self.payload = payload # pyright: ignore[reportAttributeAccessIssue]
37
39
 
38
40
 
39
41
  class Deny: ...
40
42
 
41
43
 
42
- PrerenderResponse = Ok[ServerInitMessage] | Redirect | NotFound
44
+ RoutePrerenderResponse = Ok[ServerInitMessage] | Redirect | NotFound
45
+ PrerenderResponse = Ok[Prerender] | Redirect | NotFound
43
46
  ConnectResponse = Ok[None] | Deny
44
47
 
45
48
 
@@ -50,56 +53,65 @@ class PulseMiddleware:
50
53
  for later use. Return a decision to allow or short-circuit the flow.
51
54
  """
52
55
 
53
- def prerender(
56
+ dev: bool
57
+
58
+ def __init__(self, dev: bool = False) -> None:
59
+ """Initialize middleware.
60
+
61
+ Args:
62
+ dev: If True, this middleware is only active in dev environments.
63
+ """
64
+ self.dev = dev
65
+
66
+ async def prerender(
54
67
  self,
55
68
  *,
56
69
  payload: "PrerenderPayload",
57
- result: "PrerenderResult",
58
70
  request: PulseRequest,
59
71
  session: dict[str, Any],
60
- next: Callable[[], "PrerenderResult"],
61
- ) -> "PrerenderResult":
72
+ next: Callable[[], Awaitable[PrerenderResponse]],
73
+ ) -> PrerenderResponse:
62
74
  """Handle batch prerender at the top level.
63
75
 
64
- Receives the full PrerenderPayload and can modify the PrerenderResult
65
- (views and directives) before it's returned to the client.
76
+ Receives the full PrerenderPayload. Call next() to get the PrerenderResult
77
+ and can modify it (views and directives) before returning to the client.
66
78
  """
67
- return next()
79
+ return await next()
68
80
 
69
- def prerender_route(
81
+ async def prerender_route(
70
82
  self,
71
83
  *,
72
84
  path: str,
73
85
  request: PulseRequest,
74
86
  route_info: RouteInfo,
75
87
  session: dict[str, Any],
76
- next: Callable[[], PrerenderResponse],
77
- ) -> PrerenderResponse:
78
- return next()
88
+ next: Callable[[], Awaitable[RoutePrerenderResponse]],
89
+ ) -> RoutePrerenderResponse:
90
+ return await next()
79
91
 
80
- def connect(
92
+ async def connect(
81
93
  self,
82
94
  *,
83
95
  request: PulseRequest,
84
96
  session: dict[str, Any],
85
- next: Callable[[], ConnectResponse],
97
+ next: Callable[[], Awaitable[ConnectResponse]],
86
98
  ) -> ConnectResponse:
87
- return next()
99
+ return await next()
88
100
 
89
- def message(
101
+ async def message(
90
102
  self,
91
103
  *,
92
104
  data: ClientMessage,
93
105
  session: dict[str, Any],
94
- next: Callable[[], Ok[None]],
106
+ next: Callable[[], Awaitable[Ok[None]]],
95
107
  ) -> Ok[None] | Deny:
96
108
  """Handle per-message authorization.
97
109
 
98
110
  Return Deny() to block, Ok(None) to allow.
99
111
  """
100
- return next()
112
+ return await next()
101
113
 
102
- def channel(
114
+ async def channel(
103
115
  self,
104
116
  *,
105
117
  channel_id: str,
@@ -107,9 +119,9 @@ class PulseMiddleware:
107
119
  payload: Any,
108
120
  request_id: str | None,
109
121
  session: dict[str, Any],
110
- next: Callable[[], Ok[None]],
122
+ next: Callable[[], Awaitable[Ok[None]]],
111
123
  ) -> Ok[None] | Deny:
112
- return next()
124
+ return await next()
113
125
 
114
126
 
115
127
  class MiddlewareStack(PulseMiddleware):
@@ -119,56 +131,58 @@ class MiddlewareStack(PulseMiddleware):
119
131
  middleware returns without calling `next`, the chain short-circuits.
120
132
  """
121
133
 
122
- def __init__(self, middlewares: Sequence[PulseMiddleware]):
134
+ def __init__(self, middlewares: Sequence[PulseMiddleware]) -> None:
135
+ super().__init__(dev=False)
136
+ # Filter out dev middlewares when not in dev environment
137
+ if env.pulse_env != "dev":
138
+ middlewares = [mw for mw in middlewares if not mw.dev]
123
139
  self._middlewares: list[PulseMiddleware] = list(middlewares)
124
140
 
125
141
  @override
126
- def prerender(
142
+ async def prerender(
127
143
  self,
128
144
  *,
129
145
  payload: "PrerenderPayload",
130
- result: "PrerenderResult",
131
146
  request: PulseRequest,
132
147
  session: dict[str, Any],
133
- next: Callable[[], "PrerenderResult"],
134
- ) -> "PrerenderResult":
135
- def dispatch(index: int) -> "PrerenderResult":
148
+ next: Callable[[], Awaitable[PrerenderResponse]],
149
+ ) -> PrerenderResponse:
150
+ async def dispatch(index: int) -> PrerenderResponse:
136
151
  if index >= len(self._middlewares):
137
- return next()
152
+ return await next()
138
153
  mw = self._middlewares[index]
139
154
 
140
- def _next() -> "PrerenderResult":
141
- return dispatch(index + 1)
155
+ async def _next() -> PrerenderResponse:
156
+ return await dispatch(index + 1)
142
157
 
143
- return mw.prerender(
158
+ return await mw.prerender(
144
159
  payload=payload,
145
- result=result,
146
160
  request=request,
147
161
  session=session,
148
162
  next=_next,
149
163
  )
150
164
 
151
- return dispatch(0)
165
+ return await dispatch(0)
152
166
 
153
167
  @override
154
- def prerender_route(
168
+ async def prerender_route(
155
169
  self,
156
170
  *,
157
171
  path: str,
158
172
  request: PulseRequest,
159
173
  route_info: RouteInfo,
160
174
  session: dict[str, Any],
161
- next: Callable[[], PrerenderResponse],
162
- ) -> PrerenderResponse:
163
- def dispatch(index: int) -> PrerenderResponse:
175
+ next: Callable[[], Awaitable[RoutePrerenderResponse]],
176
+ ) -> RoutePrerenderResponse:
177
+ async def dispatch(index: int) -> RoutePrerenderResponse:
164
178
  if index >= len(self._middlewares):
165
- return next()
179
+ return await next()
166
180
  mw = self._middlewares[index]
167
181
 
168
- def _next() -> PrerenderResponse:
169
- return dispatch(index + 1)
182
+ async def _next() -> RoutePrerenderResponse:
183
+ return await dispatch(index + 1)
170
184
 
171
- return mw.prerender_route(
185
+ return await mw.prerender_route(
172
186
  path=path,
173
187
  route_info=route_info,
174
188
  request=request,
@@ -176,50 +190,56 @@ class MiddlewareStack(PulseMiddleware):
176
190
  next=_next,
177
191
  )
178
192
 
179
- return dispatch(0)
193
+ return await dispatch(0)
180
194
 
181
195
  @override
182
- def connect(
196
+ async def connect(
183
197
  self,
184
198
  *,
185
199
  request: PulseRequest,
186
200
  session: dict[str, Any],
187
- next: Callable[[], ConnectResponse],
201
+ next: Callable[[], Awaitable[ConnectResponse]],
188
202
  ) -> ConnectResponse:
189
- def dispatch(index: int) -> ConnectResponse:
203
+ async def dispatch(index: int) -> ConnectResponse:
190
204
  if index >= len(self._middlewares):
191
- return next()
205
+ return await next()
192
206
  mw = self._middlewares[index]
193
207
 
194
- def _next() -> ConnectResponse:
195
- return dispatch(index + 1)
208
+ async def _next() -> ConnectResponse:
209
+ return await dispatch(index + 1)
196
210
 
197
- return mw.connect(request=request, session=session, next=_next)
211
+ return await mw.connect(request=request, session=session, next=_next)
198
212
 
199
- return dispatch(0)
213
+ return await dispatch(0)
200
214
 
201
215
  @override
202
- def message(
216
+ async def message(
203
217
  self,
204
218
  *,
205
219
  data: ClientMessage,
206
220
  session: dict[str, Any],
207
- next: Callable[[], Ok[None]],
221
+ next: Callable[[], Awaitable[Ok[None]]],
208
222
  ) -> Ok[None] | Deny:
209
- def dispatch(index: int) -> Ok[None] | Deny:
223
+ async def dispatch(index: int) -> Ok[None] | Deny:
210
224
  if index >= len(self._middlewares):
211
- return next()
225
+ return await next()
212
226
  mw = self._middlewares[index]
213
227
 
214
- def _next() -> Ok[None]:
215
- return dispatch(index + 1) # pyright: ignore[reportReturnType]
228
+ async def _next() -> Ok[None]:
229
+ result = await dispatch(index + 1)
230
+ # If dispatch returns Deny, the middleware should have short-circuited
231
+ # This should only be called when continuing the chain
232
+ if isinstance(result, Deny):
233
+ # This shouldn't happen, but handle it gracefully
234
+ return Ok(None)
235
+ return result
216
236
 
217
- return mw.message(session=session, data=data, next=_next)
237
+ return await mw.message(session=session, data=data, next=_next)
218
238
 
219
- return dispatch(0)
239
+ return await dispatch(0)
220
240
 
221
241
  @override
222
- def channel(
242
+ async def channel(
223
243
  self,
224
244
  *,
225
245
  channel_id: str,
@@ -227,17 +247,23 @@ class MiddlewareStack(PulseMiddleware):
227
247
  payload: Any,
228
248
  request_id: str | None,
229
249
  session: dict[str, Any],
230
- next: Callable[[], Ok[None]],
250
+ next: Callable[[], Awaitable[Ok[None]]],
231
251
  ) -> Ok[None] | Deny:
232
- def dispatch(index: int) -> Ok[None] | Deny:
252
+ async def dispatch(index: int) -> Ok[None] | Deny:
233
253
  if index >= len(self._middlewares):
234
- return next()
254
+ return await next()
235
255
  mw = self._middlewares[index]
236
256
 
237
- def _next() -> Ok[None]:
238
- return dispatch(index + 1) # pyright: ignore[reportReturnType]
257
+ async def _next() -> Ok[None]:
258
+ result = await dispatch(index + 1)
259
+ # If dispatch returns Deny, the middleware should have short-circuited
260
+ # This should only be called when continuing the chain
261
+ if isinstance(result, Deny):
262
+ # This shouldn't happen, but handle it gracefully
263
+ return Ok(None)
264
+ return result
239
265
 
240
- return mw.channel(
266
+ return await mw.channel(
241
267
  channel_id=channel_id,
242
268
  event=event,
243
269
  payload=payload,
@@ -246,7 +272,7 @@ class MiddlewareStack(PulseMiddleware):
246
272
  next=_next,
247
273
  )
248
274
 
249
- return dispatch(0)
275
+ return await dispatch(0)
250
276
 
251
277
 
252
278
  def stack(*middlewares: PulseMiddleware) -> PulseMiddleware:
@@ -258,84 +284,108 @@ def stack(*middlewares: PulseMiddleware) -> PulseMiddleware:
258
284
  return MiddlewareStack(list(middlewares))
259
285
 
260
286
 
261
- class PulseCoreMiddleware(PulseMiddleware):
262
- """Core middleware that ensures a PulseContext is mounted around the chain.
287
+ class LatencyMiddleware(PulseMiddleware):
288
+ """Middleware that adds artificial latency to simulate network conditions.
263
289
 
264
- It executes first to set up the context, then lets subsequent middlewares
265
- run, and finally returns their response unchanged.
290
+ Useful for testing and development to simulate real-world network delays.
291
+ Defaults are realistic for typical web applications.
292
+
293
+ Example:
294
+ ```python
295
+ app = ps.App(
296
+ middleware=ps.LatencyMiddleware(
297
+ prerender_ms=100,
298
+ connect_ms=50,
299
+ )
300
+ )
301
+ ```
266
302
  """
267
303
 
304
+ prerender_ms: float
305
+ prerender_route_ms: float
306
+ connect_ms: float
307
+ message_ms: float
308
+ channel_ms: float
309
+
310
+ def __init__(
311
+ self,
312
+ *,
313
+ prerender_ms: float = 80.0,
314
+ prerender_route_ms: float = 60.0,
315
+ connect_ms: float = 40.0,
316
+ message_ms: float = 25.0,
317
+ channel_ms: float = 20.0,
318
+ ) -> None:
319
+ """Initialize latency middleware.
320
+
321
+ Args:
322
+ prerender_ms: Latency for batch prerender requests (HTTP). Default: 80ms
323
+ prerender_route_ms: Latency for individual route prerenders. Default: 60ms
324
+ connect_ms: Latency for WebSocket connections. Default: 40ms
325
+ message_ms: Latency for WebSocket messages (including API calls). Default: 25ms
326
+ channel_ms: Latency for channel messages. Default: 20ms
327
+ dev: If True, only active in dev environments. Default: True
328
+ """
329
+ super().__init__(dev=True)
330
+ self.prerender_ms = prerender_ms
331
+ self.prerender_route_ms = prerender_route_ms
332
+ self.connect_ms = connect_ms
333
+ self.message_ms = message_ms
334
+ self.channel_ms = channel_ms
335
+
268
336
  @override
269
- def prerender(
337
+ async def prerender(
270
338
  self,
271
339
  *,
272
340
  payload: "PrerenderPayload",
273
- result: "PrerenderResult",
274
341
  request: PulseRequest,
275
342
  session: dict[str, Any],
276
- next: Callable[[], "PrerenderResult"],
277
- ) -> "PrerenderResult":
278
- res = next()
279
- # Return the result as-is (no normalization needed)
280
- return res
281
-
282
- # --- Normalization helpers -------------------------------------------------
283
- def _normalize_prerender_response(self, res: Any) -> PrerenderResponse:
284
- if isinstance(res, (Ok, Redirect, NotFound)):
285
- return res # type: ignore[return-value]
286
- # Treat any other value as a VDOM payload
287
- return Ok(res)
288
-
289
- def _normalize_connect_response(self, res: Any) -> ConnectResponse:
290
- if isinstance(res, (Ok, Deny)):
291
- return res # type: ignore[return-value]
292
- # Treat any other value as allow
293
- return Ok(None)
294
-
295
- def _normalize_message_response(self, res: Any) -> Ok[None] | Deny:
296
- if isinstance(res, (Ok, Deny)):
297
- return res # type: ignore[return-value]
298
- # Treat any other value as allow
299
- return Ok(None)
343
+ next: Callable[[], Awaitable[PrerenderResponse]],
344
+ ) -> PrerenderResponse:
345
+ if self.prerender_ms > 0:
346
+ await asyncio.sleep(self.prerender_ms / 1000.0)
347
+ return await next()
300
348
 
301
349
  @override
302
- def prerender_route(
350
+ async def prerender_route(
303
351
  self,
304
352
  *,
305
353
  path: str,
306
354
  request: PulseRequest,
307
355
  route_info: RouteInfo,
308
356
  session: dict[str, Any],
309
- next: Callable[[], PrerenderResponse],
310
- ) -> PrerenderResponse:
311
- # No render object is available during prerender middleware
312
- res = next()
313
- return self._normalize_prerender_response(res)
357
+ next: Callable[[], Awaitable[RoutePrerenderResponse]],
358
+ ) -> RoutePrerenderResponse:
359
+ if self.prerender_route_ms > 0:
360
+ await asyncio.sleep(self.prerender_route_ms / 1000.0)
361
+ return await next()
314
362
 
315
363
  @override
316
- def connect(
364
+ async def connect(
317
365
  self,
318
366
  *,
319
367
  request: PulseRequest,
320
368
  session: dict[str, Any],
321
- next: Callable[[], ConnectResponse],
369
+ next: Callable[[], Awaitable[ConnectResponse]],
322
370
  ) -> ConnectResponse:
323
- res = next()
324
- return self._normalize_connect_response(res)
371
+ if self.connect_ms > 0:
372
+ await asyncio.sleep(self.connect_ms / 1000.0)
373
+ return await next()
325
374
 
326
375
  @override
327
- def message(
376
+ async def message(
328
377
  self,
329
378
  *,
330
379
  data: ClientMessage,
331
380
  session: dict[str, Any],
332
- next: Callable[[], Ok[None]],
381
+ next: Callable[[], Awaitable[Ok[None]]],
333
382
  ) -> Ok[None] | Deny:
334
- res = next()
335
- return self._normalize_message_response(res)
383
+ if self.message_ms > 0:
384
+ await asyncio.sleep(self.message_ms / 1000.0)
385
+ return await next()
336
386
 
337
387
  @override
338
- def channel(
388
+ async def channel(
339
389
  self,
340
390
  *,
341
391
  channel_id: str,
@@ -343,7 +393,8 @@ class PulseCoreMiddleware(PulseMiddleware):
343
393
  payload: Any,
344
394
  request_id: str | None,
345
395
  session: dict[str, Any],
346
- next: Callable[[], Ok[None]],
396
+ next: Callable[[], Awaitable[Ok[None]]],
347
397
  ) -> Ok[None] | Deny:
348
- res = next()
349
- return self._normalize_message_response(res)
398
+ if self.channel_ms > 0:
399
+ await asyncio.sleep(self.channel_ms / 1000.0)
400
+ return await next()
pulse/plugin.py CHANGED
@@ -16,9 +16,6 @@ class Plugin:
16
16
  def routes(self) -> list[Route | Layout]:
17
17
  return []
18
18
 
19
- def dev_routes(self) -> list[Route | Layout]:
20
- return []
21
-
22
19
  def middleware(self) -> list[PulseMiddleware]:
23
20
  return []
24
21
 
pulse/proxy.py CHANGED
@@ -2,32 +2,36 @@
2
2
  Proxy handler for forwarding requests to React Router server in single-server mode.
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import logging
6
- from typing import Callable
7
+ from typing import cast
7
8
 
8
9
  import httpx
10
+ import websockets
9
11
  from fastapi.responses import StreamingResponse
10
12
  from starlette.background import BackgroundTask
11
13
  from starlette.requests import Request
12
14
  from starlette.responses import PlainTextResponse, Response
15
+ from starlette.websockets import WebSocket, WebSocketDisconnect
16
+ from websockets.typing import Subprotocol
13
17
 
14
18
  logger = logging.getLogger(__name__)
15
19
 
16
20
 
17
- class ReactProxyHandler:
21
+ class ReactProxy:
18
22
  """
19
- Handles proxying HTTP requests to React Router server.
23
+ Handles proxying HTTP requests and WebSocket connections to React Router server.
20
24
  """
21
25
 
22
- get_react_server_address: Callable[[], str | None]
26
+ react_server_address: str
23
27
  _client: httpx.AsyncClient | None
24
28
 
25
- def __init__(self, get_react_server_address: Callable[[], str | None]):
29
+ def __init__(self, react_server_address: str):
26
30
  """
27
31
  Args:
28
- get_react_server_address: Callable that returns the React Router server full URL (or None if not started)
32
+ react_server_address: React Router server full URL (required in single-server mode)
29
33
  """
30
- self.get_react_server_address = get_react_server_address
34
+ self.react_server_address = react_server_address
31
35
  self._client = None
32
36
 
33
37
  @property
@@ -40,20 +44,134 @@ class ReactProxyHandler:
40
44
  )
41
45
  return self._client
42
46
 
43
- async def __call__(self, request: Request) -> Response:
47
+ def _is_websocket_upgrade(self, request: Request) -> bool:
48
+ """Check if request is a WebSocket upgrade."""
49
+ upgrade = request.headers.get("upgrade", "").lower()
50
+ connection = request.headers.get("connection", "").lower()
51
+ return upgrade == "websocket" and "upgrade" in connection
52
+
53
+ def _http_to_ws_url(self, http_url: str) -> str:
54
+ """Convert HTTP URL to WebSocket URL."""
55
+ if http_url.startswith("https://"):
56
+ return http_url.replace("https://", "wss://", 1)
57
+ elif http_url.startswith("http://"):
58
+ return http_url.replace("http://", "ws://", 1)
59
+ return http_url
60
+
61
+ async def proxy_websocket(self, websocket: WebSocket) -> None:
44
62
  """
45
- Forward HTTP request to React Router server and stream response back.
63
+ Proxy WebSocket connection to React Router server.
64
+ Only allowed in dev mode and on root path "/".
46
65
  """
47
- # Get the React server address
48
- react_server_address = self.get_react_server_address()
49
- if react_server_address is None:
50
- # React server not started yet, return error
51
- return PlainTextResponse(
52
- "Service Unavailable: React server not ready", status_code=503
66
+
67
+ # Build target WebSocket URL
68
+ ws_url = self._http_to_ws_url(self.react_server_address)
69
+ target_url = ws_url.rstrip("/") + websocket.url.path
70
+ if websocket.url.query:
71
+ target_url += "?" + websocket.url.query
72
+
73
+ # Extract subprotocols from client request
74
+ subprotocol_header = websocket.headers.get("sec-websocket-protocol")
75
+ subprotocols: list[Subprotocol] | None = None
76
+ if subprotocol_header:
77
+ # Parse comma-separated list of subprotocols
78
+ # Subprotocol is a NewType (just a type annotation), so cast strings to it
79
+ subprotocols = cast(
80
+ list[Subprotocol], [p.strip() for p in subprotocol_header.split(",")]
53
81
  )
54
82
 
83
+ # Extract headers for WebSocket connection (excluding WebSocket-specific headers)
84
+ headers = {
85
+ k: v
86
+ for k, v in websocket.headers.items()
87
+ if k.lower()
88
+ not in (
89
+ "host",
90
+ "upgrade",
91
+ "connection",
92
+ "sec-websocket-key",
93
+ "sec-websocket-version",
94
+ "sec-websocket-protocol",
95
+ )
96
+ }
97
+
98
+ # Accept the client WebSocket connection first
99
+ # We'll accept without subprotocol initially, then update if target accepts one
100
+ await websocket.accept()
101
+
102
+ # Connect to target WebSocket server
103
+ try:
104
+ async with websockets.connect(
105
+ target_url,
106
+ additional_headers=headers,
107
+ subprotocols=subprotocols,
108
+ ping_interval=None, # Let the target server handle ping/pong
109
+ ) as target_ws:
110
+ # Forward messages bidirectionally
111
+ async def forward_client_to_target():
112
+ try:
113
+ async for message in websocket.iter_text():
114
+ await target_ws.send(message)
115
+ except (WebSocketDisconnect, websockets.ConnectionClosed):
116
+ # Client disconnected, close target connection
117
+ logger.debug("Client disconnected, closing target connection")
118
+ try:
119
+ await target_ws.close()
120
+ except Exception:
121
+ pass
122
+ except Exception as e:
123
+ logger.error(f"Error forwarding client message: {e}")
124
+ raise
125
+
126
+ async def forward_target_to_client():
127
+ try:
128
+ async for message in target_ws:
129
+ if isinstance(message, str):
130
+ await websocket.send_text(message)
131
+ else:
132
+ await websocket.send_bytes(message)
133
+ except (WebSocketDisconnect, websockets.ConnectionClosed) as e:
134
+ # Client or target disconnected, stop forwarding
135
+ logger.debug(
136
+ "Connection closed, stopping forward_target_to_client"
137
+ )
138
+ # If target disconnected, close client connection
139
+ if isinstance(e, websockets.ConnectionClosed):
140
+ try:
141
+ await websocket.close()
142
+ except Exception:
143
+ pass
144
+ except Exception as e:
145
+ logger.error(f"Error forwarding target message: {e}")
146
+ raise
147
+
148
+ # Run both forwarding tasks concurrently
149
+ # If one side closes, the other will detect it and stop gracefully
150
+ await asyncio.gather(
151
+ forward_client_to_target(),
152
+ forward_target_to_client(),
153
+ return_exceptions=True,
154
+ )
155
+
156
+ except (websockets.WebSocketException, websockets.ConnectionClosedError) as e:
157
+ logger.error(f"WebSocket proxy connection failed: {e}")
158
+ await websocket.close(
159
+ code=1014, # Bad Gateway
160
+ reason="Bad Gateway: Could not connect to React Router server",
161
+ )
162
+ except Exception as e:
163
+ logger.error(f"WebSocket proxy error: {e}")
164
+ await websocket.close(
165
+ code=1011, # Internal Server Error
166
+ reason="Bad Gateway: Proxy error",
167
+ )
168
+
169
+ async def __call__(self, request: Request) -> Response:
170
+ """
171
+ Forward HTTP request to React Router server and stream response back.
172
+ """
55
173
  # Build target URL
56
- url = react_server_address.rstrip("/") + request.url.path
174
+ url = self.react_server_address.rstrip("/") + request.url.path
57
175
  if request.url.query:
58
176
  url += "?" + request.url.query
59
177
 
File without changes