pulse-framework 0.1.40__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.40 → pulse_framework-0.1.41}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/__init__.py +14 -4
  4. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/app.py +159 -99
  5. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/channel.py +7 -7
  6. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/cmd.py +81 -45
  7. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/models.py +2 -0
  8. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/processes.py +67 -22
  9. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/uvicorn_log_config.py +1 -1
  10. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/codegen.py +14 -1
  11. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/templates/layout.py +10 -2
  12. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/decorators.py +132 -40
  13. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/form.py +9 -9
  14. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/helpers.py +75 -11
  15. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/core.py +4 -3
  16. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/states.py +91 -54
  17. {pulse_framework-0.1.40 → 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.40 → 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.40 → pulse_framework-0.1.41}/src/pulse/reactive.py +146 -50
  27. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/render_session.py +5 -2
  28. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/routing.py +68 -10
  29. {pulse_framework-0.1.40 → 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.40 → pulse_framework-0.1.41}/src/pulse/types/event_handler.py +2 -3
  32. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/user_session.py +3 -2
  33. pulse_framework-0.1.40/src/pulse/middleware.py +0 -349
  34. pulse_framework-0.1.40/src/pulse/proxy.py +0 -98
  35. pulse_framework-0.1.40/src/pulse/query.py +0 -408
  36. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/README.md +0 -0
  37. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/__init__.py +0 -0
  38. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/dependencies.py +0 -0
  39. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/folder_lock.py +0 -0
  40. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/helpers.py +0 -0
  41. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/packages.py +0 -0
  42. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cli/secrets.py +0 -0
  43. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/__init__.py +0 -0
  44. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/imports.py +0 -0
  45. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/js.py +0 -0
  46. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/templates/__init__.py +0 -0
  47. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/templates/route.py +0 -0
  48. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/templates/routes_ts.py +0 -0
  49. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/codegen/utils.py +0 -0
  50. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/components/__init__.py +0 -0
  51. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/components/for_.py +0 -0
  52. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/components/if_.py +0 -0
  53. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/components/react_router.py +0 -0
  54. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/context.py +0 -0
  55. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/cookies.py +0 -0
  56. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/css.py +0 -0
  57. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/env.py +0 -0
  58. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/__init__.py +0 -0
  59. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/effects.py +0 -0
  60. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/runtime.py +0 -0
  61. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/setup.py +0 -0
  62. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/hooks/stable.py +0 -0
  63. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/__init__.py +0 -0
  64. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/elements.py +0 -0
  65. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/events.py +0 -0
  66. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/props.py +0 -0
  67. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/svg.py +0 -0
  68. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/tags.py +0 -0
  69. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/html/tags.pyi +0 -0
  70. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/py.typed +0 -0
  71. {pulse_framework-0.1.40/src/pulse/types → pulse_framework-0.1.41/src/pulse/queries}/__init__.py +0 -0
  72. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/react_component.py +0 -0
  73. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/reactive_extensions.py +0 -0
  74. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/renderer.py +0 -0
  75. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/request.py +0 -0
  76. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/serializer.py +0 -0
  77. {pulse_framework-0.1.40 → pulse_framework-0.1.41}/src/pulse/vdom.py +0 -0
  78. {pulse_framework-0.1.40 → 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.40
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.40"
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 ReactProxyHandler
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):
@@ -447,15 +460,7 @@ class App:
447
460
  # Schedule cleanup timeout (will cancel/reschedule on activity)
448
461
  self._schedule_render_cleanup(render_id)
449
462
 
450
- initial_result: PrerenderResult = {
451
- "views": {},
452
- "directives": {
453
- "headers": {"X-Pulse-Render-Id": render_id},
454
- "socketio": {"auth": {"render_id": render_id}, "headers": {}},
455
- },
456
- }
457
-
458
- def _prerender_one(path: str):
463
+ async def _prerender_one(path: str):
459
464
  captured = render.prerender_mount_capture(path, route_info)
460
465
  if captured["type"] == "vdom_init":
461
466
  return Ok(captured)
@@ -469,57 +474,85 @@ class App:
469
474
  # Fallback: shouldn't happen, return not found to be safe
470
475
  return NotFound()
471
476
 
472
- 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)
473
482
 
474
483
  with PulseContext.update(render=render):
475
- for p in paths:
476
- try:
477
- res = self.middleware.prerender_route(
478
- path=p,
479
- route_info=route_info,
480
- request=PulseRequest.from_fastapi(request),
481
- session=session.data,
482
- next=lambda p=p: _prerender_one(p),
483
- )
484
- if isinstance(res, Ok):
485
- result["views"][p] = res.payload
486
- elif isinstance(res, Redirect):
487
- # Abort immediately with JSON redirect signal
488
- location = res.path or "/"
489
- resp = JSONResponse({"redirect": location})
490
- session.handle_response(resp)
491
- return resp
492
- elif isinstance(res, NotFound):
493
- # Abort immediately with JSON notFound signal
494
- resp = JSONResponse({"notFound": True})
495
- session.handle_response(resp)
496
- return resp
497
- else:
498
- raise ValueError("Unexpected prerender response:", res)
499
- except RedirectInterrupt as r:
500
- resp = JSONResponse({"redirect": r.path})
501
- session.handle_response(resp)
502
- return resp
503
- except NotFoundInterrupt:
504
- resp = JSONResponse({"notFound": True})
505
- session.handle_response(resp)
506
- return resp
507
-
508
- # Call top-level batch prerender middleware to modify final result
509
- def _return_result() -> PrerenderResult:
510
- return result
511
-
512
- final_result = self.middleware.prerender(
513
- payload=payload,
514
- result=result,
515
- request=PulseRequest.from_fastapi(request),
516
- session=session.data,
517
- next=_return_result,
518
- )
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
+ )
519
537
 
520
- resp = JSONResponse(serialize(final_result))
521
- session.handle_response(resp)
522
- 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")
523
556
 
524
557
  @self.fastapi.post(f"{prefix}/forms/{{render_id}}/{{form_id}}")
525
558
  async def handle_form_submit( # pyright: ignore[reportUnusedFunction]
@@ -544,7 +577,14 @@ class App:
544
577
  # FastAPI will match specific routes before this catch-all, but we add an explicit check
545
578
  # as a safety measure to ensure API routes are never proxied
546
579
  if self.mode == "single-server":
547
- proxy_handler = ReactProxyHandler(lambda: envvars.react_server_address)
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)
548
588
 
549
589
  @self.fastapi.api_route(
550
590
  "/{path:path}",
@@ -552,8 +592,12 @@ class App:
552
592
  include_in_schema=False,
553
593
  )
554
594
  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":
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:
557
601
  raise HTTPException(status_code=404, detail="Not found")
558
602
 
559
603
  # Proxy all unmatched HTTP requests to React Router
@@ -602,20 +646,26 @@ class App:
602
646
 
603
647
  with PulseContext.update(session=session, render=render):
604
648
 
605
- 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
606
656
  return Ok(None)
607
657
 
608
658
  try:
