pulse-framework 0.1.71__tar.gz → 0.1.73__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 (132) hide show
  1. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/PKG-INFO +4 -4
  2. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/pyproject.toml +4 -4
  3. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/__init__.py +19 -4
  4. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/app.py +27 -24
  5. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/cmd.py +1 -1
  6. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/folder_lock.py +25 -6
  7. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/processes.py +2 -0
  8. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/templates/layout.py +3 -1
  9. pulse_framework-0.1.73/src/pulse/debounce.py +79 -0
  10. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/decorators.py +4 -3
  11. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/effects.py +20 -6
  12. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/runtime.py +25 -8
  13. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/setup.py +6 -10
  14. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/stable.py +5 -9
  15. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/state.py +4 -8
  16. pulse_framework-0.1.73/src/pulse/proxy.py +783 -0
  17. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/common.py +17 -5
  18. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/infinite_query.py +14 -3
  19. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/mutation.py +2 -1
  20. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/query.py +4 -2
  21. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/render_session.py +7 -4
  22. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/renderer.py +30 -2
  23. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/routing.py +19 -5
  24. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/serializer.py +38 -19
  25. pulse_framework-0.1.73/src/pulse/state/__init__.py +1 -0
  26. pulse_framework-0.1.73/src/pulse/state/property.py +218 -0
  27. pulse_framework-0.1.73/src/pulse/state/query_param.py +538 -0
  28. {pulse_framework-0.1.71/src/pulse → pulse_framework-0.1.73/src/pulse/state}/state.py +66 -220
  29. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/nodes.py +26 -2
  30. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/transpiler.py +86 -5
  31. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/vdom.py +1 -1
  32. pulse_framework-0.1.71/src/pulse/proxy.py +0 -249
  33. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/README.md +0 -0
  34. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/_examples.py +0 -0
  35. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/channel.py +0 -0
  36. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/__init__.py +0 -0
  37. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/dependencies.py +0 -0
  38. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/helpers.py +0 -0
  39. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/logging.py +0 -0
  40. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/models.py +0 -0
  41. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/packages.py +0 -0
  42. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/secrets.py +0 -0
  43. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cli/uvicorn_log_config.py +0 -0
  44. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/code_analysis.py +0 -0
  45. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/__init__.py +0 -0
  46. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/codegen.py +0 -0
  47. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/templates/__init__.py +0 -0
  48. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/templates/route.py +0 -0
  49. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/templates/routes_ts.py +0 -0
  50. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/codegen/utils.py +0 -0
  51. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/component.py +0 -0
  52. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/components/__init__.py +0 -0
  53. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/components/for_.py +0 -0
  54. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/components/if_.py +0 -0
  55. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/components/react_router.py +0 -0
  56. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/context.py +0 -0
  57. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/cookies.py +0 -0
  58. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/__init__.py +0 -0
  59. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/elements.py +0 -0
  60. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/events.py +0 -0
  61. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/props.py +0 -0
  62. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/svg.py +0 -0
  63. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/tags.py +0 -0
  64. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/dom/tags.pyi +0 -0
  65. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/env.py +0 -0
  66. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/forms.py +0 -0
  67. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/helpers.py +0 -0
  68. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/__init__.py +0 -0
  69. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/core.py +0 -0
  70. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/hooks/init.py +0 -0
  71. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/__init__.py +0 -0
  72. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/__init__.pyi +0 -0
  73. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/_types.py +0 -0
  74. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/array.py +0 -0
  75. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/console.py +0 -0
  76. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/date.py +0 -0
  77. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/document.py +0 -0
  78. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/error.py +0 -0
  79. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/json.py +0 -0
  80. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/map.py +0 -0
  81. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/math.py +0 -0
  82. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/navigator.py +0 -0
  83. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/number.py +0 -0
  84. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/obj.py +0 -0
  85. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/object.py +0 -0
  86. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/promise.py +0 -0
  87. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/pulse.py +0 -0
  88. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/react.py +0 -0
  89. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/regexp.py +0 -0
  90. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/set.py +0 -0
  91. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/string.py +0 -0
  92. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/weakmap.py +0 -0
  93. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/weakset.py +0 -0
  94. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/js/window.py +0 -0
  95. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/messages.py +0 -0
  96. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/middleware.py +0 -0
  97. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/plugin.py +0 -0
  98. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/py.typed +0 -0
  99. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/__init__.py +0 -0
  100. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/client.py +0 -0
  101. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/effect.py +0 -0
  102. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/protocol.py +0 -0
  103. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/queries/store.py +0 -0
  104. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/react_component.py +0 -0
  105. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/reactive.py +0 -0
  106. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/reactive_extensions.py +0 -0
  107. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/request.py +0 -0
  108. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/requirements.py +0 -0
  109. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/scheduling.py +0 -0
  110. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/test_helpers.py +0 -0
  111. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/__init__.py +0 -0
  112. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/assets.py +0 -0
  113. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/builtins.py +0 -0
  114. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/dynamic_import.py +0 -0
  115. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/emit_context.py +0 -0
  116. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/errors.py +0 -0
  117. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/function.py +0 -0
  118. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/id.py +0 -0
  119. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/imports.py +0 -0
  120. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/js_module.py +0 -0
  121. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/__init__.py +0 -0
  122. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/asyncio.py +0 -0
  123. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/json.py +0 -0
  124. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/math.py +0 -0
  125. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  126. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  127. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/modules/typing.py +0 -0
  128. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/transpiler/py_module.py +0 -0
  129. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/types/__init__.py +0 -0
  130. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/types/event_handler.py +0 -0
  131. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/user_session.py +0 -0
  132. {pulse_framework-0.1.71 → pulse_framework-0.1.73}/src/pulse/version.py +0 -0
