pulse-framework 0.1.39__tar.gz → 0.1.41__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 (78) hide show
  1. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/__init__.py +14 -4
  4. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/app.py +176 -126
  5. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/channel.py +7 -7
  6. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/cmd.py +81 -45
  7. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/models.py +2 -0
  8. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/processes.py +67 -22
  9. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/uvicorn_log_config.py +1 -1
  10. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/codegen.py +14 -1
  11. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/templates/layout.py +10 -2
  12. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/decorators.py +132 -40
  13. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/form.py +9 -9
  14. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/helpers.py +75 -11
  15. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/core.py +4 -3
  16. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/states.py +91 -54
  17. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/messages.py +1 -1
  18. pulse_framework-0.1.41/src/pulse/middleware.py +400 -0
  19. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/plugin.py +0 -3
  20. pulse_framework-0.1.41/src/pulse/proxy.py +216 -0
  21. pulse_framework-0.1.41/src/pulse/queries/common.py +24 -0
  22. pulse_framework-0.1.41/src/pulse/queries/mutation.py +142 -0
  23. pulse_framework-0.1.41/src/pulse/queries/query.py +270 -0
  24. pulse_framework-0.1.41/src/pulse/queries/query_observer.py +365 -0
  25. pulse_framework-0.1.41/src/pulse/queries/store.py +60 -0
  26. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/reactive.py +146 -50
  27. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/render_session.py +5 -2
  28. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/routing.py +68 -10
  29. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/state.py +8 -7
  30. pulse_framework-0.1.41/src/pulse/types/__init__.py +0 -0
  31. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/types/event_handler.py +2 -3
  32. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/user_session.py +3 -2
  33. pulse_framework-0.1.39/src/pulse/middleware.py +0 -349
  34. pulse_framework-0.1.39/src/pulse/proxy.py +0 -195
  35. pulse_framework-0.1.39/src/pulse/query.py +0 -408
  36. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/README.md +0 -0
  37. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/__init__.py +0 -0
  38. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/dependencies.py +0 -0
  39. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/folder_lock.py +0 -0
  40. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/helpers.py +0 -0
  41. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/packages.py +0 -0
  42. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cli/secrets.py +0 -0
  43. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/__init__.py +0 -0
  44. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/imports.py +0 -0
  45. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/js.py +0 -0
  46. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/templates/__init__.py +0 -0
  47. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/templates/route.py +0 -0
  48. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/templates/routes_ts.py +0 -0
  49. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/codegen/utils.py +0 -0
  50. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/components/__init__.py +0 -0
  51. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/components/for_.py +0 -0
  52. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/components/if_.py +0 -0
  53. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/components/react_router.py +0 -0
  54. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/context.py +0 -0
  55. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/cookies.py +0 -0
  56. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/css.py +0 -0
  57. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/env.py +0 -0
  58. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/__init__.py +0 -0
  59. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/effects.py +0 -0
  60. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/runtime.py +0 -0
  61. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/setup.py +0 -0
  62. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/hooks/stable.py +0 -0
  63. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/__init__.py +0 -0
  64. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/elements.py +0 -0
  65. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/events.py +0 -0
  66. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/props.py +0 -0
  67. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/svg.py +0 -0
  68. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/tags.py +0 -0
  69. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/html/tags.pyi +0 -0
  70. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/py.typed +0 -0
  71. {pulse_framework-0.1.39/src/pulse/types → pulse_framework-0.1.41/src/pulse/queries}/__init__.py +0 -0
  72. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/react_component.py +0 -0
  73. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/reactive_extensions.py +0 -0
  74. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/renderer.py +0 -0
  75. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/request.py +0 -0
  76. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/serializer.py +0 -0
  77. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/src/pulse/vdom.py +0 -0
  78. {pulse_framework-0.1.39 → pulse_framework-0.1.41}/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.39
3
+ Version: 0.1.41
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.39"
3
+ version = "0.1.41"
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"
@@ -15,6 +15,9 @@
15
15
  # (2) All imports should target the module in which a symbol is actually defined, rather than a
16
16
  # container module where it is imported.
17
17
 
18
+ # External re-exports
19
+ from starlette.datastructures import UploadFile as UploadFile
20
+
18
21
  # Core app/session
19
22
  from pulse.app import App as App
20
23
  from pulse.app import PulseMode as PulseMode