609
- res = self.middleware.connect(
659
+ res = await self.middleware.connect(
610
660
  request=PulseRequest.from_socketio_environ(environ, auth),
611
661
  session=session.data,
612
662
  next=_next,
613
663
  )
664
+ res = _normalize_connect_response(res)
614
665
  except Exception as exc:
615
666
  render.report_error("/", "connect", exc)
616
667
  res = Ok(None)
617
668
  if isinstance(res, Deny):
618
- print(f"Denying connection, closing RenderSession {rid}")
619
669
  # Tear down the created session if denied
620
670
  self.close_render(rid)
621
671
 
@@ -623,7 +673,6 @@ class App:
623
673
  def disconnect(sid: str): # pyright: ignore[reportUnusedFunction]
624
674
  rid = self._socket_to_render.pop(sid, None)
625
675
  if rid is not None:
626
- print(f"Disconnecting WebSocket for RenderSession {rid}")
627
676
  render = self.render_sessions.get(rid)
628
677
  if render:
629
678
  render.connected = False
@@ -631,7 +680,7 @@ class App:
631
680
  self._schedule_render_cleanup(rid)
632
681
 
633
682
  @self.sio.event
634
- def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
683
+ async def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
635
684
  rid = self._socket_to_render.get(sid)
636
685
  if not rid:
637
686
  return
@@ -646,9 +695,9 @@ class App:
646
695
  msg = cast(ClientMessage, deserialize(data))
647
696
  try:
648
697
  if msg["type"] == "channel_message":
649
- self._handle_channel_message(render, session, msg)
698
+ await self._handle_channel_message(render, session, msg)
650
699
  else:
651
- self._handle_pulse_message(render, session, msg)
700
+ await self._handle_pulse_message(render, session, msg)
652
701
  except Exception as e:
653
702
  path = msg.get("path", "")
654
703
  render.report_error(path, "server", e)
@@ -688,10 +737,10 @@ class App:
688
737
  handle = later(self.session_timeout, _cleanup)
689
738
  self._render_cleanups[rid] = handle
690
739
 
691
- def _handle_pulse_message(
740
+ async def _handle_pulse_message(
692
741
  self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
693
742
  ) -> None:
694
- def _next() -> Ok[None]:
743
+ async def _next() -> Ok[None]:
695
744
  if msg["type"] == "mount":
696
745
  render.mount(msg["path"], msg["routeInfo"])
697
746
  elif msg["type"] == "navigate":
@@ -707,13 +756,20 @@ class App:
707
756
  logger.warning("Unknown message type received: %s", msg)
708
757
  return Ok()
709
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
+
710
765
  with PulseContext.update(session=session, render=render):
711
766
  try:
712
- res = self.middleware.message(
767
+ res = await self.middleware.message(
713
768
  data=msg,
714
769
  session=session.data,
715
770
  next=_next,
716
771
  )
772
+ res = _normalize_message_response(res)
717
773
  except Exception:
718
774
  logger.exception("Error in message middleware")
719
775
  return
@@ -727,7 +783,7 @@ class App:
727
783
  {"kind": "deny"},
728
784
  )
729
785
 
730
- def _handle_channel_message(
786
+ async def _handle_channel_message(
731
787
  self, render: RenderSession, session: UserSession, msg: ClientChannelMessage
732
788
  ) -> None:
733
789
  if msg.get("responseTo"):
@@ -737,15 +793,20 @@ class App:
737
793
  channel_id = str(msg.get("channel", ""))
738
794
  msg = cast(ClientChannelRequestMessage, msg)
739
795
 
740
- def _next() -> Ok[Any]:
741
- return Ok(
742
- render.channels.handle_client_event(
743
- render=render, session=session, message=msg
744
- )
796
+ async def _next() -> Ok[None]:
797
+ render.channels.handle_client_event(
798
+ render=render, session=session, message=msg
745
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)
746
807
 
747
808
  with PulseContext.update(session=session, render=render):
748
- res = self.middleware.channel(
809
+ res = await self.middleware.channel(
749
810
  channel_id=channel_id,
750
811
  event=msg.get("event", ""),
751
812
  payload=msg.get("payload"),
@@ -753,6 +814,7 @@ class App:
753
814
  session=session.data,
754
815
  next=_next,
755
816
  )
817
+ res = _normalize_message_response(res)
756
818
 
757
819
  if isinstance(res, Deny):
758
820
  if req_id := msg.get("requestId"):
@@ -844,7 +906,6 @@ class App:
844
906
  ):
845
907
  if rid in self.render_sessions:
846
908
  raise ValueError(f"RenderSession {rid} already exists")
847
- print(f"Creating RenderSession {rid}")
848
909
  render = RenderSession(
849
910
  rid,
850
911
  self.routes,
@@ -863,7 +924,6 @@ class App:
863
924
  render = self.render_sessions.pop(rid, None)
864
925
  if not render:
865
926
  return
866
- print(f"Closing RenderSession {rid}")
867
927
  sid = self._render_to_user.pop(rid)
868
928
  session = self.user_sessions[sid]
869
929
  render.close()
@@ -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: