pulse-framework 0.1.39__py3-none-any.whl → 0.1.41__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/proxy.py CHANGED
@@ -1,46 +1,38 @@
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
+ import asyncio
5
6
  import logging
6
- from collections.abc import Iterable
7
- from typing import Callable, cast
7
+ from typing import cast
8
8
 
9
9
  import httpx
10
- from starlette.datastructures import Headers
11
- from starlette.types import ASGIApp, Receive, Scope, Send
10
+ import websockets
11
+ from fastapi.responses import StreamingResponse
12
+ from starlette.background import BackgroundTask
13
+ from starlette.requests import Request
14
+ from starlette.responses import PlainTextResponse, Response
15
+ from starlette.websockets import WebSocket, WebSocketDisconnect
16
+ from websockets.typing import Subprotocol
12
17
 
13
18
  logger = logging.getLogger(__name__)
14
19
 
15
20
 
16
- class PulseProxy:
21
+ class ReactProxy:
17
22
  """
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.
23
+ Handles proxying HTTP requests and WebSocket connections to React Router server.
22
24
  """
23
25
 
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.
26
+ react_server_address: str
27
+ _client: httpx.AsyncClient | None
32
28
 
29
+ def __init__(self, react_server_address: str):
30
+ """
33
31
  Args:
34
- app: The ASGI application to wrap (socketio.ASGIApp)
35
- 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")
32
+ react_server_address: React Router server full URL (required in single-server mode)
37
33
  """
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
34
+ self.react_server_address = react_server_address
35
+ self._client = None
44
36
 
45
37
  @property
46
38
  def client(self) -> httpx.AsyncClient:
@@ -52,141 +44,170 @@ class PulseProxy:
52
44
  )
53
45
  return self._client
54
46
 
55
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
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:
56
62
  """
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:
63
+ Proxy WebSocket connection to React Router server.
64
+ Only allowed in dev mode and on root path "/".
86
65
  """
87
- Forward HTTP request to React Router server and stream response back.
88
- """
89
- # Get the React server address
90
- react_server_address = self.get_react_server_address()
91
- if react_server_address is None:
92
- # 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
- }
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(",")]
99
81
  )
100
- await send(
101
- {
102
- "type": "http.response.body",
103
- "body": b"Service Unavailable: React server not ready",
104
- }
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",
105
95
  )
106
- return
96
+ }
107
97
 
108
- # 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)
98
+ # Accept the client WebSocket connection first
99
+ # We'll accept without subprotocol initially, then update if target accepts one
100
+ await websocket.accept()
143
101
 
102
+ # Connect to target WebSocket server
144
103
  try:
145
- # Forward request to React Router
146
- method = scope["method"]
147
- response = await self.client.request(
148
- method=method,
149
- url=target_path,
150
- headers=headers,
151
- content=body,
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",
152
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
+ """
173
+ # Build target URL
174
+ url = self.react_server_address.rstrip("/") + request.url.path
175
+ if request.url.query:
176
+ url += "?" + request.url.query
153
177
 
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
- }
178
+ # Extract headers, skip host header (will be set by httpx)
179
+ headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
180
+
181
+ try:
182
+ # Build request
183
+ req = self.client.build_request(
184
+ method=request.method,
185
+ url=url,
186
+ headers=headers,
187
+ content=request.stream(),
164
188
  )
165
189
 
166
- # Stream response body
167
- await send(
168
- {
169
- "type": "http.response.body",
170
- "body": response.content,
171
- }
190
+ # Send request with streaming
191
+ r = await self.client.send(req, stream=True)
192
+
193
+ # Filter out headers that shouldn't be present in streaming responses
194
+ response_headers = {
195
+ k: v
196
+ for k, v in r.headers.items()
197
+ # if k.lower() not in ("content-length", "transfer-encoding")
198
+ }
199
+
200
+ return StreamingResponse(
201
+ r.aiter_raw(),
202
+ background=BackgroundTask(r.aclose),
203
+ status_code=r.status_code,
204
+ headers=response_headers,
172
205
  )
173
206
 
174
207
  except httpx.RequestError as e:
175
208
  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
- }
209
+ return PlainTextResponse(
210
+ "Bad Gateway: Could not reach React Router server", status_code=502
190
211
  )
191
212
 
192
213
  async def close(self):
