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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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