@@ -1,21 +1,21 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.71
3
+ Version: 0.1.73
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
- Requires-Dist: websockets>=12.0
6
5
  Requires-Dist: fastapi>=0.128.0
7
6
  Requires-Dist: uvicorn>=0.24.0
8
7
  Requires-Dist: mako>=1.3.10
9
8
  Requires-Dist: typer>=0.16.0
10
9
  Requires-Dist: python-socketio>=5.16.0
11
10
  Requires-Dist: rich>=13.7.1
12
- Requires-Dist: python-multipart>=0.0.20
11
+ Requires-Dist: python-multipart>=0.0.22
13
12
  Requires-Dist: python-dateutil>=2.9.0.post0
14
13
  Requires-Dist: starlette>=0.50.0,<0.51.0
15
14
  Requires-Dist: urllib3>=2.6.3
16
15
  Requires-Dist: watchfiles>=1.1.0
17
16
  Requires-Dist: httpx>=0.28.1
18
- Requires-Python: >=3.11
17
+ Requires-Dist: aiohttp>=3.12.0
18
+ Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
20
 
21
21
  # Pulse Python
@@ -1,23 +1,23 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.71"
3
+ version = "0.1.73"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11"
6
+ requires-python = ">=3.12"
7
7
  dependencies = [
8
- "websockets>=12.0",
9
8
  "fastapi>=0.128.0",
10
9
  "uvicorn>=0.24.0",
11
10
  "mako>=1.3.10",
12
11
  "typer>=0.16.0",
13
12
  "python-socketio>=5.16.0",
14
13
  "rich>=13.7.1",
15
- "python-multipart>=0.0.20",
14
+ "python-multipart>=0.0.22",
16
15
  "python-dateutil>=2.9.0.post0",
17
16
  "starlette>=0.50.0,<0.51.0",
18
17
  "urllib3>=2.6.3",
19
18
  "watchfiles>=1.1.0",
20
19
  "httpx>=0.28.1",
20
+ "aiohttp>=3.12.0",
21
21
  ]
22
22
 
23
23
  [tool.uv]
@@ -60,6 +60,14 @@ from pulse.context import PulseContext as PulseContext
60
60
  from pulse.cookies import Cookie as Cookie
61
61
  from pulse.cookies import SetCookie as SetCookie
62
62
 
63
+ # Debounce
64
+ from pulse.debounce import (
65
+ Debounced as Debounced,
66
+ )
67
+ from pulse.debounce import (
68
+ debounced as debounced,
69
+ )
70
+
63
71
  # Decorators