File without changes
@@ -0,0 +1,24 @@
1
+ from collections.abc import Callable
2
+ from typing import (
3
+ Any,
4
+ Concatenate,
5
+ ParamSpec,
6
+ TypeVar,
7
+ )
8
+
9
+ from pulse.state import State
10
+
11
+ T = TypeVar("T")
12
+ TState = TypeVar("TState", bound="State")
13
+ P = ParamSpec("P")
14
+ R = TypeVar("R")
15
+
16
+ OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
17
+ OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
18
+
19
+
20
+ def bind_state(
21
+ state: TState, fn: Callable[Concatenate[TState, P], R]
22
+ ) -> Callable[P, R]:
23
+ "Type-safe helper to bind a method to a state"
24
+ return fn.__get__(state, state.__class__)
@@ -0,0 +1,142 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import (
3
+ Any,
4
+ Concatenate,
5
+ Generic,
6
+ ParamSpec,
7
+ TypeVar,
8
+ override,
9
+ )
10
+
11
+ from pulse.helpers import call_flexible, maybe_await
12
+ from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
13
+ from pulse.reactive import Signal
14
+ from pulse.state import InitializableProperty, State
15
+
16
+ T = TypeVar("T")
17
+ TState = TypeVar("TState", bound=State)
18
+ R = TypeVar("R")
19
+ P = ParamSpec("P")
20
+
21
+
22
+ class MutationResult(Generic[T, P]):
23
+ """
24
+ Result object for mutations that provides reactive access to mutation state
25
+ and is callable to execute the mutation.
26
+ """
27
+
28
+ _data: Signal[T | None]
29
+ _is_running: Signal[bool]
30
+ _error: Signal[Exception | None]
31
+ _fn: Callable[P, Awaitable[T]]
32
+ _on_success: Callable[[T], Any] | None
33
+ _on_error: Callable[[Exception], Any] | None
34
+
35
+ def __init__(
36
+ self,
37
+ fn: Callable[P, Awaitable[T]],
38
+ on_success: Callable[[T], Any] | None = None,
39
+ on_error: Callable[[Exception], Any] | None = None,
40
+ ):
41
+ self._data = Signal(None, name="mutation.data")
42
+ self._is_running = Signal(False, name="mutation.is_running")
43
+ self._error = Signal(None, name="mutation.error")
44
+ self._fn = fn
45
+ self._on_success = on_success
46
+ self._on_error = on_error
47
+
48
+ @property
49
+ def data(self) -> T | None:
50
+ return self._data()
51
+
52
+ @property
53
+ def is_running(self) -> bool:
54
+ return self._is_running()
55
+
56
+ @property
57
+ def error(self) -> Exception | None:
58
+ return self._error()
59
+
60
+ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
61
+ self._is_running.write(True)
62
+ self._error.write(None)
63
+ try:
64
+ mutation_result = await self._fn(*args, **kwargs)
65
+ self._data.write(mutation_result)
66
+ if self._on_success:
67
+ await maybe_await(call_flexible(self._on_success, mutation_result))
68
+ return mutation_result
69
+ except Exception as e:
70
+ self._error.write(e)
71
+ if self._on_error:
72
+ await maybe_await(call_flexible(self._on_error, e))
73
+ raise e
74
+ finally:
75
+ self._is_running.write(False)
76
+
77
+
78
+ class MutationProperty(Generic[T, TState, P], InitializableProperty):
79
+ _on_success_fn: Callable[[TState, T], Any] | None
80
+ _on_error_fn: Callable[[TState, Exception], Any] | None
81
+ name: str
82
+ fn: Callable[Concatenate[TState, P], Awaitable[T]]
83
+
84
+ def __init__(
85
+ self,
86
+ name: str,
87
+ fn: Callable[Concatenate[TState, P], Awaitable[T]],
88
+ on_success: OnSuccessFn[TState, T] | None = None,
89
+ on_error: OnErrorFn[TState] | None = None,
90
+ ):
91
+ self.name = name
92
+ self.fn = fn
93
+ self._on_success_fn = on_success # pyright: ignore[reportAttributeAccessIssue]
94
+ self._on_error_fn = on_error # pyright: ignore[reportAttributeAccessIssue]
95
+
96
+ # Decorator to attach an on-success handler (sync or async)
97
+ def on_success(self, fn: OnSuccessFn[TState, T]):
98
+ if self._on_success_fn is not None:
99
+ raise RuntimeError(
100
+ f"Duplicate on_success() decorator for mutation '{self.name}'. Only one is allowed."
101
+ )
102
+ self._on_success_fn = fn # pyright: ignore[reportAttributeAccessIssue]
103
+ return fn
104
+
105
+ # Decorator to attach an on-error handler (sync or async)
106
+ def on_error(self, fn: OnErrorFn[TState]):
107
+ if self._on_error_fn is not None:
108
+ raise RuntimeError(
109
+ f"Duplicate on_error() decorator for mutation '{self.name}'. Only one is allowed."
110
+ )
111
+ self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
112
+ return fn
113
+
114
+ def __get__(self, obj: Any, objtype: Any = None) -> MutationResult[T, P]:
115
+ if obj is None:
116
+ return self # pyright: ignore[reportReturnType]
117
+
118
+ # Cache the result on the instance
119
+ cache_key = f"__mutation_{self.name}"
120
+ if not hasattr(obj, cache_key):
121
+ # Bind methods to state
122
+ bound_fn = bind_state(obj, self.fn)
123
+ bound_on_success = (
124
+ bind_state(obj, self._on_success_fn) if self._on_success_fn else None
125
+ )
126
+ bound_on_error = (
127
+ bind_state(obj, self._on_error_fn) if self._on_error_fn else None
128
+ )
129
+
130
+ result = MutationResult[T, P](
131
+ fn=bound_fn,
132
+ on_success=bound_on_success,
133
+ on_error=bound_on_error,
134
+ )
135
+ setattr(obj, cache_key, result)
136
+
137
+ return getattr(obj, cache_key)
138
+
139
+ @override
140
+ def initialize(self, state: State, name: str) -> MutationResult[T, P]:
141
+ # For compatibility with InitializableProperty, but mutations don't need special initialization
142
+ return self.__get__(state, state.__class__)