pulse-framework 0.1.53__py3-none-any.whl → 0.1.55__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 +3 -3
- pulse/app.py +34 -20
- pulse/code_analysis.py +38 -0
- pulse/codegen/codegen.py +18 -50
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +24 -6
- pulse/components/for_.py +17 -2
- pulse/cookies.py +38 -2
- pulse/env.py +4 -4
- pulse/hooks/init.py +174 -14
- pulse/hooks/state.py +105 -0
- pulse/js/__init__.py +12 -9
- pulse/js/obj.py +79 -0
- pulse/js/pulse.py +112 -0
- pulse/js/react.py +457 -0
- pulse/messages.py +13 -13
- pulse/proxy.py +18 -5
- pulse/render_session.py +282 -266
- pulse/renderer.py +36 -73
- pulse/serializer.py +5 -2
- pulse/transpiler/__init__.py +13 -0
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/builtins.py +0 -20
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +29 -11
- pulse/transpiler/function.py +36 -5
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +73 -20
- pulse/transpiler/modules/pulse/tags.py +35 -15
- pulse/transpiler/nodes.py +121 -36
- pulse/transpiler/py_module.py +1 -1
- pulse/transpiler/react_component.py +4 -11
- pulse/transpiler/transpiler.py +32 -26
- pulse/user_session.py +10 -0
- pulse_framework-0.1.55.dist-info/METADATA +196 -0
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
- pulse/hooks/states.py +0 -285
- pulse_framework-0.1.53.dist-info/METADATA +0 -18
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
pulse/render_session.py
CHANGED
|
@@ -31,7 +31,7 @@ from pulse.routing import (
|
|
|
31
31
|
)
|
|
32
32
|
from pulse.state import State
|
|
33
33
|
from pulse.transpiler.id import next_id
|
|
34
|
-
from pulse.transpiler.nodes import Expr
|
|
34
|
+
from pulse.transpiler.nodes import Expr
|
|
35
35
|
|
|
36
36
|
if TYPE_CHECKING:
|
|
37
37
|
from pulse.channel import ChannelsManager
|
|
@@ -46,14 +46,14 @@ class JsExecError(Exception):
|
|
|
46
46
|
|
|
47
47
|
# Module-level convenience wrapper
|
|
48
48
|
@overload
|
|
49
|
-
def run_js(expr: Expr
|
|
49
|
+
def run_js(expr: Expr, *, result: Literal[True]) -> asyncio.Future[Any]: ...
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
@overload
|
|
53
|
-
def run_js(expr: Expr
|
|
53
|
+
def run_js(expr: Expr, *, result: Literal[False] = ...) -> None: ...
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def run_js(expr: Expr
|
|
56
|
+
def run_js(expr: Expr, *, result: bool = False) -> asyncio.Future[Any] | None:
|
|
57
57
|
"""Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
|
|
58
58
|
ctx = PulseContext.get()
|
|
59
59
|
if ctx.render is None:
|
|
@@ -61,14 +61,19 @@ def run_js(expr: Expr | str, *, result: bool = False) -> asyncio.Future[Any] | N
|
|
|
61
61
|
return ctx.render.run_js(expr, result=result)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
MountState = Literal["pending", "active", "idle"]
|
|
65
|
+
|
|
66
|
+
|
|
64
67
|
class RouteMount:
|
|
65
68
|
render: "RenderSession"
|
|
66
69
|
route: RouteContext
|
|
67
70
|
tree: RenderTree
|
|
68
71
|
effect: Effect | None
|
|
69
72
|
_pulse_ctx: PulseContext | None
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
initialized: bool
|
|
74
|
+
state: MountState
|
|
75
|
+
queue: list[ServerMessage] | None
|
|
76
|
+
queue_timeout: asyncio.TimerHandle | None
|
|
72
77
|
|
|
73
78
|
def __init__(
|
|
74
79
|
self, render: "RenderSession", route: Route | Layout, route_info: RouteInfo
|
|
@@ -77,9 +82,11 @@ class RouteMount:
|
|
|
77
82
|
self.route = RouteContext(route_info, route)
|
|
78
83
|
self.effect = None
|
|
79
84
|
self._pulse_ctx = None
|
|
80
|
-
self.
|
|
81
|
-
self.
|
|
82
|
-
self.
|
|
85
|
+
self.tree = RenderTree(route.render())
|
|
86
|
+
self.initialized = False
|
|
87
|
+
self.state = "pending"
|
|
88
|
+
self.queue = None
|
|
89
|
+
self.queue_timeout = None
|
|
83
90
|
|
|
84
91
|
|
|
85
92
|
class RenderSession:
|
|
@@ -90,6 +97,8 @@ class RenderSession:
|
|
|
90
97
|
query_store: QueryStore
|
|
91
98
|
route_mounts: dict[str, RouteMount]
|
|
92
99
|
connected: bool
|
|
100
|
+
prerender_queue_timeout: float
|
|
101
|
+
disconnect_queue_timeout: float
|
|
93
102
|
_server_address: str | None
|
|
94
103
|
_client_address: str | None
|
|
95
104
|
_send_message: Callable[[ServerMessage], Any] | None
|
|
@@ -104,6 +113,8 @@ class RenderSession:
|
|
|
104
113
|
*,
|
|
105
114
|
server_address: str | None = None,
|
|
106
115
|
client_address: str | None = None,
|
|
116
|
+
prerender_queue_timeout: float = 5.0,
|
|
117
|
+
disconnect_queue_timeout: float = 2.0,
|
|
107
118
|
) -> None:
|
|
108
119
|
from pulse.channel import ChannelsManager
|
|
109
120
|
from pulse.form import FormRegistry
|
|
@@ -111,21 +122,18 @@ class RenderSession:
|
|
|
111
122
|
self.id = id
|
|
112
123
|
self.routes = routes
|
|
113
124
|
self.route_mounts = {}
|
|
114
|
-
# Base server address for building absolute API URLs (e.g., http://localhost:8000)
|
|
115
125
|
self._server_address = server_address
|
|
116
|
-
# Best-effort client address, captured at prerender or socket connect time
|
|
117
126
|
self._client_address = client_address
|
|
118
127
|
self._send_message = None
|
|
119
|
-
# Registry of per-session global singletons (created via ps.global_state without id)
|
|
120
128
|
self._global_states = {}
|
|
121
129
|
self.query_store = QueryStore()
|
|
122
|
-
# Connection state
|
|
123
130
|
self.connected = False
|
|
124
131
|
self.channels = ChannelsManager(self)
|
|
125
132
|
self.forms = FormRegistry(self)
|
|
126
133
|
self._pending_api = {}
|
|
127
|
-
# Pending JS execution results (for awaiting run_js().result())
|
|
128
134
|
self._pending_js_results = {}
|
|
135
|
+
self.prerender_queue_timeout = prerender_queue_timeout
|
|
136
|
+
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
129
137
|
|
|
130
138
|
@property
|
|
131
139
|
def server_address(self) -> str:
|
|
@@ -139,34 +147,57 @@ class RenderSession:
|
|
|
139
147
|
raise RuntimeError("Client address not set")
|
|
140
148
|
return self._client_address
|
|
141
149
|
|
|
142
|
-
# Effect error handler (batch-level) to surface runtime errors
|
|
143
150
|
def _on_effect_error(self, effect: Any, exc: Exception):
|
|
144
|
-
# TODO: wirte into effects created within a Render
|
|
145
|
-
|
|
146
|
-
# We don't want to couple effects to routing; broadcast to all active paths
|
|
147
151
|
details = {"effect": getattr(effect, "name", "<unnamed>")}
|
|
148
152
|
for path in list(self.route_mounts.keys()):
|
|
149
153
|
self.report_error(path, "effect", exc, details)
|
|
150
154
|
|
|
155
|
+
# ---- Connection lifecycle ----
|
|
156
|
+
|
|
151
157
|
def connect(self, send_message: Callable[[ServerMessage], Any]):
|
|
158
|
+
"""WebSocket connected. Set sender, don't auto-flush (attach does that)."""
|
|
152
159
|
self._send_message = send_message
|
|
153
160
|
self.connected = True
|
|
154
|
-
# Don't flush buffer or resume effects here - mount() handles reconnection
|
|
155
|
-
# by resetting mount.rendered and resuming effects to send fresh vdom_init
|
|
156
161
|
|
|
157
162
|
def disconnect(self):
|
|
158
|
-
"""
|
|
163
|
+
"""WebSocket disconnected. Start queuing briefly before pausing."""
|
|
159
164
|
self._send_message = None
|
|
160
165
|
self.connected = False
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
|
|
167
|
+
for path, mount in self.route_mounts.items():
|
|
168
|
+
if mount.state == "active":
|
|
169
|
+
mount.state = "pending"
|
|
170
|
+
mount.queue = []
|
|
171
|
+
mount.queue_timeout = self._schedule_timeout(
|
|
172
|
+
self.disconnect_queue_timeout,
|
|
173
|
+
lambda p=path: self._transition_to_idle(p),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# ---- Message routing ----
|
|
164
177
|
|
|
165
178
|
def send(self, message: ServerMessage):
|
|
166
|
-
|
|
167
|
-
#
|
|
168
|
-
|
|
179
|
+
"""Route message based on mount state."""
|
|
180
|
+
# Global messages (not path-specific) go directly if connected
|
|
181
|
+
path = message.get("path")
|
|
182
|
+
if path is None:
|
|
183
|
+
if self._send_message:
|
|
184
|
+
self._send_message(message)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Normalize path for lookup
|
|
188
|
+
path = ensure_absolute_path(path)
|
|
189
|
+
mount = self.route_mounts.get(path)
|
|
190
|
+
if not mount:
|
|
191
|
+
# Unknown path - send directly if connected (for js_exec, etc.)
|
|
192
|
+
if self._send_message:
|
|
193
|
+
self._send_message(message)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if mount.state == "pending" and mount.queue is not None:
|
|
197
|
+
mount.queue.append(message)
|
|
198
|
+
elif mount.state == "active" and self._send_message:
|
|
169
199
|
self._send_message(message)
|
|
200
|
+
# idle: drop (effect should be paused anyway)
|
|
170
201
|
|
|
171
202
|
def report_error(
|
|
172
203
|
self,
|
|
@@ -195,35 +226,242 @@ class RenderSession:
|
|
|
195
226
|
traceback.format_exc(),
|
|
196
227
|
)
|
|
197
228
|
|
|
229
|
+
# ---- State transitions ----
|
|
230
|
+
|
|
231
|
+
def _schedule_timeout(
|
|
232
|
+
self, delay: float, callback: Callable[[], None]
|
|
233
|
+
) -> asyncio.TimerHandle:
|
|
234
|
+
loop = asyncio.get_event_loop()
|
|
235
|
+
return loop.call_later(delay, callback)
|
|
236
|
+
|
|
237
|
+
def _cancel_queue_timeout(self, mount: RouteMount):
|
|
238
|
+
if mount.queue_timeout is not None:
|
|
239
|
+
mount.queue_timeout.cancel()
|
|
240
|
+
mount.queue_timeout = None
|
|
241
|
+
|
|
242
|
+
def _transition_to_idle(self, path: str):
|
|
243
|
+
mount = self.route_mounts.get(path)
|
|
244
|
+
if mount is None or mount.state != "pending":
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
mount.state = "idle"
|
|
248
|
+
mount.queue = None
|
|
249
|
+
mount.queue_timeout = None
|
|
250
|
+
if mount.effect:
|
|
251
|
+
mount.effect.pause()
|
|
252
|
+
|
|
253
|
+
# ---- Prerendering ----
|
|
254
|
+
|
|
255
|
+
def prerender(
|
|
256
|
+
self, path: str, route_info: RouteInfo | None = None
|
|
257
|
+
) -> ServerInitMessage | ServerNavigateToMessage:
|
|
258
|
+
"""
|
|
259
|
+
Synchronous render for SSR. Returns vdom_init or navigate_to message.
|
|
260
|
+
- First call: creates RouteMount in PENDING state, starts queue
|
|
261
|
+
- Subsequent calls: re-renders and returns fresh VDOM
|
|
262
|
+
"""
|
|
263
|
+
path = ensure_absolute_path(path)
|
|
264
|
+
mount = self.route_mounts.get(path)
|
|
265
|
+
is_new = mount is None
|
|
266
|
+
|
|
267
|
+
if is_new:
|
|
268
|
+
route = self.routes.find(path)
|
|
269
|
+
info = route_info or route.default_route_info()
|
|
270
|
+
mount = RouteMount(self, route, info)
|
|
271
|
+
mount.state = "pending"
|
|
272
|
+
mount.queue = []
|
|
273
|
+
self.route_mounts[path] = mount
|
|
274
|
+
elif route_info:
|
|
275
|
+
mount.route.update(route_info)
|
|
276
|
+
|
|
277
|
+
with PulseContext.update(render=self, route=mount.route):
|
|
278
|
+
try:
|
|
279
|
+
vdom = mount.tree.render()
|
|
280
|
+
if is_new:
|
|
281
|
+
mount.initialized = True
|
|
282
|
+
except RedirectInterrupt as r:
|
|
283
|
+
del self.route_mounts[path]
|
|
284
|
+
return ServerNavigateToMessage(
|
|
285
|
+
type="navigate_to", path=r.path, replace=r.replace, hard=False
|
|
286
|
+
)
|
|
287
|
+
except NotFoundInterrupt:
|
|
288
|
+
del self.route_mounts[path]
|
|
289
|
+
ctx = PulseContext.get()
|
|
290
|
+
return ServerNavigateToMessage(
|
|
291
|
+
type="navigate_to", path=ctx.app.not_found, replace=True, hard=False
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if is_new:
|
|
295
|
+
self._create_render_effect(mount, path)
|
|
296
|
+
mount.queue_timeout = self._schedule_timeout(
|
|
297
|
+
self.prerender_queue_timeout,
|
|
298
|
+
lambda: self._transition_to_idle(path),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
302
|
+
|
|
303
|
+
# ---- Client lifecycle ----
|
|
304
|
+
|
|
305
|
+
def attach(self, path: str, route_info: RouteInfo):
|
|
306
|
+
"""
|
|
307
|
+
Client ready to receive updates for path.
|
|
308
|
+
- PENDING: flush queue, transition to ACTIVE
|
|
309
|
+
- IDLE: fresh render, transition to ACTIVE
|
|
310
|
+
- ACTIVE: update route_info
|
|
311
|
+
- No mount: create fresh
|
|
312
|
+
"""
|
|
313
|
+
path = ensure_absolute_path(path)
|
|
314
|
+
mount = self.route_mounts.get(path)
|
|
315
|
+
|
|
316
|
+
if mount is None:
|
|
317
|
+
# No prerender, create fresh
|
|
318
|
+
route = self.routes.find(path)
|
|
319
|
+
mount = RouteMount(self, route, route_info)
|
|
320
|
+
mount.state = "active"
|
|
321
|
+
self.route_mounts[path] = mount
|
|
322
|
+
self._create_render_effect(mount, path)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if mount.state == "pending":
|
|
326
|
+
# Flush queue, go active
|
|
327
|
+
self._cancel_queue_timeout(mount)
|
|
328
|
+
if mount.queue:
|
|
329
|
+
for msg in mount.queue:
|
|
330
|
+
if self._send_message:
|
|
331
|
+
self._send_message(msg)
|
|
332
|
+
mount.queue = None
|
|
333
|
+
mount.state = "active"
|
|
334
|
+
mount.route.update(route_info)
|
|
335
|
+
|
|
336
|
+
elif mount.state == "idle":
|
|
337
|
+
# Need fresh render
|
|
338
|
+
mount.initialized = False
|
|
339
|
+
mount.state = "active"
|
|
340
|
+
mount.route.update(route_info)
|
|
341
|
+
if mount.effect:
|
|
342
|
+
mount.effect.resume()
|
|
343
|
+
|
|
344
|
+
elif mount.state == "active":
|
|
345
|
+
# Already active, just update route
|
|
346
|
+
mount.route.update(route_info)
|
|
347
|
+
|
|
348
|
+
def update_route(self, path: str, route_info: RouteInfo):
|
|
349
|
+
"""Update routing state (query params, etc.) for attached path."""
|
|
350
|
+
path = ensure_absolute_path(path)
|
|
351
|
+
try:
|
|
352
|
+
mount = self.get_route_mount(path)
|
|
353
|
+
mount.route.update(route_info)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
self.report_error(path, "navigate", e)
|
|
356
|
+
|
|
357
|
+
def detach(self, path: str):
|
|
358
|
+
"""Client no longer wants updates. Dispose Effect, remove mount."""
|
|
359
|
+
path = ensure_absolute_path(path)
|
|
360
|
+
if path not in self.route_mounts:
|
|
361
|
+
return
|
|
362
|
+
try:
|
|
363
|
+
mount = self.route_mounts.pop(path)
|
|
364
|
+
self._cancel_queue_timeout(mount)
|
|
365
|
+
mount.tree.unmount()
|
|
366
|
+
if mount.effect:
|
|
367
|
+
mount.effect.dispose()
|
|
368
|
+
except Exception as e:
|
|
369
|
+
self.report_error(path, "unmount", e)
|
|
370
|
+
|
|
371
|
+
# ---- Effect creation ----
|
|
372
|
+
|
|
373
|
+
def _create_render_effect(self, mount: RouteMount, path: str):
|
|
374
|
+
ctx = PulseContext.get()
|
|
375
|
+
session = ctx.session
|
|
376
|
+
|
|
377
|
+
def _render_effect():
|
|
378
|
+
with PulseContext.update(session=session, render=self, route=mount.route):
|
|
379
|
+
try:
|
|
380
|
+
if not mount.initialized:
|
|
381
|
+
vdom = mount.tree.render()
|
|
382
|
+
mount.initialized = True
|
|
383
|
+
self.send(
|
|
384
|
+
ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
ops = mount.tree.rerender()
|
|
388
|
+
if ops:
|
|
389
|
+
self.send(
|
|
390
|
+
ServerUpdateMessage(
|
|
391
|
+
type="vdom_update", path=path, ops=ops
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
except RedirectInterrupt as r:
|
|
395
|
+
self.send(
|
|
396
|
+
ServerNavigateToMessage(
|
|
397
|
+
type="navigate_to",
|
|
398
|
+
path=r.path,
|
|
399
|
+
replace=r.replace,
|
|
400
|
+
hard=False,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
except NotFoundInterrupt:
|
|
404
|
+
self.send(
|
|
405
|
+
ServerNavigateToMessage(
|
|
406
|
+
type="navigate_to",
|
|
407
|
+
path=ctx.app.not_found,
|
|
408
|
+
replace=True,
|
|
409
|
+
hard=False,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
mount.effect = Effect(
|
|
414
|
+
_render_effect,
|
|
415
|
+
immediate=True,
|
|
416
|
+
name=f"{path}:render",
|
|
417
|
+
on_error=lambda e: self.report_error(path, "render", e),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# ---- Helpers ----
|
|
421
|
+
|
|
198
422
|
def close(self):
|
|
199
423
|
self.forms.dispose()
|
|
200
424
|
for path in list(self.route_mounts.keys()):
|
|
201
|
-
self.
|
|
425
|
+
self.detach(path)
|
|
202
426
|
self.route_mounts.clear()
|
|
203
|
-
# Dispose per-session global singletons if they expose dispose()
|
|
204
427
|
for value in self._global_states.values():
|
|
205
428
|
value.dispose()
|
|
206
429
|
self._global_states.clear()
|
|
207
|
-
# Dispose all channels for this render session
|
|
208
430
|
for channel_id in list(self.channels._channels.keys()): # pyright: ignore[reportPrivateUsage]
|
|
209
431
|
channel = self.channels._channels.get(channel_id) # pyright: ignore[reportPrivateUsage]
|
|
210
432
|
if channel:
|
|
211
433
|
channel.closed = True
|
|
212
434
|
self.channels.dispose_channel(channel, reason="render.close")
|
|
213
|
-
# Cancel pending API calls
|
|
214
435
|
for fut in self._pending_api.values():
|
|
215
436
|
if not fut.done():
|
|
216
437
|
fut.cancel()
|
|
217
438
|
self._pending_api.clear()
|
|
218
|
-
# Cancel pending JS execution results
|
|
219
439
|
for fut in self._pending_js_results.values():
|
|
220
440
|
if not fut.done():
|
|
221
441
|
fut.cancel()
|
|
222
442
|
self._pending_js_results.clear()
|
|
223
|
-
# The effect will be garbage collected, and with it the dependencies
|
|
224
443
|
self._send_message = None
|
|
225
444
|
self.connected = False
|
|
226
445
|
|
|
446
|
+
def get_route_mount(self, path: str) -> RouteMount:
|
|
447
|
+
path = ensure_absolute_path(path)
|
|
448
|
+
mount = self.route_mounts.get(path)
|
|
449
|
+
if not mount:
|
|
450
|
+
raise ValueError(f"No active route for '{path}'")
|
|
451
|
+
return mount
|
|
452
|
+
|
|
453
|
+
def get_global_state(self, key: str, factory: Callable[[], Any]) -> Any:
|
|
454
|
+
"""Return a per-session singleton for the provided key."""
|
|
455
|
+
inst = self._global_states.get(key)
|
|
456
|
+
if inst is None:
|
|
457
|
+
inst = factory()
|
|
458
|
+
self._global_states[key] = inst
|
|
459
|
+
return inst
|
|
460
|
+
|
|
461
|
+
def flush(self):
|
|
462
|
+
with PulseContext.update(render=self):
|
|
463
|
+
flush_effects()
|
|
464
|
+
|
|
227
465
|
def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
|
|
228
466
|
mount = self.route_mounts[path]
|
|
229
467
|
cb = mount.tree.callbacks[key]
|
|
@@ -241,6 +479,8 @@ class RenderSession:
|
|
|
241
479
|
except Exception as e:
|
|
242
480
|
report(e)
|
|
243
481
|
|
|
482
|
+
# ---- API calls ----
|
|
483
|
+
|
|
244
484
|
async def call_api(
|
|
245
485
|
self,
|
|
246
486
|
url_or_path: str,
|
|
@@ -251,17 +491,7 @@ class RenderSession:
|
|
|
251
491
|
credentials: str = "include",
|
|
252
492
|
timeout: float = 30.0,
|
|
253
493
|
) -> dict[str, Any]:
|
|
254
|
-
"""Request the client to perform a fetch and await the result.
|
|
255
|
-
|
|
256
|
-
Accepts either an absolute URL (http/https) or a relative path. When a
|
|
257
|
-
relative path is provided, it is resolved against this session's
|
|
258
|
-
server_address.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
timeout: Maximum seconds to wait for response (default 30s).
|
|
262
|
-
Raises asyncio.TimeoutError if exceeded.
|
|
263
|
-
"""
|
|
264
|
-
# Resolve to absolute URL if a relative path is passed
|
|
494
|
+
"""Request the client to perform a fetch and await the result."""
|
|
265
495
|
if url_or_path.startswith("http://") or url_or_path.startswith("https://"):
|
|
266
496
|
url = url_or_path
|
|
267
497
|
else:
|
|
@@ -270,8 +500,8 @@ class RenderSession:
|
|
|
270
500
|
raise RuntimeError(
|
|
271
501
|
"Server address unavailable. Ensure App.run_codegen/asgi_factory set server_address."
|
|
272
502
|
)
|
|
273
|
-
|
|
274
|
-
url = f"{base}{
|
|
503
|
+
api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
|
|
504
|
+
url = f"{base}{api_path}"
|
|
275
505
|
corr_id = uuid.uuid4().hex
|
|
276
506
|
fut = create_future_on_loop()
|
|
277
507
|
self._pending_api[corr_id] = fut
|
|
@@ -312,27 +542,28 @@ class RenderSession:
|
|
|
312
542
|
)
|
|
313
543
|
|
|
314
544
|
# ---- JS Execution ----
|
|
545
|
+
|
|
315
546
|
@overload
|
|
316
547
|
def run_js(
|
|
317
|
-
self, expr: Expr
|
|
548
|
+
self, expr: Expr, *, result: Literal[True], timeout: float = ...
|
|
318
549
|
) -> asyncio.Future[object]: ...
|
|
319
550
|
|
|
320
551
|
@overload
|
|
321
552
|
def run_js(
|
|
322
553
|
self,
|
|
323
|
-
expr: Expr
|
|
554
|
+
expr: Expr,
|
|
324
555
|
*,
|
|
325
556
|
result: Literal[False] = ...,
|
|
326
557
|
timeout: float = ...,
|
|
327
558
|
) -> None: ...
|
|
328
559
|
|
|
329
560
|
def run_js(
|
|
330
|
-
self, expr: Expr
|
|
561
|
+
self, expr: Expr, *, result: bool = False, timeout: float = 10.0
|
|
331
562
|
) -> asyncio.Future[object] | None:
|
|
332
563
|
"""Execute JavaScript on the client.
|
|
333
564
|
|
|
334
565
|
Args:
|
|
335
|
-
expr:
|
|
566
|
+
expr: An Expr from calling a @javascript function.
|
|
336
567
|
result: If True, returns a Future that resolves with the JS return value.
|
|
337
568
|
If False (default), returns None (fire-and-forget).
|
|
338
569
|
timeout: Maximum seconds to wait for result (default 10s, only applies when
|
|
@@ -358,19 +589,10 @@ class RenderSession:
|
|
|
358
589
|
async def on_click():
|
|
359
590
|
pos = await run_js(get_scroll_position(), result=True)
|
|
360
591
|
print(pos["x"], pos["y"])
|
|
361
|
-
|
|
362
|
-
Example - Raw JS string:
|
|
363
|
-
def on_click():
|
|
364
|
-
run_js("console.log('Hello from Python!')")
|
|
365
592
|
"""
|
|
366
593
|
ctx = PulseContext.get()
|
|
367
594
|
exec_id = next_id()
|
|
368
595
|
|
|
369
|
-
if isinstance(expr, str):
|
|
370
|
-
code = expr
|
|
371
|
-
else:
|
|
372
|
-
code = emit(expr)
|
|
373
|
-
|
|
374
596
|
# Get route pattern path (e.g., "/users/:id") not pathname (e.g., "/users/123")
|
|
375
597
|
# This must match the path used to key views on the client side
|
|
376
598
|
path = ctx.route.pulse_route.unique_path() if ctx.route else "/"
|
|
@@ -380,7 +602,7 @@ class RenderSession:
|
|
|
380
602
|
type="js_exec",
|
|
381
603
|
path=path,
|
|
382
604
|
id=exec_id,
|
|
383
|
-
|
|
605
|
+
expr=expr.render(),
|
|
384
606
|
)
|
|
385
607
|
)
|
|
386
608
|
|
|
@@ -389,7 +611,6 @@ class RenderSession:
|
|
|
389
611
|
future: asyncio.Future[object] = loop.create_future()
|
|
390
612
|
self._pending_js_results[exec_id] = future
|
|
391
613
|
|
|
392
|
-
# Schedule auto-timeout
|
|
393
614
|
def _on_timeout() -> None:
|
|
394
615
|
self._pending_js_results.pop(exec_id, None)
|
|
395
616
|
if not future.done():
|
|
@@ -415,208 +636,3 @@ class RenderSession:
|
|
|
415
636
|
fut.set_exception(JsExecError(error))
|
|
416
637
|
else:
|
|
417
638
|
fut.set_result(data.get("result"))
|
|
418
|
-
|
|
419
|
-
def create_route_mount(self, path: str, route_info: RouteInfo | None = None):
|
|
420
|
-
route = self.routes.find(path)
|
|
421
|
-
mount = RouteMount(self, route, route_info or route.default_route_info())
|
|
422
|
-
self.route_mounts[path] = mount
|
|
423
|
-
return mount
|
|
424
|
-
|
|
425
|
-
def prerender_mount_capture(
|
|
426
|
-
self, path: str, route_info: RouteInfo | None = None
|
|
427
|
-
) -> ServerInitMessage | ServerNavigateToMessage:
|
|
428
|
-
"""
|
|
429
|
-
Mount the route and run the render effect immediately, capturing the
|
|
430
|
-
initial message instead of sending over a socket.
|
|
431
|
-
|
|
432
|
-
Returns a dict:
|
|
433
|
-
{ "type": "vdom_init", "vdom": VDOM } or
|
|
434
|
-
{ "type": "navigate_to", "path": str, "replace": bool }
|
|
435
|
-
"""
|
|
436
|
-
# If already mounted (e.g., repeated prerender), do nothing special.
|
|
437
|
-
if path in self.route_mounts:
|
|
438
|
-
# Run a diff and synthesize an update; however, for prerender we
|
|
439
|
-
# expect initial mount. Return current tree as a full VDOM.
|
|
440
|
-
mount = self.get_route_mount(path)
|
|
441
|
-
with PulseContext.update(route=mount.route):
|
|
442
|
-
vdom = mount.tree.render()
|
|
443
|
-
normalized_root = getattr(mount.tree, "_normalized", None)
|
|
444
|
-
if normalized_root is not None:
|
|
445
|
-
mount.element = normalized_root
|
|
446
|
-
mount.rendered = True
|
|
447
|
-
return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
448
|
-
|
|
449
|
-
captured: ServerInitMessage | ServerNavigateToMessage | None = None
|
|
450
|
-
|
|
451
|
-
def _capture(msg: ServerMessage):
|
|
452
|
-
nonlocal captured
|
|
453
|
-
# Only capture the first relevant message for this path
|
|
454
|
-
if captured is not None:
|
|
455
|
-
return
|
|
456
|
-
if msg["type"] == "vdom_init" and msg["path"] == path:
|
|
457
|
-
captured = ServerInitMessage(
|
|
458
|
-
type="vdom_init", path=path, vdom=msg.get("vdom")
|
|
459
|
-
)
|
|
460
|
-
elif msg["type"] == "navigate_to":
|
|
461
|
-
captured = ServerNavigateToMessage(
|
|
462
|
-
type="navigate_to",
|
|
463
|
-
path=msg["path"],
|
|
464
|
-
replace=msg["replace"],
|
|
465
|
-
hard=msg.get("hard", False),
|
|
466
|
-
)
|
|
467
|
-
|
|
468
|
-
prev_sender = self._send_message
|
|
469
|
-
try:
|
|
470
|
-
self._send_message = _capture
|
|
471
|
-
# Reuse normal mount flow which creates and runs the effect
|
|
472
|
-
self.mount(path, route_info or self.routes.find(path).default_route_info())
|
|
473
|
-
# Flush any scheduled effects to stabilize output
|
|
474
|
-
self.flush()
|
|
475
|
-
finally:
|
|
476
|
-
self._send_message = prev_sender
|
|
477
|
-
|
|
478
|
-
# Fallback: if nothing captured (shouldn't happen), return full VDOM
|
|
479
|
-
if captured is None:
|
|
480
|
-
mount = self.get_route_mount(path)
|
|
481
|
-
with PulseContext.update(route=mount.route):
|
|
482
|
-
vdom = mount.tree.render()
|
|
483
|
-
normalized_root = getattr(mount.tree, "_normalized", None)
|
|
484
|
-
if normalized_root is not None:
|
|
485
|
-
mount.element = normalized_root
|
|
486
|
-
mount.rendered = True
|
|
487
|
-
return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
488
|
-
|
|
489
|
-
return captured
|
|
490
|
-
|
|
491
|
-
def get_route_mount(
|
|
492
|
-
self,
|
|
493
|
-
path: str,
|
|
494
|
-
):
|
|
495
|
-
path = ensure_absolute_path(path)
|
|
496
|
-
mount = self.route_mounts.get(path)
|
|
497
|
-
if not mount:
|
|
498
|
-
raise ValueError(f"No active route for '{path}'")
|
|
499
|
-
return mount
|
|
500
|
-
|
|
501
|
-
# ---- Session-local global state registry ----
|
|
502
|
-
def get_global_state(self, key: str, factory: Callable[[], Any]) -> Any:
|
|
503
|
-
"""Return a per-session singleton for the provided key."""
|
|
504
|
-
inst = self._global_states.get(key)
|
|
505
|
-
if inst is None:
|
|
506
|
-
inst = factory()
|
|
507
|
-
self._global_states[key] = inst
|
|
508
|
-
return inst
|
|
509
|
-
|
|
510
|
-
def render(self, path: str, route_info: RouteInfo | None = None):
|
|
511
|
-
mount = self.create_route_mount(path, route_info)
|
|
512
|
-
with PulseContext.update(route=mount.route):
|
|
513
|
-
vdom = mount.tree.render()
|
|
514
|
-
normalized_root = getattr(mount.tree, "_normalized", None)
|
|
515
|
-
if normalized_root is not None:
|
|
516
|
-
mount.element = normalized_root
|
|
517
|
-
mount.rendered = True
|
|
518
|
-
return vdom
|
|
519
|
-
|
|
520
|
-
def rerender(self, path: str):
|
|
521
|
-
mount = self.get_route_mount(path)
|
|
522
|
-
with PulseContext.update(route=mount.route):
|
|
523
|
-
ops = mount.tree.diff(mount.element)
|
|
524
|
-
normalized_root = getattr(mount.tree, "_normalized", None)
|
|
525
|
-
if normalized_root is not None:
|
|
526
|
-
mount.element = normalized_root
|
|
527
|
-
return ops
|
|
528
|
-
|
|
529
|
-
def mount(self, path: str, route_info: RouteInfo):
|
|
530
|
-
if path in self.route_mounts:
|
|
531
|
-
# Route already mounted - this is a reconnection case.
|
|
532
|
-
# Reset rendered flag so effect sends vdom_init, update route info,
|
|
533
|
-
# and resume the paused effect.
|
|
534
|
-
mount = self.route_mounts[path]
|
|
535
|
-
mount.rendered = False
|
|
536
|
-
mount.route.update(route_info)
|
|
537
|
-
if mount.effect and mount.effect.paused:
|
|
538
|
-
mount.effect.resume()
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
mount = self.create_route_mount(path, route_info)
|
|
542
|
-
# Get current context + add RouteContext. Save it to be able to mount it
|
|
543
|
-
# whenever the render effect reruns.
|
|
544
|
-
ctx = PulseContext.get()
|
|
545
|
-
session = ctx.session
|
|
546
|
-
|
|
547
|
-
def _render_effect():
|
|
548
|
-
# Always ensure both render and route are present in context
|
|
549
|
-
with PulseContext.update(session=session, render=self, route=mount.route):
|
|
550
|
-
try:
|
|
551
|
-
if not mount.rendered:
|
|
552
|
-
vdom = mount.tree.render()
|
|
553
|
-
normalized_root = getattr(mount.tree, "_normalized", None)
|
|
554
|
-
if normalized_root is not None:
|
|
555
|
-
mount.element = normalized_root
|
|
556
|
-
mount.rendered = True
|
|
557
|
-
self.send(
|
|
558
|
-
ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
559
|
-
)
|
|
560
|
-
else:
|
|
561
|
-
ops = mount.tree.diff(mount.element)
|
|
562
|
-
normalized_root = getattr(mount.tree, "_normalized", None)
|
|
563
|
-
if normalized_root is not None:
|
|
564
|
-
mount.element = normalized_root
|
|
565
|
-
if ops:
|
|
566
|
-
self.send(
|
|
567
|
-
ServerUpdateMessage(
|
|
568
|
-
type="vdom_update", path=path, ops=ops
|
|
569
|
-
)
|
|
570
|
-
)
|
|
571
|
-
except RedirectInterrupt as r:
|
|
572
|
-
# Prefer client-side navigation over emitting VDOM operations
|
|
573
|
-
self.send(
|
|
574
|
-
ServerNavigateToMessage(
|
|
575
|
-
type="navigate_to",
|
|
576
|
-
path=r.path,
|
|
577
|
-
replace=r.replace,
|
|
578
|
-
hard=False,
|
|
579
|
-
)
|
|
580
|
-
)
|
|
581
|
-
except NotFoundInterrupt:
|
|
582
|
-
# Use app-configured not-found path; fallback to '/404'
|
|
583
|
-
self.send(
|
|
584
|
-
ServerNavigateToMessage(
|
|
585
|
-
type="navigate_to",
|
|
586
|
-
path=ctx.app.not_found,
|
|
587
|
-
replace=True,
|
|
588
|
-
hard=False,
|
|
589
|
-
)
|
|
590
|
-
)
|
|
591
|
-
|
|
592
|
-
mount.effect = Effect(
|
|
593
|
-
_render_effect,
|
|
594
|
-
immediate=True,
|
|
595
|
-
name=f"{path}:render",
|
|
596
|
-
on_error=lambda e: self.report_error(path, "render", e),
|
|
597
|
-
)
|
|
598
|
-
|
|
599
|
-
def flush(self):
|
|
600
|
-
# Ensure effects (including route render effects) run with this session
|
|
601
|
-
# bound on the PulseContext so hooks like ps.global_state work
|
|
602
|
-
with PulseContext.update(render=self):
|
|
603
|
-
flush_effects()
|
|
604
|
-
|
|
605
|
-
def navigate(self, path: str, route_info: RouteInfo):
|
|
606
|
-
# Route is already mounted, we can just update the routing state
|
|
607
|
-
try:
|
|
608
|
-
mount = self.get_route_mount(path)
|
|
609
|
-
mount.route.update(route_info)
|
|
610
|
-
except Exception as e:
|
|
611
|
-
self.report_error(path, "navigate", e)
|
|
612
|
-
|
|
613
|
-
def unmount(self, path: str):
|
|
614
|
-
if path not in self.route_mounts:
|
|
615
|
-
return
|
|
616
|
-
try:
|
|
617
|
-
mount = self.route_mounts.pop(path)
|
|
618
|
-
mount.tree.unmount()
|
|
619
|
-
if mount.effect:
|
|
620
|
-
mount.effect.dispose()
|
|
621
|
-
except Exception as e:
|
|
622
|
-
self.report_error(path, "unmount", e)
|