pulse-framework 0.1.40__tar.gz → 0.1.42__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/PKG-INFO +1 -1
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/pyproject.toml +1 -1
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/__init__.py +19 -4
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/app.py +159 -99
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/channel.py +7 -7
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/cmd.py +81 -45
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/models.py +2 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/processes.py +67 -22
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/uvicorn_log_config.py +1 -1
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/codegen.py +14 -1
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/templates/layout.py +10 -2
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/context.py +3 -2
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/decorators.py +132 -40
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/form.py +9 -9
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/helpers.py +75 -11
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/core.py +7 -8
- pulse_framework-0.1.42/src/pulse/hooks/init.py +460 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/states.py +91 -54
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/messages.py +1 -1
- pulse_framework-0.1.42/src/pulse/middleware.py +400 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/plugin.py +0 -3
- pulse_framework-0.1.42/src/pulse/proxy.py +216 -0
- pulse_framework-0.1.42/src/pulse/queries/common.py +24 -0
- pulse_framework-0.1.42/src/pulse/queries/mutation.py +142 -0
- pulse_framework-0.1.42/src/pulse/queries/query.py +270 -0
- pulse_framework-0.1.42/src/pulse/queries/query_observer.py +365 -0
- pulse_framework-0.1.42/src/pulse/queries/store.py +60 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/react_component.py +2 -1
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/reactive.py +153 -53
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/render_session.py +5 -2
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/routing.py +68 -10
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/state.py +8 -7
- pulse_framework-0.1.42/src/pulse/types/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/types/event_handler.py +2 -3
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/user_session.py +3 -2
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/vdom.py +3 -1
- pulse_framework-0.1.40/src/pulse/middleware.py +0 -349
- pulse_framework-0.1.40/src/pulse/proxy.py +0 -98
- pulse_framework-0.1.40/src/pulse/query.py +0 -408
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/README.md +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/dependencies.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/folder_lock.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/helpers.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/packages.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cli/secrets.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/imports.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/js.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/templates/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/templates/route.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/templates/routes_ts.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/codegen/utils.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/components/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/components/for_.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/components/if_.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/components/react_router.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/cookies.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/css.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/env.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/effects.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/runtime.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/setup.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/hooks/stable.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/elements.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/events.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/props.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/svg.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/tags.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/html/tags.pyi +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/py.typed +0 -0
- {pulse_framework-0.1.40/src/pulse/types → pulse_framework-0.1.42/src/pulse/queries}/__init__.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/reactive_extensions.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/renderer.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/request.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/serializer.py +0 -0
- {pulse_framework-0.1.40 → pulse_framework-0.1.42}/src/pulse/version.py +0 -0
|
@@ -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 (
|
|
@@ -160,6 +161,11 @@ from pulse.hooks.core import (
|
|
|
160
161
|
# Hooks - Effects
|
|
161
162
|
from pulse.hooks.effects import EffectsHookState as EffectsHookState
|
|
162
163
|
from pulse.hooks.effects import effects as effects
|
|
164
|
+
|
|
165
|
+
# Hooks - Init
|
|
166
|
+
from pulse.hooks.init import (
|
|
167
|
+
init as init,
|
|
168
|
+
)
|
|
163
169
|
from pulse.hooks.runtime import (
|
|
164
170
|
GLOBAL_STATES as GLOBAL_STATES,
|
|
165
171
|
)
|
|
@@ -1300,9 +1306,10 @@ from pulse.html.tags import (
|
|
|
1300
1306
|
from pulse.html.tags import (
|
|
1301
1307
|
wbr as wbr,
|
|
1302
1308
|
)
|
|
1309
|
+
from pulse.messages import ClientMessage as ClientMessage
|
|
1303
1310
|
from pulse.messages import Directives as Directives
|
|
1311
|
+
from pulse.messages import Prerender as Prerender
|
|
1304
1312
|
from pulse.messages import PrerenderPayload as PrerenderPayload
|
|
1305
|
-
from pulse.messages import PrerenderResult as PrerenderResult
|
|
1306
1313
|
from pulse.messages import SocketIODirectives as SocketIODirectives
|
|
1307
1314
|
|
|
1308
1315
|
# Middleware
|
|
@@ -1312,6 +1319,9 @@ from pulse.middleware import (
|
|
|
1312
1319
|
from pulse.middleware import (
|
|
1313
1320
|
Deny as Deny,
|
|
1314
1321
|
)
|
|
1322
|
+
from pulse.middleware import (
|
|
1323
|
+
LatencyMiddleware as LatencyMiddleware,
|
|
1324
|
+
)
|
|
1315
1325
|
from pulse.middleware import (
|
|
1316
1326
|
MiddlewareStack as MiddlewareStack,
|
|
1317
1327
|
)
|
|
@@ -1330,12 +1340,16 @@ from pulse.middleware import (
|
|
|
1330
1340
|
from pulse.middleware import (
|
|
1331
1341
|
Redirect as Redirect,
|
|
1332
1342
|
)
|
|
1343
|
+
from pulse.middleware import (
|
|
1344
|
+
RoutePrerenderResponse as RoutePrerenderResponse,
|
|
1345
|
+
)
|
|
1333
1346
|
from pulse.middleware import (
|
|
1334
1347
|
stack as stack,
|
|
1335
1348
|
)
|
|
1336
1349
|
|
|
1337
1350
|
# Plugin
|
|
1338
1351
|
from pulse.plugin import Plugin as Plugin
|
|
1352
|
+
from pulse.queries.query import QueryStatus as QueryStatus
|
|
1339
1353
|
|
|
1340
1354
|
# React component registry
|
|
1341
1355
|
from pulse.react_component import (
|
|
@@ -1419,6 +1433,7 @@ from pulse.render_session import (
|
|
|
1419
1433
|
from pulse.request import PulseRequest as PulseRequest
|
|
1420
1434
|
from pulse.routing import Layout as Layout
|
|
1421
1435
|
from pulse.routing import Route as Route
|
|
1436
|
+
from pulse.routing import RouteInfo as RouteInfo
|
|
1422
1437
|
from pulse.serializer import deserialize as deserialize
|
|
1423
1438
|
|
|
1424
1439
|
# Serializer
|
|
@@ -11,6 +11,7 @@ import os
|
|
|
11
11
|
from collections import defaultdict
|
|
12
12
|
from collections.abc import Awaitable, Sequence
|
|
13
13
|
from contextlib import asynccontextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
14
15
|
from enum import IntEnum
|
|
15
16
|
from typing import Any, Callable, Literal, TypeVar, cast
|
|
16
17
|
|
|
@@ -57,21 +58,23 @@ from pulse.messages import (
|
|
|
57
58
|
ClientChannelResponseMessage,
|
|
58
59
|
ClientMessage,
|
|
59
60
|
ClientPulseMessage,
|
|
61
|
+
Prerender,
|
|
60
62
|
PrerenderPayload,
|
|
61
|
-
PrerenderResult,
|
|
62
63
|
ServerMessage,
|
|
63
64
|
)
|
|
64
65
|
from pulse.middleware import (
|
|
66
|
+
ConnectResponse,
|
|
65
67
|
Deny,
|
|
66
68
|
MiddlewareStack,
|
|
67
69
|
NotFound,
|
|
68
70
|
Ok,
|
|
69
|
-
|
|
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):
|
|
@@ -447,15 +460,7 @@ class App:
|
|
|
447
460
|
# Schedule cleanup timeout (will cancel/reschedule on activity)
|
|
448
461
|
self._schedule_render_cleanup(render_id)
|
|
449
462
|
|
|
450
|
-
|
|
451
|
-
"views": {},
|
|
452
|
-
"directives": {
|
|
453
|
-
"headers": {"X-Pulse-Render-Id": render_id},
|
|
454
|
-
"socketio": {"auth": {"render_id": render_id}, "headers": {}},
|
|
455
|
-
},
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
def _prerender_one(path: str):
|
|
463
|
+
async def _prerender_one(path: str):
|
|
459
464
|
captured = render.prerender_mount_capture(path, route_info)
|
|
460
465
|
if captured["type"] == "vdom_init":
|
|
461
466
|
return Ok(captured)
|
|
@@ -469,57 +474,85 @@ class App:
|
|
|
469
474
|
# Fallback: shouldn't happen, return not found to be safe
|
|
470
475
|
return NotFound()
|
|
471
476
|
|
|
472
|
-
|
|
477
|
+
def _normalize_prerender_response(res: Any) -> RoutePrerenderResponse:
|
|
478
|
+
if isinstance(res, (Ok, Redirect, NotFound)):
|
|
479
|
+
return res
|
|
480
|
+
# Treat any other value as a VDOM payload
|
|
481
|
+
return Ok(res)
|
|
473
482
|
|
|
474
483
|
with PulseContext.update(render=render):
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
484
|
+
# Call top-level prerender middleware, which wraps the route processing
|
|
485
|
+
async def _process_routes() -> PrerenderResponse:
|
|
486
|
+
result_data: Prerender = {
|
|
487
|
+
"views": {},
|
|
488
|
+
"directives": {
|
|
489
|
+
"headers": {"X-Pulse-Render-Id": render_id},
|
|
490
|
+
"socketio": {
|
|
491
|
+
"auth": {"render_id": render_id},
|
|
492
|
+
"headers": {},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
# Fan out on routes
|
|
498
|
+
for p in paths:
|
|
499
|
+
try:
|
|
500
|
+
# Capture p in closure to avoid loop variable binding issue
|
|
501
|
+
async def _next(path: str = p) -> RoutePrerenderResponse:
|
|
502
|
+
return await _prerender_one(path)
|
|
503
|
+
|
|
504
|
+
# Call prerender_route middleware (in) -> prerender route -> (out)
|
|
505
|
+
res = await self.middleware.prerender_route(
|
|
506
|
+
path=p,
|
|
507
|
+
route_info=route_info,
|
|
508
|
+
request=PulseRequest.from_fastapi(request),
|
|
509
|
+
session=session.data,
|
|
510
|
+
next=_next,
|
|
511
|
+
)
|
|
512
|
+
res = _normalize_prerender_response(res)
|
|
513
|
+
if isinstance(res, Ok):
|
|
514
|
+
# Aggregate results
|
|
515
|
+
result_data["views"][p] = res.payload
|
|
516
|
+
elif isinstance(res, Redirect):
|
|
517
|
+
# Return redirect immediately
|
|
518
|
+
return Redirect(path=res.path or "/")
|
|
519
|
+
elif isinstance(res, NotFound):
|
|
520
|
+
# Return not found immediately
|
|
521
|
+
return NotFound()
|
|
522
|
+
else:
|
|
523
|
+
raise ValueError("Unexpected prerender response:", res)
|
|
524
|
+
except RedirectInterrupt as r:
|
|
525
|
+
return Redirect(path=r.path)
|
|
526
|
+
except NotFoundInterrupt:
|
|
527
|
+
return NotFound()
|
|
528
|
+
|
|
529
|
+
return Ok(result_data)
|
|
530
|
+
|
|
531
|
+
result = await self.middleware.prerender(
|
|
532
|
+
payload=payload,
|
|
533
|
+
request=PulseRequest.from_fastapi(request),
|
|
534
|
+
session=session.data,
|
|
535
|
+
next=_process_routes,
|
|
536
|
+
)
|
|
519
537
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
538
|
+
# Handle redirect/notFound responses
|
|
539
|
+
if isinstance(result, Redirect):
|
|
540
|
+
resp = JSONResponse({"redirect": result.path})
|
|
541
|
+
session.handle_response(resp)
|
|
542
|
+
return resp
|
|
543
|
+
if isinstance(result, NotFound):
|
|
544
|
+
resp = JSONResponse({"notFound": True})
|
|
545
|
+
session.handle_response(resp)
|
|
546
|
+
return resp
|
|
547
|
+
|
|
548
|
+
# Handle Ok result - serialize the payload (PrerenderResultData)
|
|
549
|
+
if isinstance(result, Ok):
|
|
550
|
+
resp = JSONResponse(serialize(result.payload))
|
|
551
|
+
session.handle_response(resp)
|
|
552
|
+
return resp
|
|
553
|
+
|
|
554
|
+
# Fallback (shouldn't happen)
|
|
555
|
+
raise ValueError("Unexpected prerender result type")
|
|
523
556
|
|
|
524
557
|
@self.fastapi.post(f"{prefix}/forms/{{render_id}}/{{form_id}}")
|
|
525
558
|
async def handle_form_submit( # pyright: ignore[reportUnusedFunction]
|
|
@@ -544,7 +577,14 @@ class App:
|
|
|
544
577
|
# FastAPI will match specific routes before this catch-all, but we add an explicit check
|
|
545
578
|
# as a safety measure to ensure API routes are never proxied
|
|
546
579
|
if self.mode == "single-server":
|
|
547
|
-
|
|
580
|
+
react_server_address = envvars.react_server_address
|
|
581
|
+
if not react_server_address:
|
|
582
|
+
raise RuntimeError(
|
|
583
|
+
"PULSE_REACT_SERVER_ADDRESS must be set in single-server mode. "
|
|
584
|
+
+ "Use 'pulse run' CLI command or set the environment variable."
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
proxy_handler = ReactProxy(react_server_address)
|
|
548
588
|
|
|
549
589
|
@self.fastapi.api_route(
|
|
550
590
|
"/{path:path}",
|
|
@@ -552,8 +592,12 @@ class App:
|
|
|
552
592
|
include_in_schema=False,
|
|
553
593
|
)
|
|
554
594
|
async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
|
|
555
|
-
# Skip WebSocket upgrades (handled by Socket.IO)
|
|
556
|
-
|
|
595
|
+
# Skip WebSocket upgrades outside the Vite dev server (handled by Socket.IO)
|
|
596
|
+
is_websocket_upgrade = (
|
|
597
|
+
request.headers.get("upgrade", "").lower() == "websocket"
|
|
598
|
+
)
|
|
599
|
+
is_vite_dev_server = self.env == "dev" and request.url.path == "/"
|
|
600
|
+
if is_websocket_upgrade and not is_vite_dev_server:
|
|
557
601
|
raise HTTPException(status_code=404, detail="Not found")
|
|
558
602
|
|
|
559
603
|
# Proxy all unmatched HTTP requests to React Router
|
|
@@ -602,20 +646,26 @@ class App:
|
|
|
602
646
|
|
|
603
647
|
with PulseContext.update(session=session, render=render):
|
|
604
648
|
|
|
605
|
-
def _next():
|
|
649
|
+
async def _next():
|
|
650
|
+
return Ok(None)
|
|
651
|
+
|
|
652
|
+
def _normalize_connect_response(res: Any) -> ConnectResponse:
|
|
653
|
+
if isinstance(res, (Ok, Deny)):
|
|
654
|
+
return res # type: ignore[return-value]
|
|
655
|
+
# Treat any other value as allow
|
|
606
656
|
return Ok(None)
|
|
607
657
|
|
|
608
658
|
try:
|
|
609
|
-
res = self.middleware.connect(
|
|
659
|
+
res = await self.middleware.connect(
|
|
610
660
|
request=PulseRequest.from_socketio_environ(environ, auth),
|
|
611
661
|
session=session.data,
|
|
612
662
|
next=_next,
|
|
613
663
|
)
|
|
664
|
+
res = _normalize_connect_response(res)
|
|
614
665
|
except Exception as exc:
|
|
615
666
|
render.report_error("/", "connect", exc)
|
|
616
667
|
res = Ok(None)
|
|
617
668
|
if isinstance(res, Deny):
|
|
618
|
-
print(f"Denying connection, closing RenderSession {rid}")
|
|
619
669
|
# Tear down the created session if denied
|
|
620
670
|
self.close_render(rid)
|
|
621
671
|
|
|
@@ -623,7 +673,6 @@ class App:
|
|
|
623
673
|
def disconnect(sid: str): # pyright: ignore[reportUnusedFunction]
|
|
624
674
|
rid = self._socket_to_render.pop(sid, None)
|
|
625
675
|
if rid is not None:
|
|
626
|
-
print(f"Disconnecting WebSocket for RenderSession {rid}")
|
|
627
676
|
render = self.render_sessions.get(rid)
|
|
628
677
|
if render:
|
|
629
678
|
render.connected = False
|
|
@@ -631,7 +680,7 @@ class App:
|
|
|
631
680
|
self._schedule_render_cleanup(rid)
|
|
632
681
|
|
|
633
682
|
@self.sio.event
|
|
634
|
-
def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
|
|
683
|
+
async def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
|
|
635
684
|
rid = self._socket_to_render.get(sid)
|
|
636
685
|
if not rid:
|
|
637
686
|
return
|
|
@@ -646,9 +695,9 @@ class App:
|
|
|
646
695
|
msg = cast(ClientMessage, deserialize(data))
|
|
647
696
|
try:
|
|
648
697
|
if msg["type"] == "channel_message":
|
|
649
|
-
self._handle_channel_message(render, session, msg)
|
|
698
|
+
await self._handle_channel_message(render, session, msg)
|
|
650
699
|
else:
|
|
651
|
-
self._handle_pulse_message(render, session, msg)
|
|
700
|
+
await self._handle_pulse_message(render, session, msg)
|
|
652
701
|
except Exception as e:
|
|
653
702
|
path = msg.get("path", "")
|
|
654
703
|
render.report_error(path, "server", e)
|
|
@@ -688,10 +737,10 @@ class App:
|
|
|
688
737
|
handle = later(self.session_timeout, _cleanup)
|
|
689
738
|
self._render_cleanups[rid] = handle
|
|
690
739
|
|
|
691
|
-
def _handle_pulse_message(
|
|
740
|
+
async def _handle_pulse_message(
|
|
692
741
|
self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
|
|
693
742
|
) -> None:
|
|
694
|
-
def _next() -> Ok[None]:
|
|
743
|
+
async def _next() -> Ok[None]:
|
|
695
744
|
if msg["type"] == "mount":
|
|
696
745
|
render.mount(msg["path"], msg["routeInfo"])
|
|
697
746
|
elif msg["type"] == "navigate":
|
|
@@ -707,13 +756,20 @@ class App:
|
|
|
707
756
|
logger.warning("Unknown message type received: %s", msg)
|
|
708
757
|
return Ok()
|
|
709
758
|
|
|
759
|
+
def _normalize_message_response(res: Any) -> Ok[None] | Deny:
|
|
760
|
+
if isinstance(res, (Ok, Deny)):
|
|
761
|
+
return res # type: ignore[return-value]
|
|
762
|
+
# Treat any other value as allow
|
|
763
|
+
return Ok(None)
|
|
764
|
+
|
|
710
765
|
with PulseContext.update(session=session, render=render):
|
|
711
766
|
try:
|
|
712
|
-
res = self.middleware.message(
|
|
767
|
+
res = await self.middleware.message(
|
|
713
768
|
data=msg,
|
|
714
769
|
session=session.data,
|
|
715
770
|
next=_next,
|
|
716
771
|
)
|
|
772
|
+
res = _normalize_message_response(res)
|
|
717
773
|
except Exception:
|
|
718
774
|
logger.exception("Error in message middleware")
|
|
719
775
|
return
|
|
@@ -727,7 +783,7 @@ class App:
|
|
|
727
783
|
{"kind": "deny"},
|
|
728
784
|
)
|
|
729
785
|
|
|
730
|
-
def _handle_channel_message(
|
|
786
|
+
async def _handle_channel_message(
|
|
731
787
|
self, render: RenderSession, session: UserSession, msg: ClientChannelMessage
|
|
732
788
|
) -> None:
|
|
733
789
|
if msg.get("responseTo"):
|
|
@@ -737,15 +793,20 @@ class App:
|
|
|
737
793
|
channel_id = str(msg.get("channel", ""))
|
|
738
794
|
msg = cast(ClientChannelRequestMessage, msg)
|
|
739
795
|
|
|
740
|
-
def _next() -> Ok[
|
|
741
|
-
|
|
742
|
-
render
|
|
743
|
-
render=render, session=session, message=msg
|
|
744
|
-
)
|
|
796
|
+
async def _next() -> Ok[None]:
|
|
797
|
+
render.channels.handle_client_event(
|
|
798
|
+
render=render, session=session, message=msg
|
|
745
799
|
)
|
|
800
|
+
return Ok(None)
|
|
801
|
+
|
|
802
|
+
def _normalize_message_response(res: Any) -> Ok[None] | Deny:
|
|
803
|
+
if isinstance(res, (Ok, Deny)):
|
|
804
|
+
return res # type: ignore[return-value]
|
|
805
|
+
# Treat any other value as allow
|
|
806
|
+
return Ok(None)
|
|
746
807
|
|
|
747
808
|
with PulseContext.update(session=session, render=render):
|
|
748
|
-
res = self.middleware.channel(
|
|
809
|
+
res = await self.middleware.channel(
|
|
749
810
|
channel_id=channel_id,
|
|
750
811
|
event=msg.get("event", ""),
|
|
751
812
|
payload=msg.get("payload"),
|
|
@@ -753,6 +814,7 @@ class App:
|
|
|
753
814
|
session=session.data,
|
|
754
815
|
next=_next,
|
|
755
816
|
)
|
|
817
|
+
res = _normalize_message_response(res)
|
|
756
818
|
|
|
757
819
|
if isinstance(res, Deny):
|
|
758
820
|
if req_id := msg.get("requestId"):
|
|
@@ -844,7 +906,6 @@ class App:
|
|
|
844
906
|
):
|
|
845
907
|
if rid in self.render_sessions:
|
|
846
908
|
raise ValueError(f"RenderSession {rid} already exists")
|
|
847
|
-
print(f"Creating RenderSession {rid}")
|
|
848
909
|
render = RenderSession(
|
|
849
910
|
rid,
|
|
850
911
|
self.routes,
|
|
@@ -863,7 +924,6 @@ class App:
|
|
|
863
924
|
render = self.render_sessions.pop(rid, None)
|
|
864
925
|
if not render:
|
|
865
926
|
return
|
|
866
|
-
print(f"Closing RenderSession {rid}")
|
|
867
927
|
sid = self._render_to_user.pop(rid)
|
|
868
928
|
session = self.user_sessions[sid]
|
|
869
929
|
render.close()
|
|
@@ -15,7 +15,6 @@ from pulse.messages import (
|
|
|
15
15
|
ServerChannelRequestMessage,
|
|
16
16
|
ServerChannelResponseMessage,
|
|
17
17
|
)
|
|
18
|
-
from pulse.routing import normalize_path
|
|
19
18
|
|
|
20
19
|
if TYPE_CHECKING:
|
|
21
20
|
from pulse.render_session import RenderSession
|
|
@@ -69,7 +68,8 @@ class ChannelsManager:
|
|
|
69
68
|
|
|
70
69
|
route_path: str | None = None
|
|
71
70
|
if ctx.route is not None:
|
|
72
|
-
|
|
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:
|