pulse-framework 0.1.62__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/render_session.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import traceback
|
|
4
|
+
import uuid
|
|
5
|
+
from asyncio import iscoroutine
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from pulse.context import PulseContext
|
|
10
|
+
from pulse.helpers import create_future_on_loop, create_task, later
|
|
11
|
+
from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
|
|
12
|
+
from pulse.messages import (
|
|
13
|
+
ServerApiCallMessage,
|
|
14
|
+
ServerErrorPhase,
|
|
15
|
+
ServerInitMessage,
|
|
16
|
+
ServerJsExecMessage,
|
|
17
|
+
ServerMessage,
|
|
18
|
+
ServerNavigateToMessage,
|
|
19
|
+
ServerUpdateMessage,
|
|
20
|
+
)
|
|
21
|
+
from pulse.queries.store import QueryStore
|
|
22
|
+
from pulse.reactive import REACTIVE_CONTEXT, Effect, flush_effects
|
|
23
|
+
from pulse.renderer import RenderTree
|
|
24
|
+
from pulse.routing import (
|
|
25
|
+
Layout,
|
|
26
|
+
Route,
|
|
27
|
+
RouteContext,
|
|
28
|
+
RouteInfo,
|
|
29
|
+
RouteTree,
|
|
30
|
+
ensure_absolute_path,
|
|
31
|
+
)
|
|
32
|
+
from pulse.state import State
|
|
33
|
+
from pulse.transpiler.id import next_id
|
|
34
|
+
from pulse.transpiler.nodes import Expr
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from pulse.channel import ChannelsManager
|
|
38
|
+
from pulse.form import FormRegistry
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__file__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class JsExecError(Exception):
|
|
44
|
+
"""Raised when client-side JS execution fails."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RenderLoopError(RuntimeError):
|
|
48
|
+
path: str
|
|
49
|
+
renders: int
|
|
50
|
+
batch_id: int
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: str, renders: int, batch_id: int) -> None:
|
|
53
|
+
super().__init__(
|
|
54
|
+
"Detected an infinite render loop in Pulse. "
|
|
55
|
+
+ f"Render path '{path}' exceeded {renders} renders in reactive batch {batch_id}. "
|
|
56
|
+
+ "This usually happens when a render or effect mutates state without a guard."
|
|
57
|
+
)
|
|
58
|
+
self.path = path
|
|
59
|
+
self.renders = renders
|
|
60
|
+
self.batch_id = batch_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Module-level convenience wrapper
|
|
64
|
+
@overload
|
|
65
|
+
def run_js(expr: Any, *, result: Literal[True]) -> asyncio.Future[Any]: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@overload
|
|
69
|
+
def run_js(expr: Any, *, result: Literal[False] = ...) -> None: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_js(expr: Any, *, result: bool = False) -> asyncio.Future[Any] | None:
|
|
73
|
+
"""Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
|
|
74
|
+
ctx = PulseContext.get()
|
|
75
|
+
if ctx.render is None:
|
|
76
|
+
raise RuntimeError("run_js() can only be called during callback execution")
|
|
77
|
+
return ctx.render.run_js(expr, result=result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
MountState = Literal["pending", "active", "idle", "closed"]
|
|
81
|
+
PendingAction = Literal["idle", "dispose"]
|
|
82
|
+
T_Render = TypeVar("T_Render")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RouteMount:
|
|
86
|
+
render: "RenderSession"
|
|
87
|
+
path: str
|
|
88
|
+
route: RouteContext
|
|
89
|
+
tree: RenderTree
|
|
90
|
+
effect: Effect | None
|
|
91
|
+
_pulse_ctx: PulseContext | None
|
|
92
|
+
initialized: bool
|
|
93
|
+
state: MountState
|
|
94
|
+
pending_action: PendingAction | None
|
|
95
|
+
queue: list[ServerMessage] | None
|
|
96
|
+
queue_timeout: asyncio.TimerHandle | None
|
|
97
|
+
render_batch_id: int
|
|
98
|
+
render_batch_renders: int
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
render: "RenderSession",
|
|
103
|
+
path: str,
|
|
104
|
+
route: Route | Layout,
|
|
105
|
+
route_info: RouteInfo,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.render = render
|
|
108
|
+
self.path = ensure_absolute_path(path)
|
|
109
|
+
self.route = RouteContext(route_info, route)
|
|
110
|
+
self.effect = None
|
|
111
|
+
self._pulse_ctx = None
|
|
112
|
+
self.tree = RenderTree(route.render())
|
|
113
|
+
self.initialized = False
|
|
114
|
+
self.state = "pending"
|
|
115
|
+
self.pending_action = None
|
|
116
|
+
self.queue = []
|
|
117
|
+
self.queue_timeout = None
|
|
118
|
+
self.render_batch_id = -1
|
|
119
|
+
self.render_batch_renders = 0
|
|
120
|
+
|
|
121
|
+
def update_route(self, route_info: RouteInfo) -> None:
|
|
122
|
+
self.route.update(route_info)
|
|
123
|
+
|
|
124
|
+
def _cancel_pending_timeout(self) -> None:
|
|
125
|
+
if self.queue_timeout is not None:
|
|
126
|
+
self.queue_timeout.cancel()
|
|
127
|
+
self.queue_timeout = None
|
|
128
|
+
self.pending_action = None
|
|
129
|
+
|
|
130
|
+
def _on_pending_timeout(self) -> None:
|
|
131
|
+
if self.state != "pending":
|
|
132
|
+
return
|
|
133
|
+
action = self.pending_action
|
|
134
|
+
self.pending_action = None
|
|
135
|
+
if action == "dispose":
|
|
136
|
+
self.render.dispose_mount(self.path, self)
|
|
137
|
+
return
|
|
138
|
+
self.to_idle()
|
|
139
|
+
|
|
140
|
+
def start_pending(self, timeout: float, *, action: PendingAction = "idle") -> None:
|
|
141
|
+
if self.state == "pending":
|
|
142
|
+
prev_action = self.pending_action
|
|
143
|
+
next_action: PendingAction = (
|
|
144
|
+
"dispose" if prev_action == "dispose" or action == "dispose" else "idle"
|
|
145
|
+
)
|
|
146
|
+
self._cancel_pending_timeout()
|
|
147
|
+
self.pending_action = next_action
|
|
148
|
+
self.queue_timeout = later(timeout, self._on_pending_timeout)
|
|
149
|
+
return
|
|
150
|
+
self._cancel_pending_timeout()
|
|
151
|
+
if self.state == "idle" and self.effect:
|
|
152
|
+
self.effect.resume()
|
|
153
|
+
self.state = "pending"
|
|
154
|
+
self.queue = []
|
|
155
|
+
self.pending_action = action
|
|
156
|
+
self.queue_timeout = later(timeout, self._on_pending_timeout)
|
|
157
|
+
|
|
158
|
+
def activate(self, send_message: Callable[[ServerMessage], Any]) -> None:
|
|
159
|
+
if self.state != "pending":
|
|
160
|
+
return
|
|
161
|
+
self._cancel_pending_timeout()
|
|
162
|
+
if self.queue:
|
|
163
|
+
for msg in self.queue:
|
|
164
|
+
send_message(msg)
|
|
165
|
+
self.queue = None
|
|
166
|
+
self.state = "active"
|
|
167
|
+
|
|
168
|
+
def deliver(
|
|
169
|
+
self, message: ServerMessage, send_message: Callable[[ServerMessage], Any]
|
|
170
|
+
):
|
|
171
|
+
if self.state == "pending":
|
|
172
|
+
if self.queue is None:
|
|
173
|
+
raise RuntimeError(f"Pending mount missing queue for {self.path!r}")
|
|
174
|
+
self.queue.append(message)
|
|
175
|
+
return
|
|
176
|
+
if self.state == "active":
|
|
177
|
+
send_message(message)
|
|
178
|
+
return
|
|
179
|
+
if self.state == "closed":
|
|
180
|
+
raise RuntimeError(f"Message sent to closed mount {self.path!r}")
|
|
181
|
+
|
|
182
|
+
def to_idle(self) -> None:
|
|
183
|
+
if self.state != "pending":
|
|
184
|
+
return
|
|
185
|
+
self.state = "idle"
|
|
186
|
+
self.queue = None
|
|
187
|
+
self._cancel_pending_timeout()
|
|
188
|
+
if self.effect:
|
|
189
|
+
self.effect.pause()
|
|
190
|
+
|
|
191
|
+
def ensure_effect(self, *, lazy: bool = False, flush: bool = True) -> None:
|
|
192
|
+
if self.effect is not None:
|
|
193
|
+
if flush:
|
|
194
|
+
self.effect.flush()
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
ctx = PulseContext.get()
|
|
198
|
+
session = ctx.session
|
|
199
|
+
|
|
200
|
+
def _render_effect():
|
|
201
|
+
message = self.render.rerender(self, self.path, session=session)
|
|
202
|
+
if message is not None:
|
|
203
|
+
self.render.send(message)
|
|
204
|
+
|
|
205
|
+
def _report_render_error(exc: Exception) -> None:
|
|
206
|
+
details: dict[str, Any] | None = None
|
|
207
|
+
if isinstance(exc, RenderLoopError):
|
|
208
|
+
details = {
|
|
209
|
+
"renders": exc.renders,
|
|
210
|
+
"batch_id": exc.batch_id,
|
|
211
|
+
}
|
|
212
|
+
self.render.report_error(self.path, "render", exc, details)
|
|
213
|
+
|
|
214
|
+
self.effect = Effect(
|
|
215
|
+
_render_effect,
|
|
216
|
+
immediate=False,
|
|
217
|
+
name=f"{self.path}:render",
|
|
218
|
+
on_error=_report_render_error,
|
|
219
|
+
lazy=lazy,
|
|
220
|
+
)
|
|
221
|
+
if flush:
|
|
222
|
+
self.effect.flush()
|
|
223
|
+
|
|
224
|
+
def dispose(self) -> None:
|
|
225
|
+
self._cancel_pending_timeout()
|
|
226
|
+
self.state = "closed"
|
|
227
|
+
self.queue = None
|
|
228
|
+
self.tree.unmount()
|
|
229
|
+
if self.effect:
|
|
230
|
+
self.effect.dispose()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class RenderSession:
|
|
234
|
+
id: str
|
|
235
|
+
routes: RouteTree
|
|
236
|
+
channels: "ChannelsManager"
|
|
237
|
+
forms: "FormRegistry"
|
|
238
|
+
query_store: QueryStore
|
|
239
|
+
route_mounts: dict[str, RouteMount]
|
|
240
|
+
connected: bool
|
|
241
|
+
prerender_queue_timeout: float
|
|
242
|
+
detach_queue_timeout: float
|
|
243
|
+
disconnect_queue_timeout: float
|
|
244
|
+
render_loop_limit: int
|
|
245
|
+
_server_address: str | None
|
|
246
|
+
_client_address: str | None
|
|
247
|
+
_send_message: Callable[[ServerMessage], Any] | None
|
|
248
|
+
_pending_api: dict[str, asyncio.Future[dict[str, Any]]]
|
|
249
|
+
_pending_js_results: dict[str, asyncio.Future[Any]]
|
|
250
|
+
_global_states: dict[str, State]
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
id: str,
|
|
255
|
+
routes: RouteTree,
|
|
256
|
+
*,
|
|
257
|
+
server_address: str | None = None,
|
|
258
|
+
client_address: str | None = None,
|
|
259
|
+
prerender_queue_timeout: float = 5.0,
|
|
260
|
+
detach_queue_timeout: float = 15.0,
|
|
261
|
+
disconnect_queue_timeout: float = 300.0,
|
|
262
|
+
render_loop_limit: int = 50,
|
|
263
|
+
) -> None:
|
|
264
|
+
from pulse.channel import ChannelsManager
|
|
265
|
+
from pulse.form import FormRegistry
|
|
266
|
+
|
|
267
|
+
self.id = id
|
|
268
|
+
self.routes = routes
|
|
269
|
+
self.route_mounts = {}
|
|
270
|
+
self._server_address = server_address
|
|
271
|
+
self._client_address = client_address
|
|
272
|
+
self._send_message = None
|
|
273
|
+
self._global_states = {}
|
|
274
|
+
self.query_store = QueryStore()
|
|
275
|
+
self.connected = False
|
|
276
|
+
self.channels = ChannelsManager(self)
|
|
277
|
+
self.forms = FormRegistry(self)
|
|
278
|
+
self._pending_api = {}
|
|
279
|
+
self._pending_js_results = {}
|
|
280
|
+
self.prerender_queue_timeout = prerender_queue_timeout
|
|
281
|
+
self.detach_queue_timeout = detach_queue_timeout
|
|
282
|
+
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
283
|
+
self.render_loop_limit = render_loop_limit
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def server_address(self) -> str:
|
|
287
|
+
if self._server_address is None:
|
|
288
|
+
raise RuntimeError("Server address not set")
|
|
289
|
+
return self._server_address
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def client_address(self) -> str:
|
|
293
|
+
if self._client_address is None:
|
|
294
|
+
raise RuntimeError("Client address not set")
|
|
295
|
+
return self._client_address
|
|
296
|
+
|
|
297
|
+
def _on_effect_error(self, effect: Effect, exc: Exception):
|
|
298
|
+
details = {"effect": effect.name or "<unnamed>"}
|
|
299
|
+
for path in list(self.route_mounts.keys()):
|
|
300
|
+
self.report_error(path, "effect", exc, details)
|
|
301
|
+
|
|
302
|
+
# ---- Connection lifecycle ----
|
|
303
|
+
|
|
304
|
+
def connect(self, send_message: Callable[[ServerMessage], Any]):
|
|
305
|
+
"""WebSocket connected. Set sender, don't auto-flush (attach does that)."""
|
|
306
|
+
self._send_message = send_message
|
|
307
|
+
self.connected = True
|
|
308
|
+
|
|
309
|
+
def disconnect(self):
|
|
310
|
+
"""WebSocket disconnected. Start queuing briefly before pausing."""
|
|
311
|
+
self._send_message = None
|
|
312
|
+
self.connected = False
|
|
313
|
+
|
|
314
|
+
for mount in self.route_mounts.values():
|
|
315
|
+
if mount.state == "active":
|
|
316
|
+
mount.start_pending(self.disconnect_queue_timeout)
|
|
317
|
+
|
|
318
|
+
# ---- Message routing ----
|
|
319
|
+
|
|
320
|
+
def send(self, message: ServerMessage):
|
|
321
|
+
"""Route message based on mount state."""
|
|
322
|
+
# Global messages (not path-specific) go directly if connected
|
|
323
|
+
path = message.get("path")
|
|
324
|
+
if path is None:
|
|
325
|
+
if self._send_message:
|
|
326
|
+
self._send_message(message)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
# Normalize path for lookup
|
|
330
|
+
path = ensure_absolute_path(path)
|
|
331
|
+
mount = self.route_mounts.get(path)
|
|
332
|
+
if not mount:
|
|
333
|
+
# Unknown path - send directly if connected (for js_exec, etc.)
|
|
334
|
+
if self._send_message:
|
|
335
|
+
self._send_message(message)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
if self._send_message:
|
|
339
|
+
mount.deliver(message, self._send_message)
|
|
340
|
+
return
|
|
341
|
+
if mount.state == "pending":
|
|
342
|
+
mount.deliver(message, lambda _: None)
|
|
343
|
+
# idle: drop (effect should be paused anyway)
|
|
344
|
+
|
|
345
|
+
def report_error(
|
|
346
|
+
self,
|
|
347
|
+
path: str,
|
|
348
|
+
phase: ServerErrorPhase,
|
|
349
|
+
exc: BaseException,
|
|
350
|
+
details: dict[str, Any] | None = None,
|
|
351
|
+
):
|
|
352
|
+
self.send(
|
|
353
|
+
{
|
|
354
|
+
"type": "server_error",
|
|
355
|
+
"path": path,
|
|
356
|
+
"error": {
|
|
357
|
+
"message": str(exc),
|
|
358
|
+
"stack": traceback.format_exc(),
|
|
359
|
+
"phase": phase,
|
|
360
|
+
"details": details or {},
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
logger.error(
|
|
365
|
+
"Error reported for path %r during %s: %s\n%s",
|
|
366
|
+
path,
|
|
367
|
+
phase,
|
|
368
|
+
exc,
|
|
369
|
+
traceback.format_exc(),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# ---- State transitions ----
|
|
373
|
+
|
|
374
|
+
# ---- Prerendering ----
|
|
375
|
+
|
|
376
|
+
def prerender(
|
|
377
|
+
self, paths: list[str], route_info: RouteInfo | None = None
|
|
378
|
+
) -> dict[str, ServerInitMessage | ServerNavigateToMessage]:
|
|
379
|
+
"""
|
|
380
|
+
Synchronous render for SSR. Returns per-path init or navigate_to messages.
|
|
381
|
+
- Creates mounts in PENDING state and starts queue
|
|
382
|
+
"""
|
|
383
|
+
normalized = [ensure_absolute_path(path) for path in paths]
|
|
384
|
+
|
|
385
|
+
results: dict[str, ServerInitMessage | ServerNavigateToMessage] = {}
|
|
386
|
+
|
|
387
|
+
for path in normalized:
|
|
388
|
+
route = self.routes.find(path)
|
|
389
|
+
info = route_info or route.default_route_info()
|
|
390
|
+
mount = self.route_mounts.get(path)
|
|
391
|
+
|
|
392
|
+
if mount is None:
|
|
393
|
+
mount = RouteMount(self, path, route, info)
|
|
394
|
+
self.route_mounts[path] = mount
|
|
395
|
+
mount.ensure_effect(lazy=True, flush=False)
|
|
396
|
+
else:
|
|
397
|
+
mount.update_route(info)
|
|
398
|
+
if mount.effect is None:
|
|
399
|
+
mount.ensure_effect(lazy=True, flush=False)
|
|
400
|
+
|
|
401
|
+
if mount.state != "active" and mount.queue_timeout is None:
|
|
402
|
+
mount.start_pending(self.prerender_queue_timeout)
|
|
403
|
+
assert mount.effect is not None
|
|
404
|
+
with mount.effect.capture_deps(update_deps=True):
|
|
405
|
+
message = self.render(mount, path)
|
|
406
|
+
|
|
407
|
+
results[path] = message
|
|
408
|
+
if message["type"] == "navigate_to":
|
|
409
|
+
mount.dispose()
|
|
410
|
+
del self.route_mounts[path]
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
return results
|
|
414
|
+
|
|
415
|
+
# ---- Client lifecycle ----
|
|
416
|
+
|
|
417
|
+
def attach(self, path: str, route_info: RouteInfo):
|
|
418
|
+
"""
|
|
419
|
+
Client ready to receive updates for path.
|
|
420
|
+
- PENDING: flush queue, transition to ACTIVE
|
|
421
|
+
- IDLE: request reload
|
|
422
|
+
- ACTIVE: update route_info
|
|
423
|
+
- No mount: request reload
|
|
424
|
+
"""
|
|
425
|
+
path = ensure_absolute_path(path)
|
|
426
|
+
mount = self.route_mounts.get(path)
|
|
427
|
+
|
|
428
|
+
if mount is None or mount.state == "idle":
|
|
429
|
+
# Initial render must come from prerender
|
|
430
|
+
self.send({"type": "reload"})
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
# Update route info for active and pending mounts
|
|
434
|
+
mount.update_route(route_info)
|
|
435
|
+
if mount.state == "pending" and self._send_message:
|
|
436
|
+
mount.activate(self._send_message)
|
|
437
|
+
|
|
438
|
+
def update_route(self, path: str, route_info: RouteInfo):
|
|
439
|
+
"""Update routing state (query params, etc.) for attached path."""
|
|
440
|
+
path = ensure_absolute_path(path)
|
|
441
|
+
try:
|
|
442
|
+
mount = self.get_route_mount(path)
|
|
443
|
+
mount.update_route(route_info)
|
|
444
|
+
except Exception as e:
|
|
445
|
+
self.report_error(path, "navigate", e)
|
|
446
|
+
|
|
447
|
+
def dispose_mount(self, path: str, mount: RouteMount) -> None:
|
|
448
|
+
current = self.route_mounts.get(path)
|
|
449
|
+
if current is not mount:
|
|
450
|
+
return
|
|
451
|
+
try:
|
|
452
|
+
self.route_mounts.pop(path, None)
|
|
453
|
+
mount.dispose()
|
|
454
|
+
except Exception as e:
|
|
455
|
+
self.report_error(path, "unmount", e)
|
|
456
|
+
|
|
457
|
+
def detach(self, path: str, *, timeout: float | None = None):
|
|
458
|
+
"""Client no longer wants updates. Queue briefly, then dispose."""
|
|
459
|
+
path = ensure_absolute_path(path)
|
|
460
|
+
mount = self.route_mounts.get(path)
|
|
461
|
+
if not mount:
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if timeout is None:
|
|
465
|
+
timeout = self.detach_queue_timeout
|
|
466
|
+
if timeout <= 0:
|
|
467
|
+
self.dispose_mount(path, mount)
|
|
468
|
+
return
|
|
469
|
+
mount.start_pending(timeout, action="dispose")
|
|
470
|
+
|
|
471
|
+
# ---- Effect creation ----
|
|
472
|
+
|
|
473
|
+
def _check_render_loop(self, mount: RouteMount, path: str) -> None:
|
|
474
|
+
batch_id = REACTIVE_CONTEXT.get().batch.flush_id
|
|
475
|
+
if mount.render_batch_id == batch_id:
|
|
476
|
+
mount.render_batch_renders += 1
|
|
477
|
+
else:
|
|
478
|
+
mount.render_batch_id = batch_id
|
|
479
|
+
mount.render_batch_renders = 1
|
|
480
|
+
if mount.render_batch_renders > self.render_loop_limit:
|
|
481
|
+
if mount.effect:
|
|
482
|
+
mount.effect.pause()
|
|
483
|
+
raise RenderLoopError(path, mount.render_batch_renders, batch_id)
|
|
484
|
+
|
|
485
|
+
def _render_with_interrupts(
|
|
486
|
+
self,
|
|
487
|
+
mount: RouteMount,
|
|
488
|
+
path: str,
|
|
489
|
+
*,
|
|
490
|
+
session: Any | None = None,
|
|
491
|
+
render_fn: Callable[[], T_Render],
|
|
492
|
+
) -> T_Render | ServerNavigateToMessage:
|
|
493
|
+
ctx = PulseContext.get()
|
|
494
|
+
render_session = ctx.session if session is None else session
|
|
495
|
+
with PulseContext.update(
|
|
496
|
+
session=render_session, render=self, route=mount.route
|
|
497
|
+
):
|
|
498
|
+
try:
|
|
499
|
+
self._check_render_loop(mount, path)
|
|
500
|
+
return render_fn()
|
|
501
|
+
except RedirectInterrupt as r:
|
|
502
|
+
return ServerNavigateToMessage(
|
|
503
|
+
type="navigate_to",
|
|
504
|
+
path=r.path,
|
|
505
|
+
replace=r.replace,
|
|
506
|
+
hard=False,
|
|
507
|
+
)
|
|
508
|
+
except NotFoundInterrupt:
|
|
509
|
+
ctx = PulseContext.get()
|
|
510
|
+
return ServerNavigateToMessage(
|
|
511
|
+
type="navigate_to",
|
|
512
|
+
path=ctx.app.not_found,
|
|
513
|
+
replace=True,
|
|
514
|
+
hard=False,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def render(
|
|
518
|
+
self, mount: RouteMount, path: str, *, session: Any | None = None
|
|
519
|
+
) -> ServerInitMessage | ServerNavigateToMessage:
|
|
520
|
+
def _render() -> ServerInitMessage:
|
|
521
|
+
vdom = mount.tree.render()
|
|
522
|
+
mount.initialized = True
|
|
523
|
+
return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
524
|
+
|
|
525
|
+
message = self._render_with_interrupts(
|
|
526
|
+
mount, path, session=session, render_fn=_render
|
|
527
|
+
)
|
|
528
|
+
return message
|
|
529
|
+
|
|
530
|
+
def rerender(
|
|
531
|
+
self, mount: RouteMount, path: str, *, session: Any | None = None
|
|
532
|
+
) -> ServerUpdateMessage | ServerNavigateToMessage | None:
|
|
533
|
+
def _rerender() -> ServerUpdateMessage | None:
|
|
534
|
+
if not mount.initialized:
|
|
535
|
+
raise RuntimeError(f"rerender called before init for {path!r}")
|
|
536
|
+
ops = mount.tree.rerender()
|
|
537
|
+
if ops:
|
|
538
|
+
return ServerUpdateMessage(type="vdom_update", path=path, ops=ops)
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
return self._render_with_interrupts(
|
|
542
|
+
mount, path, session=session, render_fn=_rerender
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# ---- Helpers ----
|
|
546
|
+
|
|
547
|
+
def close(self):
|
|
548
|
+
self.forms.dispose()
|
|
549
|
+
for path in list(self.route_mounts.keys()):
|
|
550
|
+
self.detach(path, timeout=0)
|
|
551
|
+
self.route_mounts.clear()
|
|
552
|
+
for value in self._global_states.values():
|
|
553
|
+
value.dispose()
|
|
554
|
+
self._global_states.clear()
|
|
555
|
+
for channel_id in list(self.channels._channels.keys()): # pyright: ignore[reportPrivateUsage]
|
|
556
|
+
channel = self.channels._channels.get(channel_id) # pyright: ignore[reportPrivateUsage]
|
|
557
|
+
if channel:
|
|
558
|
+
channel.closed = True
|
|
559
|
+
self.channels.dispose_channel(channel, reason="render.close")
|
|
560
|
+
for fut in self._pending_api.values():
|
|
561
|
+
if not fut.done():
|
|
562
|
+
fut.cancel()
|
|
563
|
+
self._pending_api.clear()
|
|
564
|
+
for fut in self._pending_js_results.values():
|
|
565
|
+
if not fut.done():
|
|
566
|
+
fut.cancel()
|
|
567
|
+
self._pending_js_results.clear()
|
|
568
|
+
self._send_message = None
|
|
569
|
+
self.connected = False
|
|
570
|
+
|
|
571
|
+
def get_route_mount(self, path: str) -> RouteMount:
|
|
572
|
+
path = ensure_absolute_path(path)
|
|
573
|
+
mount = self.route_mounts.get(path)
|
|
574
|
+
if not mount:
|
|
575
|
+
raise ValueError(f"No active route for '{path}'")
|
|
576
|
+
return mount
|
|
577
|
+
|
|
578
|
+
def get_global_state(self, key: str, factory: Callable[[], Any]) -> Any:
|
|
579
|
+
"""Return a per-session singleton for the provided key."""
|
|
580
|
+
inst = self._global_states.get(key)
|
|
581
|
+
if inst is None:
|
|
582
|
+
inst = factory()
|
|
583
|
+
self._global_states[key] = inst
|
|
584
|
+
return inst
|
|
585
|
+
|
|
586
|
+
def flush(self):
|
|
587
|
+
with PulseContext.update(render=self):
|
|
588
|
+
flush_effects()
|
|
589
|
+
|
|
590
|
+
def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
|
|
591
|
+
mount = self.route_mounts[path]
|
|
592
|
+
cb = mount.tree.callbacks[key]
|
|
593
|
+
|
|
594
|
+
def report(e: BaseException, is_async: bool = False):
|
|
595
|
+
self.report_error(path, "callback", e, {"callback": key, "async": is_async})
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
with PulseContext.update(render=self, route=mount.route):
|
|
599
|
+
res = cb.fn(*args[: cb.n_args])
|
|
600
|
+
if iscoroutine(res):
|
|
601
|
+
create_task(
|
|
602
|
+
res, on_done=lambda t: (e := t.exception()) and report(e, True)
|
|
603
|
+
)
|
|
604
|
+
except Exception as e:
|
|
605
|
+
report(e)
|
|
606
|
+
|
|
607
|
+
# ---- API calls ----
|
|
608
|
+
|
|
609
|
+
async def call_api(
|
|
610
|
+
self,
|
|
611
|
+
url_or_path: str,
|
|
612
|
+
*,
|
|
613
|
+
method: str = "POST",
|
|
614
|
+
headers: dict[str, str] | None = None,
|
|
615
|
+
body: Any | None = None,
|
|
616
|
+
credentials: str = "include",
|
|
617
|
+
timeout: float = 30.0,
|
|
618
|
+
) -> dict[str, Any]:
|
|
619
|
+
"""Request the client to perform a fetch and await the result."""
|
|
620
|
+
if url_or_path.startswith("http://") or url_or_path.startswith("https://"):
|
|
621
|
+
url = url_or_path
|
|
622
|
+
else:
|
|
623
|
+
base = self.server_address
|
|
624
|
+
if not base:
|
|
625
|
+
raise RuntimeError(
|
|
626
|
+
"Server address unavailable. Ensure App.run_codegen/asgi_factory set server_address."
|
|
627
|
+
)
|
|
628
|
+
api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
|
|
629
|
+
url = f"{base}{api_path}"
|
|
630
|
+
corr_id = uuid.uuid4().hex
|
|
631
|
+
fut = create_future_on_loop()
|
|
632
|
+
self._pending_api[corr_id] = fut
|
|
633
|
+
headers = headers or {}
|
|
634
|
+
headers["x-pulse-render-id"] = self.id
|
|
635
|
+
self.send(
|
|
636
|
+
ServerApiCallMessage(
|
|
637
|
+
type="api_call",
|
|
638
|
+
id=corr_id,
|
|
639
|
+
url=url,
|
|
640
|
+
method=method,
|
|
641
|
+
headers=headers,
|
|
642
|
+
body=body,
|
|
643
|
+
credentials="include" if credentials == "include" else "omit",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
try:
|
|
647
|
+
result = await asyncio.wait_for(fut, timeout=timeout)
|
|
648
|
+
except asyncio.TimeoutError:
|
|
649
|
+
self._pending_api.pop(corr_id, None)
|
|
650
|
+
raise
|
|
651
|
+
return result
|
|
652
|
+
|
|
653
|
+
def handle_api_result(self, data: dict[str, Any]):
|
|
654
|
+
id_ = data.get("id")
|
|
655
|
+
if id_ is None:
|
|
656
|
+
return
|
|
657
|
+
id_ = str(id_)
|
|
658
|
+
fut = self._pending_api.pop(id_, None)
|
|
659
|
+
if fut and not fut.done():
|
|
660
|
+
fut.set_result(
|
|
661
|
+
{
|
|
662
|
+
"ok": data.get("ok", False),
|
|
663
|
+
"status": data.get("status", 0),
|
|
664
|
+
"headers": data.get("headers", {}),
|
|
665
|
+
"body": data.get("body"),
|
|
666
|
+
}
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# ---- JS Execution ----
|
|
670
|
+
|
|
671
|
+
@overload
|
|
672
|
+
def run_js(
|
|
673
|
+
self, expr: Any, *, result: Literal[True], timeout: float = ...
|
|
674
|
+
) -> asyncio.Future[object]: ...
|
|
675
|
+
|
|
676
|
+
@overload
|
|
677
|
+
def run_js(
|
|
678
|
+
self,
|
|
679
|
+
expr: Any,
|
|
680
|
+
*,
|
|
681
|
+
result: Literal[False] = ...,
|
|
682
|
+
timeout: float = ...,
|
|
683
|
+
) -> None: ...
|
|
684
|
+
|
|
685
|
+
def run_js(
|
|
686
|
+
self, expr: Any, *, result: bool = False, timeout: float = 10.0
|
|
687
|
+
) -> asyncio.Future[object] | None:
|
|
688
|
+
"""Execute JavaScript on the client.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
expr: An Expr from calling a @javascript function.
|
|
692
|
+
result: If True, returns a Future that resolves with the JS return value.
|
|
693
|
+
If False (default), returns None (fire-and-forget).
|
|
694
|
+
timeout: Maximum seconds to wait for result (default 10s, only applies when
|
|
695
|
+
result=True). Future raises asyncio.TimeoutError if exceeded.
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
None if result=False, otherwise a Future resolving to the JS result.
|
|
699
|
+
|
|
700
|
+
Example - Fire and forget:
|
|
701
|
+
@javascript
|
|
702
|
+
def focus_element(selector: str):
|
|
703
|
+
document.querySelector(selector).focus()
|
|
704
|
+
|
|
705
|
+
def on_save():
|
|
706
|
+
save_data()
|
|
707
|
+
run_js(focus_element("#next-input"))
|
|
708
|
+
|
|
709
|
+
Example - Await result:
|
|
710
|
+
@javascript
|
|
711
|
+
def get_scroll_position():
|
|
712
|
+
return {"x": window.scrollX, "y": window.scrollY}
|
|
713
|
+
|
|
714
|
+
async def on_click():
|
|
715
|
+
pos = await run_js(get_scroll_position(), result=True)
|
|
716
|
+
print(pos["x"], pos["y"])
|
|
717
|
+
"""
|
|
718
|
+
if not isinstance(expr, Expr):
|
|
719
|
+
raise TypeError(
|
|
720
|
+
f"run_js() requires an Expr (from @javascript function or pulse.js module), got {type(expr).__name__}"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
ctx = PulseContext.get()
|
|
724
|
+
exec_id = next_id()
|
|
725
|
+
|
|
726
|
+
# Get route pattern path (e.g., "/users/:id") not pathname (e.g., "/users/123")
|
|
727
|
+
# This must match the path used to key views on the client side
|
|
728
|
+
path = ctx.route.pulse_route.unique_path() if ctx.route else "/"
|
|
729
|
+
|
|
730
|
+
self.send(
|
|
731
|
+
ServerJsExecMessage(
|
|
732
|
+
type="js_exec",
|
|
733
|
+
path=path,
|
|
734
|
+
id=exec_id,
|
|
735
|
+
expr=expr.render(),
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
if result:
|
|
740
|
+
loop = asyncio.get_running_loop()
|
|
741
|
+
future: asyncio.Future[object] = loop.create_future()
|
|
742
|
+
self._pending_js_results[exec_id] = future
|
|
743
|
+
|
|
744
|
+
def _on_timeout() -> None:
|
|
745
|
+
self._pending_js_results.pop(exec_id, None)
|
|
746
|
+
if not future.done():
|
|
747
|
+
future.set_exception(asyncio.TimeoutError())
|
|
748
|
+
|
|
749
|
+
loop.call_later(timeout, _on_timeout)
|
|
750
|
+
|
|
751
|
+
return future
|
|
752
|
+
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
def handle_js_result(self, data: dict[str, Any]) -> None:
|
|
756
|
+
"""Handle js_result message from client."""
|
|
757
|
+
exec_id = data.get("id")
|
|
758
|
+
if exec_id is None:
|
|
759
|
+
return
|
|
760
|
+
exec_id = str(exec_id)
|
|
761
|
+
fut = self._pending_js_results.pop(exec_id, None)
|
|
762
|
+
if fut is None or fut.done():
|
|
763
|
+
return
|
|
764
|
+
error = data.get("error")
|
|
765
|
+
if error is not None:
|
|
766
|
+
fut.set_exception(JsExecError(error))
|
|
767
|
+
else:
|
|
768
|
+
fut.set_result(data.get("result"))
|