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