@@ -69,6 +72,7 @@ from pulse.css import (
69
72
  # Decorators
70
73
  from pulse.decorators import computed as computed
71
74
  from pulse.decorators import effect as effect
75
+ from pulse.decorators import mutation as mutation
72
76
  from pulse.decorators import query as query
73
77
 
74
78
  # Environment
@@ -89,9 +93,6 @@ from pulse.form import (
89
93
  from pulse.form import (
90
94
  ManualForm as ManualForm,
91
95
  )
92
- from pulse.form import (
93
- UploadFile as UploadFile,
94
- )
95
96
 
96
97
  # Helpers
97
98
  from pulse.helpers import (
@@ -1300,9 +1301,10 @@ from pulse.html.tags import (
1300
1301
  from pulse.html.tags import (
1301
1302
  wbr as wbr,
1302
1303
  )
1304
+ from pulse.messages import ClientMessage as ClientMessage
1303
1305
  from pulse.messages import Directives as Directives
1306
+ from pulse.messages import Prerender as Prerender
1304
1307
  from pulse.messages import PrerenderPayload as PrerenderPayload
1305
- from pulse.messages import PrerenderResult as PrerenderResult
1306
1308
  from pulse.messages import SocketIODirectives as SocketIODirectives
1307
1309
 
1308
1310
  # Middleware
@@ -1312,6 +1314,9 @@ from pulse.middleware import (
1312
1314
  from pulse.middleware import (
1313
1315
  Deny as Deny,
1314
1316
  )
1317
+ from pulse.middleware import (
1318
+ LatencyMiddleware as LatencyMiddleware,
1319
+ )
1315
1320
  from pulse.middleware import (
1316
1321
  MiddlewareStack as MiddlewareStack,
1317
1322
  )
@@ -1330,12 +1335,16 @@ from pulse.middleware import (
1330
1335
  from pulse.middleware import (
1331
1336
  Redirect as Redirect,
1332
1337
  )
1338
+ from pulse.middleware import (
1339
+ RoutePrerenderResponse as RoutePrerenderResponse,
1340
+ )
1333
1341
  from pulse.middleware import (
1334
1342
  stack as stack,
1335
1343
  )
1336
1344
 
1337
1345
  # Plugin
1338
1346
  from pulse.plugin import Plugin as Plugin
1347
+ from pulse.queries.query import QueryStatus as QueryStatus
1339
1348
 
1340
1349
  # React component registry
1341
1350
  from pulse.react_component import (
@@ -1419,6 +1428,7 @@ from pulse.render_session import (
1419
1428
  from pulse.request import PulseRequest as PulseRequest
1420
1429
  from pulse.routing import Layout as Layout
1421
1430
  from pulse.routing import Route as Route
1431
+ from pulse.routing import RouteInfo as RouteInfo
1422
1432
  from pulse.serializer import deserialize as deserialize
1423
1433
 
1424
1434
  # Serializer
@@ -11,6 +11,7 @@ import os
11
11
  from collections import defaultdict
12
12
  from collections.abc import Awaitable, Sequence
13
13
  from contextlib import asynccontextmanager
14
+ from dataclasses import dataclass
14
15
  from enum import IntEnum
15
16
  from typing import Any, Callable, Literal, TypeVar, cast
16
17
 
@@ -57,21 +58,23 @@ from pulse.messages import (
57
58
  ClientChannelResponseMessage,
58
59
  ClientMessage,
59
60
  ClientPulseMessage,
61
+ Prerender,
60
62
  PrerenderPayload,
61
- PrerenderResult,
62
63
  ServerMessage,
63
64
  )
64
65
  from pulse.middleware import (
66
+ ConnectResponse,
65
67
  Deny,
66
68
  MiddlewareStack,
67
69
  NotFound,
68
70
  Ok,
69
- PulseCoreMiddleware,
71
+ PrerenderResponse,
70
72
  PulseMiddleware,
71
73
  Redirect,
74
+ RoutePrerenderResponse,
72
75
  )
73
76
  from pulse.plugin import Plugin
74
- from pulse.proxy import PulseProxy
77
+ from pulse.proxy import ReactProxy
75
78
  from pulse.react_component import ReactComponent, registered_react_components
76
79
  from pulse.render_session import RenderSession
77
80
  from pulse.request import PulseRequest
@@ -100,6 +103,25 @@ class AppStatus(IntEnum):
100
103
  PulseMode = Literal["subdomains", "single-server"]
101
104
 
102
105
 
106
+ @dataclass
107
+ class ConnectionStatusConfig:
108
+ """
109
+ Configuration for connection status message delays.
110
+
111
+ Attributes:
112
+ initial_connecting_delay: Delay in seconds before showing "Connecting..." message
113
+ on initial connection attempt. Default: 2.0
114
+ initial_error_delay: Additional delay in seconds before showing error message
115
+ on initial connection attempt (after connecting message). Default: 8.0
116
+ reconnect_error_delay: Delay in seconds before showing error message when
117
+ reconnecting after losing connection. Default: 8.0
118
+ """
119
+
120
+ initial_connecting_delay: float = 2.0
121
+ initial_error_delay: float = 8.0
122
+ reconnect_error_delay: float = 8.0
123
+
124
+
103
125
  class App:
104
126
  """
105
127
  Pulse UI Application - the main entry point for defining your app.
@@ -144,11 +166,11 @@ class App:
144
166
  _socket_to_render: dict[str, str]
145
167
  _render_cleanups: dict[str, asyncio.TimerHandle]
146
168
  session_timeout: float
169
+ connection_status: ConnectionStatusConfig
147
170
 
148
171
  def __init__(
149
172
  self,
150
173
  routes: Sequence[Route | Layout] | None = None,
151
- dev_routes: Sequence[Route | Layout] | None = None,
152
174
  codegen: CodegenConfig | None = None,
153
175
  middleware: PulseMiddleware | Sequence[PulseMiddleware] | None = None,
154
176
  plugins: Sequence[Plugin] | None = None,
@@ -164,14 +186,8 @@ class App:
164
186
  cors: CORSOptions | None = None,
165
187
  fastapi: dict[str, Any] | None = None,
166
188
  session_timeout: float = 60.0,
189
+ connection_status: ConnectionStatusConfig | None = None,
167
190
  ):
168
- """
169
- Initialize a new Pulse App.
170
-
171
- Args:
172
- routes: Optional list of Route objects to register.
173
- codegen: Optional codegen configuration.
174
- """
175
191
  # Resolve mode from environment and expose on the app instance
176
192
  self.env = envvars.pulse_env
177
193
  self.mode = mode
@@ -197,13 +213,12 @@ class App:
197
213
  # Add plugin routes after user-defined routes
198
214
  for plugin in self.plugins:
199
215
  all_routes.extend(plugin.routes())
200
- if self.env == "dev":
201
- all_routes.extend(plugin.dev_routes())
202
216
 
203
217
  # Auto-add React components to all routes
204
218
  add_react_components(all_routes, registered_react_components())
205
219
  add_css_modules(all_routes, registered_css_modules())
206
220
  add_css_imports(all_routes, registered_css_imports())
221
+ # RouteTree filters routes based on dev flag and environment during construction
207
222
  self.routes = RouteTree(all_routes)
208
223
  self.not_found = not_found
209
224
  # Default not-found path for client-side navigation on not_found()
@@ -222,6 +237,7 @@ class App:
222
237
  # Map render_id -> cleanup timer handle for timeout-based expiry
223
238
  self._render_cleanups = {}
224
239
  self.session_timeout = session_timeout
240
+ self.connection_status = connection_status or ConnectionStatusConfig()
225
241
 
226
242
  self.codegen = Codegen(
227
243
  self.routes,
@@ -236,11 +252,11 @@ class App:
236
252
  self.asgi = socketio.ASGIApp(self.sio, self.fastapi)
237
253
 
238
254
  if middleware is None:
239
- mw_stack: list[PulseMiddleware] = [PulseCoreMiddleware()]
255
+ mw_stack: list[PulseMiddleware] = []
240
256
  elif isinstance(middleware, PulseMiddleware):
241
- mw_stack = [PulseCoreMiddleware(), middleware]
257
+ mw_stack = [middleware]
242
258
  else:
243
- mw_stack = [PulseCoreMiddleware(), *middleware]
259
+ mw_stack = list(middleware)
244
260
 
245
261
  # Let plugins contribute middleware (in plugin priority order)
246
262
  for plugin in self.plugins:
@@ -266,10 +282,6 @@ class App:
266
282
  logger.info(
267
283
  f"Single-server mode: React Router running at {react_server_address}"
268
284
  )
269
- else:
270
- logger.warning(
271
- "Single-server mode: PULSE_REACT_SERVER_ADDRESS not set."
272
- )
273
285
 
274
286
  try:
275
287
  yield
@@ -303,6 +315,7 @@ class App:
303
315
  self.server_address,
304
316
  self.internal_server_address or self.server_address,
305
317
  self.api_prefix,
318
+ connection_status=self.connection_status,
306
319
  )
307
320
 
308
321
  def asgi_factory(self):
@@ -335,11 +348,6 @@ class App:
335
348
  self.setup(server_address)
336
349
  self.status = AppStatus.running
337
350
 
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
351
  return self.asgi
344
352
 
345
353
  def run(
@@ -379,28 +387,6 @@ class App:
379
387
  **cors_config,
380
388
  )
381
389
 
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
390
  # Mount PulseContext for all FastAPI routes (no route info). Other API
405
391
  # routes / middleware should be added at the module-level, which means
406
392
  # this middleware will wrap all of them.
@@ -419,8 +405,6 @@ class App:
419
405
  )
420
406
  render_id = request.headers.get("x-pulse-render-id")
421
407
  render = self._get_render_for_session(render_id, session)
422
- if render:
423
- print(f"Reusing render session {render_id}")
424
408
  with PulseContext.update(session=session, render=render):
425
409
  res: Response = await call_next(request)
426
410
  session.handle_response(res)
@@ -476,15 +460,7 @@ class App:
476
460
  # Schedule cleanup timeout (will cancel/reschedule on activity)
477
461
  self._schedule_render_cleanup(render_id)
478
462
 
479
- initial_result: PrerenderResult = {
480
- "views": {},
481
- "directives": {
482
- "headers": {"X-Pulse-Render-Id": render_id},
483
- "socketio": {"auth": {"render_id": render_id}, "headers": {}},
484
- },
485
- }
486
-
487
- def _prerender_one(path: str):
463
+ async def _prerender_one(path: str):
488
464
  captured = render.prerender_mount_capture(path, route_info)
489
465
  if captured["type"] == "vdom_init":
490
466
  return Ok(captured)
@@ -498,57 +474,85 @@ class App:
498
474
  # Fallback: shouldn't happen, return not found to be safe
499
475
  return NotFound()
500
476
 
501
- result = initial_result.copy()
477
+ def _normalize_prerender_response(res: Any) -> RoutePrerenderResponse:
478
+ if isinstance(res, (Ok, Redirect, NotFound)):
479
+ return res
480
+ # Treat any other value as a VDOM payload
481
+ return Ok(res)
502
482
 
503
483
  with PulseContext.update(render=render):
504
- for p in paths:
505
- try:
506
- res = self.middleware.prerender_route(
507
- path=p,
508
- route_info=route_info,
509
- request=PulseRequest.from_fastapi(request),
510
- session=session.data,
511
- next=lambda p=p: _prerender_one(p),
512
- )
513
- if isinstance(res, Ok):
514
- result["views"][p] = res.payload
515
- elif isinstance(res, Redirect):
516
- # Abort immediately with JSON redirect signal
517
- location = res.path or "/"
518
- resp = JSONResponse({"redirect": location})
519
- session.handle_response(resp)
520
- return resp
521
- elif isinstance(res, NotFound):
522
- # Abort immediately with JSON notFound signal
523
- resp = JSONResponse({"notFound": True})
524
- session.handle_response(resp)
525
- return resp
526
- else:
527
- raise ValueError("Unexpected prerender response:", res)
528
- except RedirectInterrupt as r:
529
- resp = JSONResponse({"redirect": r.path})
530
- session.handle_response(resp)
531
- return resp
532
- except NotFoundInterrupt:
533
- resp = JSONResponse({"notFound": True})
534
- session.handle_response(resp)
535
- return resp
536
-
537
- # Call top-level batch prerender middleware to modify final result
538
- def _return_result() -> PrerenderResult:
539
- return result
540
-
541
- final_result = self.middleware.prerender(
542
- payload=payload,
543
- result=result,
544
- request=PulseRequest.from_fastapi(request),
545
- session=session.data,
546
- next=_return_result,
547
- )
484
+ # Call top-level prerender middleware, which wraps the route processing
485
+ async def _process_routes() -> PrerenderResponse:
486
+ result_data: Prerender = {
487
+ "views": {},
488
+ "directives": {
489
+ "headers": {"X-Pulse-Render-Id": render_id},
490
+ "socketio": {
491
+ "auth": {"render_id": render_id},
492
+ "headers": {},
493
+ },
494
+ },
495
+ }
496
+
497
+ # Fan out on routes
498
+ for p in paths:
499
+ try:
500
+ # Capture p in closure to avoid loop variable binding issue
501
+ async def _next(path: str = p) -> RoutePrerenderResponse:
502
+ return await _prerender_one(path)
503
+
504
+ # Call prerender_route middleware (in) -> prerender route -> (out)
505
+ res = await self.middleware.prerender_route(
506
+ path=p,
507
+ route_info=route_info,
508
+ request=PulseRequest.from_fastapi(request),
509
+ session=session.data,
510
+ next=_next,
511
+ )
512
+ res = _normalize_prerender_response(res)
513
+ if isinstance(res, Ok):
514
+ # Aggregate results
515
+ result_data["views"][p] = res.payload
516
+ elif isinstance(res, Redirect):
517
+ # Return redirect immediately
518
+ return Redirect(path=res.path or "/")
519
+ elif isinstance(res, NotFound):
520
+ # Return not found immediately
521
+ return NotFound()
522
+ else:
523
+ raise ValueError("Unexpected prerender response:", res)
524
+ except RedirectInterrupt as r:
525
+ return Redirect(path=r.path)
526
+ except NotFoundInterrupt:
527
+ return NotFound()
528
+
529
+ return Ok(result_data)
530
+
531
+ result = await self.middleware.prerender(
532
+ payload=payload,
533
+ request=PulseRequest.from_fastapi(request),
534
+ session=session.data,
535
+ next=_process_routes,
536
+ )
548
537
 
549
- resp = JSONResponse(serialize(final_result))
550
- session.handle_response(resp)
551
- return resp
538
+ # Handle redirect/notFound responses
539
+ if isinstance(result, Redirect):
540
+ resp = JSONResponse({"redirect": result.path})
541
+ session.handle_response(resp)
542
+ return resp
543
+ if isinstance(result, NotFound):
544
+ resp = JSONResponse({"notFound": True})
545
+ session.handle_response(resp)
546
+ return resp
547
+
548
+ # Handle Ok result - serialize the payload (PrerenderResultData)
549
+ if isinstance(result, Ok):
550
+ resp = JSONResponse(serialize(result.payload))
551
+ session.handle_response(resp)
552
+ return resp
553
+
554
+ # Fallback (shouldn't happen)
555
+ raise ValueError("Unexpected prerender result type")
552
556
 
553
557
  @self.fastapi.post(f"{prefix}/forms/{{render_id}}/{{form_id}}")
554
558
  async def handle_form_submit( # pyright: ignore[reportUnusedFunction]
@@ -568,6 +572,37 @@ class App:
568
572
  for plugin in self.plugins:
569
573
  plugin.on_setup(self)
570
574
 
575
+ # In single-server mode, add catch-all route to proxy unmatched requests to React server
576
+ # This route must be registered last so FastAPI tries all specific routes first
577
+ # FastAPI will match specific routes before this catch-all, but we add an explicit check
578
+ # as a safety measure to ensure API routes are never proxied
579
+ if self.mode == "single-server":
580
+ react_server_address = envvars.react_server_address
581
+ if not react_server_address:
582
+ raise RuntimeError(
583
+ "PULSE_REACT_SERVER_ADDRESS must be set in single-server mode. "
584
+ + "Use 'pulse run' CLI command or set the environment variable."
585
+ )
586
+
587
+ proxy_handler = ReactProxy(react_server_address)
588
+
589
+ @self.fastapi.api_route(
590
+ "/{path:path}",
591
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
592
+ include_in_schema=False,
593
+ )
594
+ async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
595
+ # Skip WebSocket upgrades outside the Vite dev server (handled by Socket.IO)
596
+ is_websocket_upgrade = (
597
+ request.headers.get("upgrade", "").lower() == "websocket"
598
+ )
599
+ is_vite_dev_server = self.env == "dev" and request.url.path == "/"
600
+ if is_websocket_upgrade and not is_vite_dev_server:
601
+ raise HTTPException(status_code=404, detail="Not found")
602
+
603
+ # Proxy all unmatched HTTP requests to React Router
604
+ return await proxy_handler(request)
605
+
571
606
  @self.sio.event
572
607
  async def connect( # pyright: ignore[reportUnusedFunction]
573
608
  sid: str, environ: dict[str, Any], auth: dict[str, str] | None
@@ -611,20 +646,26 @@ class App:
611
646
 
612
647
  with PulseContext.update(session=session, render=render):
613
648
 
614
- def _next():
649
+ async def _next():
650
+ return Ok(None)
651
+
652
+ def _normalize_connect_response(res: Any) -> ConnectResponse:
653
+ if isinstance(res, (Ok, Deny)):
654
+ return res # type: ignore[return-value]
655
+ # Treat any other value as allow
615
656
  return Ok(None)
616
657
 
617
658
  try:
618
- res = self.middleware.connect(
659
+ res = await self.middleware.connect(
619
660
  request=PulseRequest.from_socketio_environ(environ, auth),
620
661
  session=session.data,
621
662
  next=_next,
622
663
  )
664
+ res = _normalize_connect_response(res)
623
665
  except Exception as exc:
624
666
  render.report_error("/", "connect", exc)
625
667
  res = Ok(None)
626
668
  if isinstance(res, Deny):
627
- print(f"Denying connection, closing RenderSession {rid}")
628
669
  # Tear down the created session if denied
629
670
  self.close_render(rid)
630
671
 
@@ -632,7 +673,6 @@ class App:
632
673
  def disconnect(sid: str): # pyright: ignore[reportUnusedFunction]
633
674
  rid = self._socket_to_render.pop(sid, None)
634
675
  if rid is not None:
635
- print(f"Disconnecting WebSocket for RenderSession {rid}")
636
676
  render = self.render_sessions.get(rid)
637
677
  if render:
638
678
  render.connected = False
@@ -640,7 +680,7 @@ class App:
640
680
  self._schedule_render_cleanup(rid)
641
681
 
642
682
  @self.sio.event
643
- def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
683
+ async def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
644
684
  rid = self._socket_to_render.get(sid)
645
685
  if not rid:
646
686
  return
@@ -655,9 +695,9 @@ class App:
655
695
  msg = cast(ClientMessage, deserialize(data))
656
696
  try:
657
697
  if msg["type"] == "channel_message":
658
- self._handle_channel_message(render, session, msg)
698
+ await self._handle_channel_message(render, session, msg)
659
699
  else:
660
- self._handle_pulse_message(render, session, msg)
700
+ await self._handle_pulse_message(render, session, msg)
661
701
  except Exception as e:
662
702
  path = msg.get("path", "")
663
703
  render.report_error(path, "server", e)
@@ -697,10 +737,10 @@ class App:
697
737
  handle = later(self.session_timeout, _cleanup)
698
738
  self._render_cleanups[rid] = handle
699
739
 
700
- def _handle_pulse_message(
740
+ async def _handle_pulse_message(
701
741
  self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
702
742
  ) -> None:
703
- def _next() -> Ok[None]:
743
+ async def _next() -> Ok[None]:
704
744
  if msg["type"] == "mount":
705
745
  render.mount(msg["path"], msg["routeInfo"])
706
746
  elif msg["type"] == "navigate":
@@ -716,13 +756,20 @@ class App:
716
756
  logger.warning("Unknown message type received: %s", msg)
717
757
  return Ok()
718
758
 
759
+ def _normalize_message_response(res: Any) -> Ok[None] | Deny:
760
+ if isinstance(res, (Ok, Deny)):
761
+ return res # type: ignore[return-value]
762
+ # Treat any other value as allow
763
+ return Ok(None)
764
+
719
765
  with PulseContext.update(session=session, render=render):
720
766
  try:
721
- res = self.middleware.message(
767
+ res = await self.middleware.message(
722
768
  data=msg,
723
769
  session=session.data,
724
770
  next=_next,
725
771
  )
772
+ res = _normalize_message_response(res)
726
773
  except Exception:
727
774
  logger.exception("Error in message middleware")
728
775
  return
@@ -736,7 +783,7 @@ class App:
736
783
  {"kind": "deny"},
737
784
  )
738
785
 
739
- def _handle_channel_message(
786
+ async def _handle_channel_message(
740
787
  self, render: RenderSession, session: UserSession, msg: ClientChannelMessage
741
788
  ) -> None:
742
789
  if msg.get("responseTo"):
@@ -746,15 +793,20 @@ class App:
746
793
  channel_id = str(msg.get("channel", ""))
747
794
  msg = cast(ClientChannelRequestMessage, msg)
748
795
 
749
- def _next() -> Ok[Any]:
750
- return Ok(
751
- render.channels.handle_client_event(
752
- render=render, session=session, message=msg
753
- )
796
+ async def _next() -> Ok[None]:
797
+ render.channels.handle_client_event(
798
+ render=render, session=session, message=msg
754
799
  )
800
+ return Ok(None)
801
+
802
+ def _normalize_message_response(res: Any) -> Ok[None] | Deny:
803
+ if isinstance(res, (Ok, Deny)):
804
+ return res # type: ignore[return-value]
805
+ # Treat any other value as allow
806
+ return Ok(None)
755
807
 
756
808
  with PulseContext.update(session=session, render=render):
757
- res = self.middleware.channel(
809
+ res = await self.middleware.channel(
758
810
  channel_id=channel_id,
759
811
  event=msg.get("event", ""),
760
812
  payload=msg.get("payload"),
@@ -762,6 +814,7 @@ class App:
762
814
  session=session.data,
763
815
  next=_next,
764
816
  )
817
+ res = _normalize_message_response(res)
765
818
 
766
819
  if isinstance(res, Deny):
767
820
  if req_id := msg.get("requestId"):
@@ -853,7 +906,6 @@ class App:
853
906
  ):
854
907
  if rid in self.render_sessions:
855
908
  raise ValueError(f"RenderSession {rid} already exists")
856
- print(f"Creating RenderSession {rid}")
857
909
  render = RenderSession(
858
910
  rid,
859
911
  self.routes,
@@ -872,7 +924,6 @@ class App:
872
924
  render = self.render_sessions.pop(rid, None)
873
925
  if not render:
874
926
  return
875
- print(f"Closing RenderSession {rid}")
876
927
  sid = self._render_to_user.pop(rid)
877
928
  session = self.user_sessions[sid]
878
929
  render.close()
@@ -902,7 +953,6 @@ class App:
902
953
  self._cancel_render_cleanup(rid)
903
954
 
904
955
  # Close all render sessions
905
- print("Closing app")
906
956
  for rid in list(self.render_sessions.keys()):
907
957
  self.close_render(rid)
908
958
 
@@ -15,7 +15,6 @@ from pulse.messages import (
15
15
  ServerChannelRequestMessage,
16
16
  ServerChannelResponseMessage,
17
17
  )
18
- from pulse.routing import normalize_path
19
18
 
20
19
  if TYPE_CHECKING:
21
20
  from pulse.render_session import RenderSession
@@ -69,7 +68,8 @@ class ChannelsManager:
69
68
 
70
69
  route_path: str | None = None
71
70
  if ctx.route is not None:
72
- route_path = normalize_path(ctx.route.pulse_route.unique_path())
71
+ # unique_path() returns absolute path, use as-is for keys
72
+ route_path = ctx.route.pulse_route.unique_path()
73
73
 
74
74
  channel = Channel(
75
75
  self,
@@ -84,18 +84,18 @@ class ChannelsManager:
84
84
  return channel
85
85
 
86
86
  # ------------------------------------------------------------------
87
- def remove_route(self, route_path: str) -> None:
88
- key = normalize_path(route_path)
89
- route_channels = list(self._channels_by_route.get(key, set()))
87
+ def remove_route(self, path: str) -> None:
88
+ # route_path is already an absolute path
89
+ route_channels = list(self._channels_by_route.get(path, set()))
90
90
  # if route_channels:
91
- # print(f"Disposing {len(route_channels)} channel(s) for route {key}")
91
+ # print(f"Disposing {len(route_channels)} channel(s) for route {route_path}")
92
92
  for channel_id in route_channels:
93
93
  channel = self._channels.get(channel_id)
94
94
  if channel is None:
95
95
  continue
96
96
  channel.closed = True
97
97
  self.dispose_channel(channel, reason="route.unmount")
98
- self._channels_by_route.pop(key, None)
98
+ self._channels_by_route.pop(path, None)
99
99
 
100
100
  # ------------------------------------------------------------------
101
101
  def handle_client_response(self, message: ClientChannelResponseMessage) -> None: