pulse-framework 0.1.38a9__tar.gz → 0.1.40__tar.gz

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 (71) hide show
  1. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/app.py +22 -32
  4. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/middleware.py +8 -6
  5. pulse_framework-0.1.40/src/pulse/proxy.py +98 -0
  6. pulse_framework-0.1.38a9/src/pulse/proxy.py +0 -195
  7. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/README.md +0 -0
  8. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/__init__.py +0 -0
  9. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/channel.py +0 -0
  10. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/__init__.py +0 -0
  11. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/cmd.py +0 -0
  12. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/dependencies.py +0 -0
  13. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/folder_lock.py +0 -0
  14. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/helpers.py +0 -0
  15. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/models.py +0 -0
  16. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/packages.py +0 -0
  17. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/processes.py +0 -0
  18. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/secrets.py +0 -0
  19. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cli/uvicorn_log_config.py +0 -0
  20. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/__init__.py +0 -0
  21. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/codegen.py +0 -0
  22. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/imports.py +0 -0
  23. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/js.py +0 -0
  24. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/templates/__init__.py +0 -0
  25. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/templates/layout.py +0 -0
  26. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/templates/route.py +0 -0
  27. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/templates/routes_ts.py +0 -0
  28. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/codegen/utils.py +0 -0
  29. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/components/__init__.py +0 -0
  30. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/components/for_.py +0 -0
  31. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/components/if_.py +0 -0
  32. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/components/react_router.py +0 -0
  33. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/context.py +0 -0
  34. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/cookies.py +0 -0
  35. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/css.py +0 -0
  36. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/decorators.py +0 -0
  37. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/env.py +0 -0
  38. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/form.py +0 -0
  39. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/helpers.py +0 -0
  40. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/__init__.py +0 -0
  41. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/core.py +0 -0
  42. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/effects.py +0 -0
  43. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/runtime.py +0 -0
  44. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/setup.py +0 -0
  45. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/stable.py +0 -0
  46. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/hooks/states.py +0 -0
  47. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/__init__.py +0 -0
  48. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/elements.py +0 -0
  49. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/events.py +0 -0
  50. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/props.py +0 -0
  51. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/svg.py +0 -0
  52. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/tags.py +0 -0
  53. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/html/tags.pyi +0 -0
  54. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/messages.py +0 -0
  55. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/plugin.py +0 -0
  56. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/py.typed +0 -0
  57. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/query.py +0 -0
  58. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/react_component.py +0 -0
  59. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/reactive.py +0 -0
  60. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/reactive_extensions.py +0 -0
  61. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/render_session.py +0 -0
  62. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/renderer.py +0 -0
  63. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/request.py +0 -0
  64. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/routing.py +0 -0
  65. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/serializer.py +0 -0
  66. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/state.py +0 -0
  67. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/types/__init__.py +0 -0
  68. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/types/event_handler.py +0 -0
  69. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/user_session.py +0 -0
  70. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/vdom.py +0 -0
  71. {pulse_framework-0.1.38a9 → pulse_framework-0.1.40}/src/pulse/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.38a9
3
+ Version: 0.1.40
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.38a9"
3
+ version = "0.1.40"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -71,7 +71,7 @@ from pulse.middleware import (
71
71
  Redirect,
72
72
  )
73
73
  from pulse.plugin import Plugin
74
- from pulse.proxy import PulseProxy
74
+ from pulse.proxy import ReactProxyHandler
75
75
  from pulse.react_component import ReactComponent, registered_react_components
76
76
  from pulse.render_session import RenderSession
77
77
  from pulse.request import PulseRequest
@@ -335,11 +335,6 @@ class App:
335
335
  self.setup(server_address)
336
336
  self.status = AppStatus.running
337
337
 
338
- # In single-server mode, the Pulse server acts as reverse proxy to the React server
339
- if self.mode == "single-server":
340
- return PulseProxy(
341
- self.asgi, lambda: envvars.react_server_address, self.api_prefix
342
- )
343
338
  return self.asgi
344
339
 
345
340
  def run(
@@ -379,28 +374,6 @@ class App:
379
374
  **cors_config,
380
375
  )
381
376
 
382
- # Debug middleware to log CORS-related request details
383
- # @self.fastapi.middleware("http")
384
- # async def cors_debug_middleware(
385
- # request: Request, call_next: Callable[[Request], Awaitable[Response]]
386
- # ):
387
- # origin = request.headers.get("origin")
388
- # method = request.method
389
- # path = request.url.path
390
- # print(
391
- # f"[CORS Debug] {method} {path} | Origin: {origin} | "
392
- # + f"Mode: {self.mode} | Server: {self.server_address}"
393
- # )
394
- # response = await call_next(request)
395
- # allow_origin = response.headers.get("access-control-allow-origin")
396
- # if allow_origin:
397
- # print(f"[CORS Debug] Response allows origin: {allow_origin}")
398
- # elif origin:
399
- # logger.warning(
400
- # f"[CORS Debug] Origin {origin} present but no Access-Control-Allow-Origin header set"
401
- # )
402
- # return response
403
-
404
377
  # Mount PulseContext for all FastAPI routes (no route info). Other API
405
378
  # routes / middleware should be added at the module-level, which means
406
379
  # this middleware will wrap all of them.
@@ -419,8 +392,6 @@ class App:
419
392
  )
420
393
  render_id = request.headers.get("x-pulse-render-id")
421
394
  render = self._get_render_for_session(render_id, session)
422
- if render:
423
- print(f"Reusing render session {render_id}")
424
395
  with PulseContext.update(session=session, render=render):
425
396
  res: Response = await call_next(request)
426
397
  session.handle_response(res)
@@ -568,6 +539,26 @@ class App:
568
539
  for plugin in self.plugins:
569
540
  plugin.on_setup(self)
570
541
 
542
+ # In single-server mode, add catch-all route to proxy unmatched requests to React server
543
+ # This route must be registered last so FastAPI tries all specific routes first
544
+ # FastAPI will match specific routes before this catch-all, but we add an explicit check
545
+ # as a safety measure to ensure API routes are never proxied
546
+ if self.mode == "single-server":
547
+ proxy_handler = ReactProxyHandler(lambda: envvars.react_server_address)
548
+
549
+ @self.fastapi.api_route(
550
+ "/{path:path}",
551
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
552
+ include_in_schema=False,
553
+ )
554
+ async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
555
+ # Skip WebSocket upgrades (handled by Socket.IO)
556
+ if request.headers.get("upgrade", "").lower() == "websocket":
557
+ raise HTTPException(status_code=404, detail="Not found")
558
+
559
+ # Proxy all unmatched HTTP requests to React Router
560
+ return await proxy_handler(request)
561
+
571
562
  @self.sio.event
