pulse-framework 0.1.62__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/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/proxy.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proxy handler for forwarding requests to React Router server in single-server mode.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
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
|
|
17
|
+
|
|
18
|
+
from pulse.context import PulseContext
|
|
19
|
+
from pulse.cookies import parse_cookie_header
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReactProxy:
|
|
25
|
+
"""
|
|
26
|
+
Handles proxying HTTP requests and WebSocket connections to React Router server.
|
|
27
|
+
|
|
28
|
+
In single-server mode, the Python server proxies unmatched routes to the React
|
|
29
|
+
dev server. This proxy rewrites URLs in responses to use the external server
|
|
30
|
+
address instead of the internal React server address.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
react_server_address: str
|
|
34
|
+
server_address: str
|
|
35
|
+
_client: httpx.AsyncClient | None
|
|
36
|
+
|
|
37
|
+
def __init__(self, react_server_address: str, server_address: str):
|
|
38
|
+
"""
|
|
39
|
+
Args:
|
|
40
|
+
react_server_address: Internal React Router server URL (e.g., http://localhost:5173)
|
|
41
|
+
server_address: External server URL exposed to clients (e.g., http://localhost:8000)
|
|
42
|
+
"""
|
|
43
|
+
self.react_server_address = react_server_address
|
|
44
|
+
self.server_address = server_address
|
|
45
|
+
self._client = None
|
|
46
|
+
|
|
47
|
+
def rewrite_url(self, url: str) -> str:
|
|
48
|
+
"""Rewrite internal React server URLs to external server address."""
|
|
49
|
+
if self.react_server_address in url:
|
|
50
|
+
return url.replace(self.react_server_address, self.server_address)
|
|
51
|
+
return url
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def client(self) -> httpx.AsyncClient:
|
|
55
|
+
"""Lazy initialization of HTTP client."""
|
|
56
|
+
if self._client is None:
|
|
57
|
+
self._client = httpx.AsyncClient(
|
|
58
|
+
timeout=httpx.Timeout(30.0),
|
|
59
|
+
follow_redirects=False,
|
|
60
|
+
)
|
|
61
|
+
return self._client
|
|
62
|
+
|
|
63
|
+
def _is_websocket_upgrade(self, request: Request) -> bool:
|
|
64
|
+
"""Check if request is a WebSocket upgrade."""
|
|
65
|
+
upgrade = request.headers.get("upgrade", "").lower()
|
|
66
|
+
connection = request.headers.get("connection", "").lower()
|
|
67
|
+
return upgrade == "websocket" and "upgrade" in connection
|
|
68
|
+
|
|
69
|
+
def _http_to_ws_url(self, http_url: str) -> str:
|
|
70
|
+
"""Convert HTTP URL to WebSocket URL."""
|
|
71
|
+
if http_url.startswith("https://"):
|
|
72
|
+
return http_url.replace("https://", "wss://", 1)
|
|
73
|
+
elif http_url.startswith("http://"):
|
|
74
|
+
return http_url.replace("http://", "ws://", 1)
|
|
75
|
+
return http_url
|
|
76
|
+
|
|
77
|
+
async def proxy_websocket(self, websocket: WebSocket) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Proxy WebSocket connection to React Router server.
|
|
80
|
+
Only allowed in dev mode and on root path "/".
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Build target WebSocket URL
|
|
84
|
+
ws_url = self._http_to_ws_url(self.react_server_address)
|
|
85
|
+
target_url = ws_url.rstrip("/") + websocket.url.path
|
|
86
|
+
if websocket.url.query:
|
|
87
|
+
target_url += "?" + websocket.url.query
|
|
88
|
+
|
|
89
|
+
# Extract subprotocols from client request
|
|
90
|
+
subprotocol_header = websocket.headers.get("sec-websocket-protocol")
|
|
91
|
+
subprotocols: list[Subprotocol] | None = None
|
|
92
|
+
if subprotocol_header:
|
|
93
|
+
# Parse comma-separated list of subprotocols
|
|
94
|
+
# Subprotocol is a NewType (just a type annotation), so cast strings to it
|
|
95
|
+
subprotocols = cast(
|
|
96
|
+
list[Subprotocol], [p.strip() for p in subprotocol_header.split(",")]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Extract headers for WebSocket connection (excluding WebSocket-specific headers)
|
|
100
|
+
headers = {
|
|
101
|
+
k: v
|
|
102
|
+
for k, v in websocket.headers.items()
|
|
103
|
+
if k.lower()
|
|
104
|
+
not in (
|
|
105
|
+
"host",
|
|
106
|
+
"upgrade",
|
|
107
|
+
"connection",
|
|
108
|
+
"sec-websocket-key",
|
|
109
|
+
"sec-websocket-version",
|
|
110
|
+
"sec-websocket-protocol",
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Connect to target WebSocket server first to negotiate subprotocol
|
|
115
|
+
try:
|
|
116
|
+
async with websockets.connect(
|
|
117
|
+
target_url,
|
|
118
|
+
additional_headers=headers,
|
|
119
|
+
subprotocols=subprotocols,
|
|
120
|
+
ping_interval=None, # Let the target server handle ping/pong
|
|
121
|
+
) as target_ws:
|
|
122
|
+
# Accept client connection with the negotiated subprotocol
|
|
123
|
+
await websocket.accept(subprotocol=target_ws.subprotocol)
|
|
124
|
+
|
|
125
|
+
# Forward messages bidirectionally
|
|
126
|
+
async def forward_client_to_target():
|
|
127
|
+
try:
|
|
128
|
+
async for message in websocket.iter_text():
|
|
129
|
+
await target_ws.send(message)
|
|
130
|
+
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
|
131
|
+
# Client disconnected, close target connection
|
|
132
|
+
logger.debug("Client disconnected, closing target connection")
|
|
133
|
+
try:
|
|
134
|
+
await target_ws.close()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Error forwarding client message: {e}")
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
async def forward_target_to_client():
|
|
142
|
+
try:
|
|
143
|
+
async for message in target_ws:
|
|
144
|
+
if isinstance(message, str):
|
|
145
|
+
await websocket.send_text(message)
|
|
146
|
+
else:
|
|
147
|
+
await websocket.send_bytes(message)
|
|
148
|
+
except (WebSocketDisconnect, websockets.ConnectionClosed) as e:
|
|
149
|
+
# Client or target disconnected, stop forwarding
|
|
150
|
+
logger.debug(
|
|
151
|
+
"Connection closed, stopping forward_target_to_client"
|
|
152
|
+
)
|
|
153
|
+
# If target disconnected, close client connection
|
|
154
|
+
if isinstance(e, websockets.ConnectionClosed):
|
|
155
|
+
try:
|
|
156
|
+
await websocket.close()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"Error forwarding target message: {e}")
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
# Run both forwarding tasks concurrently
|
|
164
|
+
# If one side closes, the other will detect it and stop gracefully
|
|
165
|
+
await asyncio.gather(
|
|
166
|
+
forward_client_to_target(),
|
|
167
|
+
forward_target_to_client(),
|
|
168
|
+
return_exceptions=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
except (websockets.WebSocketException, websockets.ConnectionClosedError) as e:
|
|
172
|
+
logger.error(f"WebSocket proxy connection failed: {e}")
|
|
173
|
+
await websocket.close(
|
|
174
|
+
code=1014, # Bad Gateway
|
|
175
|
+
reason="Bad Gateway: Could not connect to React Router server",
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f"WebSocket proxy error: {e}")
|
|
179
|
+
await websocket.close(
|
|
180
|
+
code=1011, # Internal Server Error
|
|
181
|
+
reason="Bad Gateway: Proxy error",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def __call__(self, request: Request) -> Response:
|
|
185
|
+
"""
|
|
186
|
+
Forward HTTP request to React Router server and stream response back.
|
|
187
|
+
"""
|
|
188
|
+
# Build target URL
|
|
189
|
+
url = self.react_server_address.rstrip("/") + request.url.path
|
|
190
|
+
if request.url.query:
|
|
191
|
+
url += "?" + request.url.query
|
|
192
|
+
|
|
193
|
+
# Extract headers, skip host header (will be set by httpx)
|
|
194
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
|
195
|
+
ctx = PulseContext.get()
|
|
196
|
+
session = ctx.session
|
|
197
|
+
if session is not None:
|
|
198
|
+
session_cookie = session.get_cookie_value(ctx.app.cookie.name)
|
|
199
|
+
if session_cookie:
|
|
200
|
+
existing = parse_cookie_header(headers.get("cookie"))
|
|
201
|
+
if existing.get(ctx.app.cookie.name) != session_cookie:
|
|
202
|
+
existing[ctx.app.cookie.name] = session_cookie
|
|
203
|
+
headers["cookie"] = "; ".join(
|
|
204
|
+
f"{key}={value}" for key, value in existing.items()
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
# Build request
|
|
209
|
+
req = self.client.build_request(
|
|
210
|
+
method=request.method,
|
|
211
|
+
url=url,
|
|
212
|
+
headers=headers,
|
|
213
|
+
content=request.stream(),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Send request with streaming
|
|
217
|
+
r = await self.client.send(req, stream=True)
|
|
218
|
+
|
|
219
|
+
# Rewrite headers that may contain internal React server URLs
|
|
220
|
+
response_headers: dict[str, str] = {}
|
|
221
|
+
for k, v in r.headers.items():
|
|
222
|
+
if k.lower() in ("location", "content-location"):
|
|
223
|
+
v = self.rewrite_url(v)
|
|
224
|
+
response_headers[k] = v
|
|
225
|
+
|
|
226
|
+
return StreamingResponse(
|
|
227
|
+
r.aiter_raw(),
|
|
228
|
+
background=BackgroundTask(r.aclose),
|
|
229
|
+
status_code=r.status_code,
|
|
230
|
+
headers=response_headers,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
except httpx.RequestError as e:
|
|
234
|
+
logger.error(f"Proxy request failed: {e}")
|
|
235
|
+
return PlainTextResponse(
|
|
236
|
+
"Bad Gateway: Could not reach React Router server", status_code=502
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def close(self):
|
|
240
|
+
"""Close the HTTP client."""
|
|
241
|
+
if self._client is not None:
|
|
242
|
+
await self._client.aclose()
|
pulse/py.typed
ADDED
|
File without changes
|
|
File without changes
|