64
72
  from pulse.decorators import computed as computed
65
73
  from pulse.decorators import effect as effect
@@ -1200,7 +1208,7 @@ from pulse.hooks.core import (
1200
1208
  )
1201
1209
 
1202
1210
  # Hooks - Effects (import to register inline_effect_hook before registry locks)
1203
- from pulse.hooks.effects import InlineEffectHookState as InlineEffectHookState
1211
+ from pulse.hooks.effects import EffectState as EffectState
1204
1212
 
1205
1213
  # Hooks - Init
1206
1214
  from pulse.hooks.init import (
@@ -1235,6 +1243,9 @@ from pulse.hooks.runtime import (
1235
1243
  from pulse.hooks.runtime import (
1236
1244
  not_found as not_found,
1237
1245
  )
1246
+ from pulse.hooks.runtime import (
1247
+ pulse_route as pulse_route,
1248
+ )
1238
1249
  from pulse.hooks.runtime import (
1239
1250
  redirect as redirect,
1240
1251
  )
@@ -1259,7 +1270,7 @@ from pulse.hooks.runtime import (
1259
1270
 
1260
1271
  # Hooks - Setup
1261
1272
  from pulse.hooks.setup import (
1262
- SetupHookState as SetupHookState,
1273
+ SetupState as SetupState,
1263
1274
  )
1264
1275
  from pulse.hooks.setup import (
1265
1276
  setup as setup,
@@ -1271,7 +1282,7 @@ from pulse.hooks.stable import (
1271
1282
  StableEntry as StableEntry,
1272
1283
  )
1273
1284
  from pulse.hooks.stable import (
1274
- StableRegistry as StableRegistry,
1285
+ StableState as StableState,
1275
1286
  )
1276
1287
 
1277
1288
  # Hooks - Stable
@@ -1322,6 +1333,9 @@ from pulse.middleware import (
1322
1333
 
1323
1334
  # Plugin
1324
1335
  from pulse.plugin import Plugin as Plugin
1336
+
1337
+ # Proxy
1338
+ from pulse.proxy import ProxyConfig as ProxyConfig
1325
1339
  from pulse.queries.client import QueryClient as QueryClient
1326
1340
  from pulse.queries.client import QueryFilter as QueryFilter
1327
1341
  from pulse.queries.client import queries as queries
@@ -1430,7 +1444,8 @@ from pulse.serializer import deserialize as deserialize
1430
1444
  from pulse.serializer import serialize as serialize
1431
1445
 
1432
1446
  # State and routing
1433
- from pulse.state import State as State
1447
+ from pulse.state.query_param import QueryParam as QueryParam
1448
+ from pulse.state.state import State as State
1434
1449
 
1435
1450
  # Transpiler v2
1436
1451
  from pulse.transpiler.function import JsFunction as JsFunction
@@ -67,7 +67,7 @@ from pulse.middleware import (
67
67
  Redirect,
68
68
  )
69
69
  from pulse.plugin import Plugin
70
- from pulse.proxy import ReactProxy
70
+ from pulse.proxy import ProxyConfig, ReactProxy
71
71
  from pulse.render_session import RenderSession
72
72
  from pulse.request import PulseRequest
73
73
  from pulse.routing import Layout, Route, RouteTree, ensure_absolute_path
@@ -211,6 +211,7 @@ class App:
211
211
  _tasks: TaskRegistry
212
212
  _timers: TimerRegistry
213
213
  _proxy: ReactProxy | None
214
+ proxy_config: ProxyConfig | None
214
215
  session_timeout: float
215
216
  connection_status: ConnectionStatusConfig
216
217
  render_loop_limit: int
@@ -232,6 +233,7 @@ class App:
232
233
  not_found: str = "/not-found",
233
234
  # Deployment and integration options
234
235
  mode: PulseMode = "single-server",
236
+ proxy: ProxyConfig | None = None,
235
237
  api_prefix: str = "/_pulse",
236
238
  cors: CORSOptions | None = None,
237
239
  fastapi: dict[str, Any] | None = None,
@@ -245,6 +247,7 @@ class App:
245
247
  # Resolve mode from environment and expose on the app instance
246
248
  self.env = envvars.pulse_env
247
249
  self.mode = mode
250
+ self.proxy_config = proxy
248
251
  self.status = AppStatus.created
249
252
  # Persist the server address for use by sessions (API calls, etc.) in ci/prod.
250
253
  self.server_address = server_address if self.env in ("ci", "prod") else None
@@ -506,15 +509,22 @@ class App:
506
509
  )
507
510
  render_id = request.headers.get("x-pulse-render-id")
508
511
  render = self._get_render_for_session(render_id, session)
509
- with PulseContext.update(session=session, render=render):
510
- res: Response = await call_next(request)
511
- session.handle_response(res)
512
-
513
- self._sessions_in_request[session.sid] -= 1
514
- if self._sessions_in_request[session.sid] == 0:
515
- del self._sessions_in_request[session.sid]
516
-
517
- return res
512
+ try:
513
+ with PulseContext.update(session=session, render=render):
514
+ res: Response = await call_next(request)
515
+ session.handle_response(res)
516
+ return res
517
+ except RuntimeError as exc:
518
+ # Client disconnected before response was sent. This happens when
519
+ # ASGI handlers (like the proxy) return early on disconnect without
520
+ # sending a response, which is valid ASGI but breaks BaseHTTPMiddleware.
521
+ if "No response returned" in str(exc):
522
+ return Response(status_code=499)
523
+ raise
524
+ finally:
525
+ self._sessions_in_request[session.sid] -= 1
526
+ if self._sessions_in_request[session.sid] == 0:
527
+ del self._sessions_in_request[session.sid]
518
528
 
519
529
  # Apply prefix to all routes
520
530
  prefix = self.api_prefix
@@ -654,10 +664,8 @@ class App:
654
664
  for plugin in self.plugins:
655
665
  plugin.on_setup(self)
656
666
 
657
- # In single-server mode, add catch-all route to proxy unmatched requests to React server
658
- # This route must be registered last so FastAPI tries all specific routes first
659
- # FastAPI will match specific routes before this catch-all, but we add an explicit check
660
- # as a safety measure to ensure API routes are never proxied
667
+ # In single-server mode, add catch-all route to proxy unmatched requests to React server.
668
+ # This route must be registered last so FastAPI tries all specific routes first.
661
669
  if self.mode == "single-server":
662
670
  react_server_address = envvars.react_server_address
663
671
  if not react_server_address:
@@ -666,11 +674,12 @@ class App:
666
674
  + "Use 'pulse run' CLI command or set the environment variable."
667
675
  )
668
676
 
669
- self._proxy = ReactProxy(
677
+ proxy_handler = ReactProxy(
670
678
  react_server_address=react_server_address,
671
679
  server_address=server_address,
680
+ config=self.proxy_config,
672
681
  )
673
- proxy_handler = self._proxy
682
+ self._proxy = proxy_handler
674
683
 
675
684
  # In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
676
685
  # Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
@@ -680,14 +689,8 @@ class App:
680
689
  async def websocket_proxy(websocket: WebSocket, path: str): # pyright: ignore[reportUnusedFunction]
681
690
  await proxy_handler.proxy_websocket(websocket)
682
691
 
683
- @self.fastapi.api_route(
684
- "/{path:path}",
685
- methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
686
- include_in_schema=False,
687
- )
688
- async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
689
- # Proxy all unmatched HTTP requests to React Router
690
- return await proxy_handler(request)
692
+ # Register ASGI-level catch-all last.
693
+ self.fastapi.mount("/", proxy_handler, name="react-proxy")
691
694
 
692
695
  @self.sio.event
693
696
  async def connect( # pyright: ignore[reportUnusedFunction]
@@ -233,7 +233,7 @@ def run(
233
233
  )
234
234
  commands.append(server_cmd)
235
235
 
236
- with FolderLock(web_root):
236
+ with FolderLock(web_root, address=address, port=port):
237
237
  try:
238
238
  exit_code = execute_commands(
239
239
  commands,
@@ -52,7 +52,7 @@ def _write_gitignore_for_lock(lock_path: Path) -> None:
52
52
  ensure_gitignore_has(lock_path.parent, lock_path.name)
53
53
 
54
54
 
55
- def _create_lock_file(lock_path: Path) -> None:
55
+ def _create_lock_file(lock_path: Path, *, address: str, port: int) -> None:
56
56
  """Create a lock file with current process information."""
57
57
  lock_path = Path(lock_path)
58
58
  _write_gitignore_for_lock(lock_path)
@@ -61,18 +61,26 @@ def _create_lock_file(lock_path: Path) -> None:
61
61
  info = _read_lock(lock_path) or {}
62
62
  pid = int(info.get("pid", 0) or 0)
63
63
  if pid and is_process_alive(pid):
64
+ existing_addr = info.get("address", address)
65
+ existing_port = info.get("port", port)
66
+ protocol = (
67
+ "http" if existing_addr in ("127.0.0.1", "localhost") else "https"
68
+ )
69
+ url = f"{protocol}://{existing_addr}:{existing_port}"
64
70
  raise RuntimeError(
65
- f"Another Pulse dev instance appears to be running (pid={pid}) for {lock_path.parent}."
71
+ f"Another Pulse dev instance is running at {url} (pid={pid})"
66
72
  )
67
73
  # Stale lock; continue to overwrite
68
74
 
69
- payload = {
75
+ payload: dict[str, Any] = {
70
76
  "pid": os.getpid(),
71
77
  "created_at": int(time.time()),
72
78
  "hostname": socket.gethostname(),
73
79
  "platform": platform.platform(),
74
80
  "python": platform.python_version(),
75
81
  "cwd": os.getcwd(),
82
+ "address": address,
83
+ "port": port,
76
84
  }
77
85
  try:
78
86
  lock_path.parent.mkdir(parents=True, exist_ok=True)
@@ -105,23 +113,34 @@ class FolderLock:
105
113
  and know not to delete the lock on exit.
106
114
 
107
115
  Example:
108
- with FolderLock(web_root):
116
+ with FolderLock(web_root, address="localhost", port=8000):
109
117
  # Protected region
110
118
  pass
111
119
  """
112
120
 
113
- def __init__(self, web_root: Path, *, filename: str = ".pulse/lock"):
121
+ def __init__(
122
+ self,
123
+ web_root: Path,
124
+ *,
125
+ address: str,
126
+ port: int,
127
+ filename: str = ".pulse/lock",
128
+ ):
114
129
  """
115
130
  Initialize FolderLock.
116
131
 
117
132
  Args:
118
133
  web_root: Path to the web root directory
134
+ address: Server address to store in lock file
135
+ port: Server port to store in lock file
119
136
  filename: Name of the lock file (default: ".pulse/lock")
120
137
  """
121
138
  self.lock_path: Path = lock_path_for_web_root(web_root, filename)
139
+ self.address: str = address
140
+ self.port: int = port
122
141
 
123
142
  def __enter__(self):
124
- _create_lock_file(self.lock_path)
143
+ _create_lock_file(self.lock_path, address=self.address, port=self.port)
125
144
  return self
126
145
 
127
146
  def __exit__(
@@ -272,6 +272,8 @@ def _write_tagged_line(name: str, message: str, tag_mode: TagMode) -> None:
272
272
  "Network: use --host to expose" in clean_message
273
273
  or "press h + enter to show help" in clean_message
274
274
  or "➜ Local:" in clean_message
275
+ or "/__manifest" in clean_message
276
+ or "?import" in clean_message
275
277
  ):
276
278
  return
277
279
 
@@ -32,7 +32,9 @@ export async function loader(args: LoaderFunctionArgs) {
32
32
  if (cookie) fwd.set("cookie", cookie);
33
33
  if (authorization) fwd.set("authorization", authorization);
34
34
  fwd.set("content-type", "application/json");
35
- const res = await fetch(`${internal_server_address}$${"{"}config.apiPrefix}/prerender`, {
35
+ // Internal server address for server-side loader requests.
36
+ const internalServerAddress = "${internal_server_address}";
37
+ const res = await fetch(`$${"{"}internalServerAddress}$${"{"}config.apiPrefix}/prerender`, {
36
38
  method: "POST",
37
39
  headers: fwd,
38
40
  body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args) }),
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Generic, ParamSpec, TypeVar
8
+
9
+ from pulse.context import PULSE_CONTEXT
10
+ from pulse.scheduling import TimerHandleLike, later
11
+
12
+ P = ParamSpec("P")
13
+ R = TypeVar("R")
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Debounced(Generic[P, R]):
18
+ fn: Callable[P, R]
19
+ delay_ms: float
20
+ _handle: TimerHandleLike | asyncio.Handle | None = field(
21
+ default=None, init=False, repr=False, compare=False
22
+ )
23
+
24
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Any:
25
+ if self._handle is not None:
26
+ self._handle.cancel()
27
+
28
+ delay = self.delay_ms / 1000.0
29
+
30
+ def _run() -> None:
31
+ object.__setattr__(self, "_handle", None)
32
+ result = self.fn(*args, **kwargs)
33
+ if asyncio.iscoroutine(result):
34
+ loop = asyncio.get_running_loop()
35
+ task = loop.create_task(result)
36
+
37
+ def _log_task_exception(t: asyncio.Task[Any]) -> None:
38
+ try:
39
+ t.result()
40
+ except asyncio.CancelledError:
41
+ pass
42
+ except Exception as exc:
43
+ loop.call_exception_handler(
44
+ {
45
+ "message": "Unhandled exception in debounced() task",
46
+ "exception": exc,
47
+ "context": {"callback": self.fn},
48
+ }
49
+ )
50
+
51
+ task.add_done_callback(_log_task_exception)
52
+
53
+ if PULSE_CONTEXT.get() is not None:
54
+ handle = later(delay, _run)
55
+ else:
56
+ try:
57
+ loop = asyncio.get_running_loop()
58
+ except RuntimeError:
59
+ try:
60
+ loop = asyncio.get_event_loop()
61
+ except RuntimeError as exc:
62
+ raise RuntimeError("debounced() requires an event loop") from exc
63
+ handle = loop.call_later(delay, _run)
64
+
65
+ object.__setattr__(self, "_handle", handle)
66
+
67
+
68
+ def debounced(fn: Callable[P, R], delay_ms: int | float) -> Debounced[P, R]:
69
+ """Return a debounced callback marker (delay in milliseconds)."""
70
+ if not callable(fn):
71
+ raise TypeError("debounced() requires a callable")
72
+ if isinstance(delay_ms, bool) or not isinstance(delay_ms, (int, float)):
73
+ raise TypeError("debounced() delay must be a number (ms)")
74
+ if not math.isfinite(delay_ms) or delay_ms < 0:
75
+ raise ValueError("debounced() delay must be finite and >= 0")
76
+ return Debounced(fn=fn, delay_ms=float(delay_ms))
77
+
78
+
79
+ __all__ = ["Debounced", "debounced"]
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
5
5
  from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload
6
6
 
7
7
  from pulse.hooks.core import HOOK_CONTEXT
8
- from pulse.hooks.effects import inline_effect_hook
8
+ from pulse.hooks.effects import effect_state
9
9
  from pulse.hooks.state import collect_component_identity
10
10
  from pulse.reactive import (
11
11
  AsyncEffect,
@@ -16,7 +16,8 @@ from pulse.reactive import (
16
16
  EffectFn,
17
17
  Signal,
18
18
  )
19
- from pulse.state import ComputedProperty, State, StateEffect
19
+ from pulse.state.property import ComputedProperty, StateEffect
20
+ from pulse.state.state import State
20
21
 
21
22
  T = TypeVar("T")
22
23
  TState = TypeVar("TState", bound=State)
@@ -336,7 +337,7 @@ def effect(
336
337
  else:
337
338
  identity = key
338
339
 
339
- state = inline_effect_hook()
340
+ state = effect_state()
340
341
  return state.get_or_create(cast(Any, identity), key, create_effect)
341
342
 
342
343
  if fn is not None:
@@ -2,10 +2,10 @@ from collections.abc import Callable
2
2
  from typing import Any, override
3
3
 
4
4
  from pulse.hooks.core import HookMetadata, HookState, hooks
5
- from pulse.reactive import AsyncEffect, Effect
5
+ from pulse.reactive import REACTIVE_CONTEXT, AsyncEffect, Effect
6
6
 
7
7
 
8
- class InlineEffectHookState(HookState):
8
+ class EffectState(HookState):
9
9
  """Stores inline effects keyed by function identity or explicit key."""
10
10
 
11
11
  __slots__ = ("effects", "_seen_this_render") # pyright: ignore[reportUnannotatedClassAttribute]
@@ -33,6 +33,20 @@ class InlineEffectHookState(HookState):
33
33
  if key not in self._seen_this_render:
34
34
  self.effects[key].dispose()
35
35
  del self.effects[key]
36
+ # Remove inline effects from the active render scope to avoid parent cleanup.
37
+ rc = REACTIVE_CONTEXT.get()
38
+ scope = rc.scope
39
+ if scope is None or not scope.effects:
40
+ return
41
+ for key in self._seen_this_render:
42
+ effect = self.effects.get(key)
43
+ if effect is None:
44
+ continue
45
+ try:
46
+ scope.effects.remove(effect)
47
+ effect.parent = None
48
+ except ValueError:
49
+ pass
36
50
 
37
51
  def get_or_create(
38
52
  self,
@@ -72,9 +86,9 @@ class InlineEffectHookState(HookState):
72
86
  self._seen_this_render.clear()
73
87
 
74
88
 
75
- inline_effect_hook = hooks.create(
89
+ effect_state = hooks.create(
76
90
  "pulse:core.inline_effects",
77
- lambda: InlineEffectHookState(),
91
+ factory=EffectState,
78
92
  metadata=HookMetadata(
79
93
  owner="pulse.core",
80
94
  description="Storage for inline @ps.effect decorators in components",
@@ -83,6 +97,6 @@ inline_effect_hook = hooks.create(
83
97
 
84
98
 
85
99
  __all__ = [
86
- "InlineEffectHookState",
87
- "inline_effect_hook",
100
+ "EffectState",
101
+ "effect_state",
88
102
  ]
@@ -13,8 +13,8 @@ from typing import (
13
13
  from pulse.context import PulseContext
14
14
  from pulse.hooks.core import HOOK_CONTEXT
15
15
  from pulse.reactive_extensions import ReactiveDict
16
- from pulse.routing import RouteContext
17
- from pulse.state import State
16
+ from pulse.routing import Layout, Route, RouteInfo
17
+ from pulse.state.state import State
18
18
 
19
19
 
20
20
  class RedirectInterrupt(Exception):
@@ -47,11 +47,11 @@ class NotFoundInterrupt(Exception):
47
47
  pass
48
48
 
49
49
 
50
- def route() -> RouteContext:
51
- """Get the current route context.
50
+ def route() -> RouteInfo:
51
+ """Get the current route info.
52
52
 
53
53
  Returns:
54
- RouteContext: Object with access to route parameters, path, and query.
54
+ RouteInfo: Mapping with access to route parameters, path, and query.
55
55
 
56
56
  Raises:
57
57
  RuntimeError: If called outside of a component render context.
@@ -61,8 +61,8 @@ def route() -> RouteContext:
61
61
  ```python
62
62
  def user_page():
63
63
  r = ps.route()
64
- user_id = r.params.get("user_id") # From /users/:user_id
65
- page = r.query.get("page", "1") # From ?page=2
64
+ user_id = r["pathParams"].get("user_id") # From /users/:user_id
65
+ page = r["queryParams"].get("page", "1") # From ?page=2
66
66
  return m.Text(f"User {user_id}, Page {page}")
67
67
  ```
68
68
  """
@@ -71,7 +71,24 @@ def route() -> RouteContext:
71
71
  raise RuntimeError(
72
72
  "`pulse.route` can only be called within a component during rendering."
73
73
  )
74
- return ctx.route
74
+ return ctx.route.info
75
+
76
+
77
+ def pulse_route() -> Route | Layout:
78
+ """Get the current route definition.
79
+
80
+ Returns:
81
+ Route | Layout: The active route or layout definition.
82
+
83
+ Raises:
84
+ RuntimeError: If called outside of a component render context.
85
+ """
86
+ ctx = PulseContext.get()
87
+ if not ctx or not ctx.route:
88
+ raise RuntimeError(
89
+ "`pulse.pulse_route` can only be called within a component during rendering."
90
+ )
91
+ return ctx.route.pulse_route
75
92
 
76
93
 
77
94
  def session() -> ReactiveDict[str, Any]:
@@ -10,7 +10,7 @@ P = ParamSpec("P")
10
10
  T = TypeVar("T")
11
11
 
12
12
 
13
- class SetupHookState(HookState):
13
+ class SetupState(HookState):
14
14
  """Internal hook state for the setup hook.
15
15
 
16
16
  Manages the initialization, argument tracking, and lifecycle of
@@ -140,13 +140,9 @@ class SetupHookState(HookState):
140
140
  return key
141
141
 
142
142
 
143
- def _setup_factory():
144
- return SetupHookState()
145
-
146
-
147
- _setup_hook = hooks.create(
143
+ setup_state = hooks.create(
148
144
  "pulse:core.setup",
149
- _setup_factory,
145
+ factory=SetupState,
150
146
  metadata=HookMetadata(
151
147
  owner="pulse.core",
152
148
  description="Internal storage for pulse.setup hook",
@@ -195,7 +191,7 @@ def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
195
191
  - Use ``ps.setup()`` directly when AST rewriting is problematic
196
192
  - Arguments must be consistent across renders (same count and names)
197
193
  """
198
- state = _setup_hook()
194
+ state = setup_state()
199
195
  state.ensure_not_called()
200
196
 
201
197
  key = state.consume_pending_key()
@@ -245,10 +241,10 @@ def setup_key(key: str) -> None:
245
241
  """
246
242
  if not isinstance(key, str):
247
243
  raise TypeError("setup_key() requires a string key")
248
- state = _setup_hook()
244
+ state = setup_state()
249
245
  if state.called_this_render:
250
246
  raise RuntimeError("setup_key() must be called before setup() in a render")
251
247
  state.set_pending_key(key)
252
248
 
253
249
 
254
- __all__ = ["setup", "setup_key", "SetupHookState"]
250
+ __all__ = ["setup", "setup_key", "SetupState"]
@@ -35,7 +35,7 @@ class StableEntry:
35
35
  self.wrapper = wrapper
36
36
 
37
37
 
38
- class StableRegistry(HookState):
38
+ class StableState(HookState):
39
39
  """Internal hook state that stores stable entries by key.
40
40
 
41
41
  Maintains a dictionary of StableEntry objects, allowing stable
@@ -50,13 +50,9 @@ class StableRegistry(HookState):
50
50
  self.entries: dict[str, StableEntry] = {}
51
51
 
52
52
 
53
- def _stable_factory(*_: object) -> StableRegistry:
54
- return StableRegistry()
55
-
56
-
57
- _stable_hook = hooks.create(
53
+ stable_state = hooks.create(
58
54
  "pulse:core.stable",
59
- _stable_factory,
55
+ factory=StableState,
60
56
  metadata=HookMetadata(
61
57
  owner="pulse.core",
62
58
  description="Internal registry for pulse.stable values",
@@ -119,7 +115,7 @@ def stable(key: str, value: Any = MISSING) -> Any:
119
115
  if not key:
120
116
  raise ValueError("stable() requires a non-empty string key")
121
117
 
122
- registry = _stable_hook()
118
+ registry = stable_state()
123
119
  entry = registry.entries.get(key)
124
120
 
125
121
  if value is not MISSING:
@@ -135,4 +131,4 @@ def stable(key: str, value: Any = MISSING) -> Any:
135
131
  return entry.wrapper
136
132
 
137
133
 
138
- __all__ = ["stable", "StableRegistry", "StableEntry"]
134
+ __all__ = ["stable", "StableState", "StableEntry"]