572
563
  async def connect( # pyright: ignore[reportUnusedFunction]
573
564
  sid: str, environ: dict[str, Any], auth: dict[str, str] | None
@@ -902,7 +893,6 @@ class App:
902
893
  self._cancel_render_cleanup(rid)
903
894
 
904
895
  # Close all render sessions
905
- print("Closing app")
906
896
  for rid in list(self.render_sessions.keys()):
907
897
  self.close_render(rid)
908
898
 
@@ -939,7 +929,7 @@ class App:
939
929
  return # no active render for this user session
940
930
 
941
931
  # We don't want to wait for this to resolve
942
- create_task(render.call_api(f"/{self.api_prefix}/set-cookies", method="GET"))
932
+ create_task(render.call_api(f"{self.api_prefix}/set-cookies", method="GET"))
943
933
  sess.scheduled_cookie_refresh = True
944
934
 
945
935
 
@@ -1,15 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Callable, Sequence
4
- from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload, override
5
-
6
- from pulse.messages import ClientMessage, ServerInitMessage
4
+ from typing import Any, Generic, TypeVar, overload, override
5
+
6
+ from pulse.messages import (
7
+ ClientMessage,
8
+ PrerenderPayload,
9
+ PrerenderResult,
10
+ ServerInitMessage,
11
+ )
7
12
  from pulse.request import PulseRequest
8
13
  from pulse.routing import RouteInfo
9
14
 
10
- if TYPE_CHECKING:
11
- from pulse.app import PrerenderPayload, PrerenderResult
12
-
13
15
  T = TypeVar("T")
14
16
 
15
17
 
@@ -0,0 +1,98 @@
1
+ """
2
+ Proxy handler for forwarding requests to React Router server in single-server mode.
3
+ """
4
+
5
+ import logging
6
+ from typing import Callable
7
+
8
+ import httpx
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
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ReactProxyHandler:
18
+ """
19
+ Handles proxying HTTP requests to React Router server.
20
+ """
21
+
22
+ get_react_server_address: Callable[[], str | None]
23
+ _client: httpx.AsyncClient | None
24
+
25
+ def __init__(self, get_react_server_address: Callable[[], str | None]):
26
+ """
27
+ Args:
28
+ get_react_server_address: Callable that returns the React Router server full URL (or None if not started)
29
+ """
30
+ self.get_react_server_address = get_react_server_address
31
+ self._client = None
32
+
33
+ @property
34
+ def client(self) -> httpx.AsyncClient:
35
+ """Lazy initialization of HTTP client."""
36
+ if self._client is None:
37
+ self._client = httpx.AsyncClient(
38
+ timeout=httpx.Timeout(30.0),
39
+ follow_redirects=False,
40
+ )
41
+ return self._client
42
+
43
+ async def __call__(self, request: Request) -> Response:
44
+ """
45
+ Forward HTTP request to React Router server and stream response back.
46
+ """
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
53
+ )
54
+
55
+ # Build target URL
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"}
62
+
63
+ try:
64
+ # Build request
65
+ req = self.client.build_request(
66
+ method=request.method,
67
+ url=url,
68
+ headers=headers,
69
+ content=request.stream(),
70
+ )
71
+
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,
87
+ )
88
+
89
+ except httpx.RequestError as e:
90
+ logger.error(f"Proxy request failed: {e}")
91
+ return PlainTextResponse(
92
+ "Bad Gateway: Could not reach React Router server", status_code=502
93
+ )
94
+
95
+ async def close(self):
96
+ """Close the HTTP client."""
97
+ if self._client is not None:
98
+ await self._client.aclose()
@@ -1,195 +0,0 @@
1
- """
2
- Proxy ASGI app for forwarding requests to React Router server in single-server mode.
3
- """
4
-
5
- import logging
6
- from collections.abc import Iterable
7
- from typing import Callable, cast
8
-
9
- import httpx
10
- from starlette.datastructures import Headers
11
- from starlette.types import ASGIApp, Receive, Scope, Send
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- class PulseProxy:
17
- """
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.
22
- """
23
-
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.
32
-
33
- 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")
37
- """
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
44
-
45
- @property
46
- def client(self) -> httpx.AsyncClient:
47
- """Lazy initialization of HTTP client."""
48
- if self._client is None:
49
- self._client = httpx.AsyncClient(
50
- timeout=httpx.Timeout(30.0),
51
- follow_redirects=False,
52
- )
53
- return self._client
54
-
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:
86
- """
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
- }
99
- )
100
- await send(
101
- {
102
- "type": "http.response.body",
103
- "body": b"Service Unavailable: React server not ready",
104
- }
105
- )
106
- return
107
-
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)
143
-
144
- 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,
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
- }
164
- )
165
-
166
- # Stream response body
167
- await send(
168
- {
169
- "type": "http.response.body",
170
- "body": response.content,
171
- }
172
- )
173
-
174
- except httpx.RequestError as e:
175
- 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
- }
190
- )
191
-
192
- async def close(self):
193
- """Close the HTTP client."""
194
- if self._client is not None:
195
- await self._client.aclose()