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/app.py
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pulse UI App class - similar to FastAPI's App.
|
|
3
|
+
|
|
4
|
+
This module provides the main App class that users instantiate in their main.py
|
|
5
|
+
to define routes and configure their Pulse application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Awaitable, Sequence
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import IntEnum
|
|
16
|
+
from typing import Any, Callable, Literal, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
import socketio
|
|
19
|
+
import uvicorn
|
|
20
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
21
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
22
|
+
from fastapi.responses import JSONResponse
|
|
23
|
+
from starlette.types import ASGIApp
|
|
24
|
+
from starlette.websockets import WebSocket
|
|
25
|
+
|
|
26
|
+
from pulse.codegen.codegen import Codegen, CodegenConfig
|
|
27
|
+
from pulse.context import PULSE_CONTEXT, PulseContext
|
|
28
|
+
from pulse.cookies import (
|
|
29
|
+
Cookie,
|
|
30
|
+
CORSOptions,
|
|
31
|
+
compute_cookie_domain,
|
|
32
|
+
compute_cookie_secure,
|
|
33
|
+
cors_options,
|
|
34
|
+
session_cookie,
|
|
35
|
+
)
|
|
36
|
+
from pulse.env import (
|
|
37
|
+
ENV_PULSE_HOST,
|
|
38
|
+
ENV_PULSE_PORT,
|
|
39
|
+
PulseEnv,
|
|
40
|
+
)
|
|
41
|
+
from pulse.env import env as envvars
|
|
42
|
+
from pulse.helpers import (
|
|
43
|
+
create_task,
|
|
44
|
+
find_available_port,
|
|
45
|
+
get_client_address,
|
|
46
|
+
get_client_address_socketio,
|
|
47
|
+
later,
|
|
48
|
+
)
|
|
49
|
+
from pulse.hooks.core import hooks
|
|
50
|
+
from pulse.messages import (
|
|
51
|
+
ClientChannelMessage,
|
|
52
|
+
ClientChannelRequestMessage,
|
|
53
|
+
ClientChannelResponseMessage,
|
|
54
|
+
ClientMessage,
|
|
55
|
+
ClientPulseMessage,
|
|
56
|
+
Prerender,
|
|
57
|
+
PrerenderPayload,
|
|
58
|
+
ServerInitMessage,
|
|
59
|
+
ServerMessage,
|
|
60
|
+
ServerNavigateToMessage,
|
|
61
|
+
)
|
|
62
|
+
from pulse.middleware import (
|
|
63
|
+
ConnectResponse,
|
|
64
|
+
Deny,
|
|
65
|
+
MiddlewareStack,
|
|
66
|
+
NotFound,
|
|
67
|
+
Ok,
|
|
68
|
+
PrerenderResponse,
|
|
69
|
+
PulseMiddleware,
|
|
70
|
+
Redirect,
|
|
71
|
+
)
|
|
72
|
+
from pulse.plugin import Plugin
|
|
73
|
+
from pulse.proxy import ReactProxy
|
|
74
|
+
from pulse.render_session import RenderSession
|
|
75
|
+
from pulse.request import PulseRequest
|
|
76
|
+
from pulse.routing import Layout, Route, RouteTree, ensure_absolute_path
|
|
77
|
+
from pulse.serializer import Serialized, deserialize, serialize
|
|
78
|
+
from pulse.user_session import (
|
|
79
|
+
CookieSessionStore,
|
|
80
|
+
SessionStore,
|
|
81
|
+
UserSession,
|
|
82
|
+
new_sid,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logger = logging.getLogger(__name__)
|
|
86
|
+
|
|
87
|
+
T = TypeVar("T")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AppStatus(IntEnum):
|
|
91
|
+
"""Application lifecycle status.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
created: App instance created but not yet initialized.
|
|
95
|
+
initialized: App.setup() has been called, routes configured.
|
|
96
|
+
running: App is actively serving requests.
|
|
97
|
+
draining: App is shutting down, draining connections.
|
|
98
|
+
stopped: App has been fully stopped.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
created = 0
|
|
102
|
+
initialized = 1
|
|
103
|
+
running = 2
|
|
104
|
+
draining = 3
|
|
105
|
+
stopped = 4
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
PulseMode = Literal["subdomains", "single-server"]
|
|
109
|
+
"""Deployment mode for the application.
|
|
110
|
+
|
|
111
|
+
Values:
|
|
112
|
+
"single-server": Python and React served from the same origin (default).
|
|
113
|
+
"subdomains": Python API on a subdomain (e.g., api.example.com).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class ConnectionStatusConfig:
|
|
119
|
+
"""
|
|
120
|
+
Configuration for connection status message delays.
|
|
121
|
+
|
|
122
|
+
Attributes:
|
|
123
|
+
initial_connecting_delay: Delay in seconds before showing "Connecting..." message
|
|
124
|
+
on initial connection attempt. Default: 2.0
|
|
125
|
+
initial_error_delay: Additional delay in seconds before showing error message
|
|
126
|
+
on initial connection attempt (after connecting message). Default: 8.0
|
|
127
|
+
reconnect_error_delay: Delay in seconds before showing error message when
|
|
128
|
+
reconnecting after losing connection. Default: 8.0
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
initial_connecting_delay: float = 2.0
|
|
132
|
+
initial_error_delay: float = 8.0
|
|
133
|
+
reconnect_error_delay: float = 8.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class App:
|
|
137
|
+
"""Main Pulse application class.
|
|
138
|
+
|
|
139
|
+
Creates a server that handles routing, sessions, and WebSocket connections.
|
|
140
|
+
Similar to FastAPI, users create an App instance and define their routes.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
routes: Route definitions for the application.
|
|
144
|
+
codegen: Code generation settings for React Router output.
|
|
145
|
+
middleware: Request middleware, either a single middleware or sequence.
|
|
146
|
+
plugins: Application plugins that can contribute routes, middleware,
|
|
147
|
+
and lifecycle hooks.
|
|
148
|
+
cookie: Session cookie configuration.
|
|
149
|
+
session_store: Session storage backend. Defaults to CookieSessionStore.
|
|
150
|
+
server_address: Public server URL. Used only in ci/prod.
|
|
151
|
+
dev_server_address: Development server URL. Defaults to
|
|
152
|
+
"http://localhost:8000".
|
|
153
|
+
internal_server_address: Internal URL for server-side loader fetches.
|
|
154
|
+
Falls back to server_address if not provided.
|
|
155
|
+
not_found: Path for 404 page. Defaults to "/not-found".
|
|
156
|
+
mode: Deployment mode - "single-server" (default) or "subdomains".
|
|
157
|
+
api_prefix: API route prefix. Defaults to "/_pulse".
|
|
158
|
+
cors: CORS configuration. Auto-configured based on mode if not provided.
|
|
159
|
+
fastapi: Additional FastAPI constructor options.
|
|
160
|
+
session_timeout: Session cleanup timeout in seconds. Defaults to 60.0.
|
|
161
|
+
connection_status: Connection status UI timing configuration.
|
|
162
|
+
|
|
163
|
+
Attributes:
|
|
164
|
+
env: Current environment ("dev", "ci", or "prod").
|
|
165
|
+
mode: Deployment mode ("single-server" or "subdomains").
|
|
166
|
+
status: Current application lifecycle status.
|
|
167
|
+
routes: Parsed route tree containing all registered routes.
|
|
168
|
+
fastapi: Underlying FastAPI instance.
|
|
169
|
+
asgi: ASGI application (includes Socket.IO).
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
```python
|
|
173
|
+
import pulse as ps
|
|
174
|
+
|
|
175
|
+
app = ps.App(
|
|
176
|
+
routes=[
|
|
177
|
+
ps.Route("/", render=home),
|
|
178
|
+
ps.Route("/users/:id", render=user_detail),
|
|
179
|
+
],
|
|
180
|
+
session_timeout=120.0,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
app.run(port=8000)
|
|
185
|
+
```
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
env: PulseEnv
|
|
189
|
+
mode: PulseMode
|
|
190
|
+
status: AppStatus
|
|
191
|
+
server_address: str | None
|
|
192
|
+
dev_server_address: str
|
|
193
|
+
internal_server_address: str | None
|
|
194
|
+
api_prefix: str
|
|
195
|
+
plugins: list[Plugin]
|
|
196
|
+
routes: RouteTree
|
|
197
|
+
not_found: str
|
|
198
|
+
user_sessions: dict[str, UserSession]
|
|
199
|
+
render_sessions: dict[str, RenderSession]
|
|
200
|
+
session_store: SessionStore | CookieSessionStore
|
|
201
|
+
cookie: Cookie
|
|
202
|
+
cors: CORSOptions | None
|
|
203
|
+
codegen: Codegen
|
|
204
|
+
fastapi: FastAPI
|
|
205
|
+
sio: socketio.AsyncServer
|
|
206
|
+
asgi: ASGIApp
|
|
207
|
+
middleware: MiddlewareStack
|
|
208
|
+
_user_to_render: dict[str, list[str]]
|
|
209
|
+
_render_to_user: dict[str, str]
|
|
210
|
+
_sessions_in_request: dict[str, int]
|
|
211
|
+
_socket_to_render: dict[str, str]
|
|
212
|
+
_render_cleanups: dict[str, asyncio.TimerHandle]
|
|
213
|
+
session_timeout: float
|
|
214
|
+
connection_status: ConnectionStatusConfig
|
|
215
|
+
render_loop_limit: int
|
|
216
|
+
detach_queue_timeout: float
|
|
217
|
+
disconnect_queue_timeout: float
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
routes: Sequence[Route | Layout] | None = None,
|
|
222
|
+
codegen: CodegenConfig | None = None,
|
|
223
|
+
middleware: PulseMiddleware | Sequence[PulseMiddleware] | None = None,
|
|
224
|
+
plugins: Sequence[Plugin] | None = None,
|
|
225
|
+
cookie: Cookie | None = None,
|
|
226
|
+
session_store: SessionStore | None = None,
|
|
227
|
+
server_address: str | None = None,
|
|
228
|
+
dev_server_address: str = "http://localhost:8000",
|
|
229
|
+
internal_server_address: str | None = None,
|
|
230
|
+
not_found: str = "/not-found",
|
|
231
|
+
# Deployment and integration options
|
|
232
|
+
mode: PulseMode = "single-server",
|
|
233
|
+
api_prefix: str = "/_pulse",
|
|
234
|
+
cors: CORSOptions | None = None,
|
|
235
|
+
fastapi: dict[str, Any] | None = None,
|
|
236
|
+
session_timeout: float = 60.0,
|
|
237
|
+
detach_queue_timeout: float = 15.0,
|
|
238
|
+
disconnect_queue_timeout: float = 300.0,
|
|
239
|
+
connection_status: ConnectionStatusConfig | None = None,
|
|
240
|
+
render_loop_limit: int = 50,
|
|
241
|
+
):
|
|
242
|
+
# Resolve mode from environment and expose on the app instance
|
|
243
|
+
self.env = envvars.pulse_env
|
|
244
|
+
self.mode = mode
|
|
245
|
+
self.status = AppStatus.created
|
|
246
|
+
# Persist the server address for use by sessions (API calls, etc.) in ci/prod.
|
|
247
|
+
self.server_address = server_address if self.env in ("ci", "prod") else None
|
|
248
|
+
# Development server address (used in dev mode)
|
|
249
|
+
self.dev_server_address = dev_server_address
|
|
250
|
+
# Optional internal address used by server-side loader fetches
|
|
251
|
+
self.internal_server_address = internal_server_address
|
|
252
|
+
|
|
253
|
+
self.api_prefix = api_prefix
|
|
254
|
+
|
|
255
|
+
# Resolve and store plugins (sorted by priority, highest first)
|
|
256
|
+
self.plugins = []
|
|
257
|
+
if plugins:
|
|
258
|
+
self.plugins = sorted(
|
|
259
|
+
list(plugins), key=lambda p: getattr(p, "priority", 0), reverse=True
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Build the complete route list from constructor args and plugins
|
|
263
|
+
all_routes: list[Route | Layout] = list(routes or [])
|
|
264
|
+
# Add plugin routes after user-defined routes
|
|
265
|
+
for plugin in self.plugins:
|
|
266
|
+
all_routes.extend(plugin.routes())
|
|
267
|
+
|
|
268
|
+
# RouteTree filters routes based on dev flag and environment during construction
|
|
269
|
+
self.routes = RouteTree(all_routes)
|
|
270
|
+
self.not_found = not_found
|
|
271
|
+
# Default not-found path for client-side navigation on not_found()
|
|
272
|
+
# Users can override via App(..., not_found_path="/my-404") in future
|
|
273
|
+
self.user_sessions = {}
|
|
274
|
+
self.render_sessions = {}
|
|
275
|
+
self.session_store = session_store or CookieSessionStore()
|
|
276
|
+
self.cookie = cookie or session_cookie(mode=self.mode)
|
|
277
|
+
self.cors = cors
|
|
278
|
+
|
|
279
|
+
self._user_to_render = defaultdict(list)
|
|
280
|
+
self._render_to_user = {}
|
|
281
|
+
self._sessions_in_request = {}
|
|
282
|
+
# Map websocket sid -> renderId for message routing
|
|
283
|
+
self._socket_to_render = {}
|
|
284
|
+
# Map render_id -> cleanup timer handle for timeout-based expiry
|
|
285
|
+
self._render_cleanups = {}
|
|
286
|
+
self.session_timeout = session_timeout
|
|
287
|
+
self.detach_queue_timeout = detach_queue_timeout
|
|
288
|
+
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
289
|
+
self.connection_status = connection_status or ConnectionStatusConfig()
|
|
290
|
+
self.render_loop_limit = render_loop_limit
|
|
291
|
+
|
|
292
|
+
self.codegen = Codegen(
|
|
293
|
+
self.routes,
|
|
294
|
+
config=codegen or CodegenConfig(),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
self.fastapi = FastAPI(
|
|
298
|
+
title="Pulse UI Server",
|
|
299
|
+
lifespan=self.fastapi_lifespan,
|
|
300
|
+
)
|
|
301
|
+
self.sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
|
|
302
|
+
self.asgi = socketio.ASGIApp(self.sio, self.fastapi)
|
|
303
|
+
|
|
304
|
+
if middleware is None:
|
|
305
|
+
mw_stack: list[PulseMiddleware] = []
|
|
306
|
+
elif isinstance(middleware, PulseMiddleware):
|
|
307
|
+
mw_stack = [middleware]
|
|
308
|
+
else:
|
|
309
|
+
mw_stack = list(middleware)
|
|
310
|
+
|
|
311
|
+
# Let plugins contribute middleware (in plugin priority order)
|
|
312
|
+
for plugin in self.plugins:
|
|
313
|
+
mw_stack.extend(plugin.middleware())
|
|
314
|
+
|
|
315
|
+
self.middleware = MiddlewareStack(mw_stack)
|
|
316
|
+
|
|
317
|
+
@asynccontextmanager
|
|
318
|
+
async def fastapi_lifespan(self, _: FastAPI):
|
|
319
|
+
try:
|
|
320
|
+
if isinstance(self.session_store, SessionStore):
|
|
321
|
+
await self.session_store.init()
|
|
322
|
+
except Exception:
|
|
323
|
+
logger.exception("Error during SessionStore.init()")
|
|
324
|
+
|
|
325
|
+
# Call plugin on_startup hooks before serving
|
|
326
|
+
for plugin in self.plugins:
|
|
327
|
+
plugin.on_startup(self)
|
|
328
|
+
|
|
329
|
+
if self.mode == "single-server":
|
|
330
|
+
react_server_address = envvars.react_server_address
|
|
331
|
+
if react_server_address:
|
|
332
|
+
logger.info(
|
|
333
|
+
f"Single-server mode: React Router running at {react_server_address}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
yield
|
|
338
|
+
finally:
|
|
339
|
+
try:
|
|
340
|
+
await self.close()
|
|
341
|
+
except Exception:
|
|
342
|
+
logger.exception("Error during App.close()")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
if isinstance(self.session_store, SessionStore):
|
|
346
|
+
await self.session_store.close()
|
|
347
|
+
except Exception:
|
|
348
|
+
logger.exception("Error during SessionStore.close()")
|
|
349
|
+
|
|
350
|
+
def run_codegen(
|
|
351
|
+
self, address: str | None = None, internal_address: str | None = None
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Generate React Router code for all routes.
|
|
354
|
+
|
|
355
|
+
Generates TypeScript/JSX files for React Router integration based on
|
|
356
|
+
the application's route definitions.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
address: Public server address. Updates server_address if provided.
|
|
360
|
+
internal_address: Internal server address for SSR fetches. Updates
|
|
361
|
+
internal_server_address if provided.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
RuntimeError: If no server address is available (neither passed
|
|
365
|
+
as argument nor set on the App instance).
|
|
366
|
+
"""
|
|
367
|
+
# Allow the CLI to disable codegen in specific scenarios (e.g., prod server-only)
|
|
368
|
+
if envvars.codegen_disabled:
|
|
369
|
+
return
|
|
370
|
+
if address:
|
|
371
|
+
self.server_address = address
|
|
372
|
+
if internal_address:
|
|
373
|
+
self.internal_server_address = internal_address
|
|
374
|
+
if not self.server_address:
|
|
375
|
+
raise RuntimeError(
|
|
376
|
+
"Please provide a server address to the App constructor or the Pulse CLI."
|
|
377
|
+
)
|
|
378
|
+
self.codegen.generate_all(
|
|
379
|
+
self.server_address,
|
|
380
|
+
self.internal_server_address or self.server_address,
|
|
381
|
+
self.api_prefix,
|
|
382
|
+
connection_status=self.connection_status,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def asgi_factory(self) -> ASGIApp:
|
|
386
|
+
"""ASGI factory for production deployment.
|
|
387
|
+
|
|
388
|
+
Called on each uvicorn reload. Initializes code generation and sets up
|
|
389
|
+
the application with the appropriate server address.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
The ASGI application instance (includes Socket.IO).
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
RuntimeError: If in prod/ci mode without an explicit server_address.
|
|
396
|
+
"""
|
|
397
|
+
# In prod/ci, use the server_address provided to App(...).
|
|
398
|
+
if self.env in ("prod", "ci"):
|
|
399
|
+
if not self.server_address:
|
|
400
|
+
raise RuntimeError(
|
|
401
|
+
f"In {self.env}, please provide an explicit server_address to App(...)."
|
|
402
|
+
)
|
|
403
|
+
server_address = self.server_address
|
|
404
|
+
# In dev, prefer env vars set by CLI (--address/--port), otherwise use dev_server_address.
|
|
405
|
+
else:
|
|
406
|
+
# In dev mode, check if CLI set PULSE_HOST/PULSE_PORT env vars
|
|
407
|
+
# If env vars were explicitly set (not just defaults), use them
|
|
408
|
+
host = os.environ.get(ENV_PULSE_HOST)
|
|
409
|
+
port = os.environ.get(ENV_PULSE_PORT)
|
|
410
|
+
if host is not None and port is not None:
|
|
411
|
+
protocol = "http" if host in ("127.0.0.1", "localhost") else "https"
|
|
412
|
+
server_address = f"{protocol}://{host}:{port}"
|
|
413
|
+
else:
|
|
414
|
+
server_address = self.dev_server_address
|
|
415
|
+
|
|
416
|
+
# Use internal server address for server-side loader if provided; fallback to public
|
|
417
|
+
internal_address = self.internal_server_address or server_address
|
|
418
|
+
self.run_codegen(server_address, internal_address)
|
|
419
|
+
self.setup(server_address)
|
|
420
|
+
self.status = AppStatus.running
|
|
421
|
+
|
|
422
|
+
return self.asgi
|
|
423
|
+
|
|
424
|
+
def run(
|
|
425
|
+
self,
|
|
426
|
+
address: str = "localhost",
|
|
427
|
+
port: int = 8000,
|
|
428
|
+
find_port: bool = True,
|
|
429
|
+
reload: bool = True,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Start the development server with uvicorn.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
address: Host address to bind to. Defaults to "localhost".
|
|
435
|
+
port: Port number to listen on. Defaults to 8000.
|
|
436
|
+
find_port: If True, automatically find an available port if the
|
|
437
|
+
specified port is in use. Defaults to True.
|
|
438
|
+
reload: If True, enable auto-reload on file changes. Defaults to True.
|
|
439
|
+
"""
|
|
440
|
+
if find_port:
|
|
441
|
+
port = find_available_port(port)
|
|
442
|
+
|
|
443
|
+
uvicorn.run(self.asgi_factory, reload=reload)
|
|
444
|
+
|
|
445
|
+
def setup(self, server_address: str) -> None:
|
|
446
|
+
"""Initialize the app with a server address.
|
|
447
|
+
|
|
448
|
+
Configures FastAPI routes, middleware, CORS, and Socket.IO handlers.
|
|
449
|
+
Called automatically by asgi_factory().
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
server_address: The public URL where the server is accessible.
|
|
453
|
+
|
|
454
|
+
Note:
|
|
455
|
+
This method is idempotent - calling it multiple times on an already
|
|
456
|
+
initialized app will log a warning and return early.
|
|
457
|
+
"""
|
|
458
|
+
if self.status >= AppStatus.initialized:
|
|
459
|
+
logger.warning("Called App.setup() on an already initialized application")
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
self.server_address = server_address
|
|
463
|
+
PULSE_CONTEXT.set(PulseContext(app=self))
|
|
464
|
+
|
|
465
|
+
hooks.lock()
|
|
466
|
+
|
|
467
|
+
# Compute cookie domain from deployment/server address if not explicitly provided
|
|
468
|
+
if self.cookie.domain is None:
|
|
469
|
+
self.cookie.domain = compute_cookie_domain(self.mode, self.server_address)
|
|
470
|
+
if self.cookie.secure is None:
|
|
471
|
+
self.cookie.secure = compute_cookie_secure(self.env, self.server_address)
|
|
472
|
+
|
|
473
|
+
# Add CORS middleware (configurable/overridable)
|
|
474
|
+
if self.cors is not None:
|
|
475
|
+
self.fastapi.add_middleware(CORSMiddleware, **self.cors)
|
|
476
|
+
else:
|
|
477
|
+
# Use deployment-specific CORS settings
|
|
478
|
+
cors_config = cors_options(self.mode, self.server_address)
|
|
479
|
+
self.fastapi.add_middleware(
|
|
480
|
+
CORSMiddleware,
|
|
481
|
+
**cors_config,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Mount PulseContext for all FastAPI routes (no route info). Other API
|
|
485
|
+
# routes / middleware should be added at the module-level, which means
|
|
486
|
+
# this middleware will wrap all of them.
|
|
487
|
+
@self.fastapi.middleware("http")
|
|
488
|
+
async def session_middleware( # pyright: ignore[reportUnusedFunction]
|
|
489
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
490
|
+
):
|
|
491
|
+
# Skip session handling for CORS preflight requests
|
|
492
|
+
if request.method == "OPTIONS":
|
|
493
|
+
return await call_next(request)
|
|
494
|
+
# Session cookie handling
|
|
495
|
+
cookie = self.cookie.get_from_fastapi(request)
|
|
496
|
+
session = await self.get_or_create_session(cookie)
|
|
497
|
+
self._sessions_in_request[session.sid] = (
|
|
498
|
+
self._sessions_in_request.get(session.sid, 0) + 1
|
|
499
|
+
)
|
|
500
|
+
render_id = request.headers.get("x-pulse-render-id")
|
|
501
|
+
render = self._get_render_for_session(render_id, session)
|
|
502
|
+
with PulseContext.update(session=session, render=render):
|
|
503
|
+
res: Response = await call_next(request)
|
|
504
|
+
session.handle_response(res)
|
|
505
|
+
|
|
506
|
+
self._sessions_in_request[session.sid] -= 1
|
|
507
|
+
if self._sessions_in_request[session.sid] == 0:
|
|
508
|
+
del self._sessions_in_request[session.sid]
|
|
509
|
+
|
|
510
|
+
return res
|
|
511
|
+
|
|
512
|
+
# Apply prefix to all routes
|
|
513
|
+
prefix = self.api_prefix
|
|
514
|
+
|
|
515
|
+
@self.fastapi.get(f"{prefix}/health")
|
|
516
|
+
def healthcheck(): # pyright: ignore[reportUnusedFunction]
|
|
517
|
+
return {"health": "ok", "message": "Pulse server is running"}
|
|
518
|
+
|
|
519
|
+
@self.fastapi.get(f"{prefix}/set-cookies")
|
|
520
|
+
def set_cookies(): # pyright: ignore[reportUnusedFunction]
|
|
521
|
+
return {"health": "ok", "message": "Cookies updated"}
|
|
522
|
+
|
|
523
|
+
# RouteInfo is the request body
|
|
524
|
+
@self.fastapi.post(f"{prefix}/prerender")
|
|
525
|
+
async def prerender(payload: PrerenderPayload, request: Request): # pyright: ignore[reportUnusedFunction]
|
|
526
|
+
"""
|
|
527
|
+
POST /prerender
|
|
528
|
+
Body: { paths: string[], routeInfo: RouteInfo, ttlSeconds?: number }
|
|
529
|
+
Headers: X-Pulse-Render-Id (optional, for render session reuse)
|
|
530
|
+
Returns: { renderId: string, <path>: VDOM, ... }
|
|
531
|
+
"""
|
|
532
|
+
session = PulseContext.get().session
|
|
533
|
+
if session is None:
|
|
534
|
+
raise RuntimeError("Internal error: couldn't resolve user session")
|
|
535
|
+
paths = payload.get("paths") or []
|
|
536
|
+
if len(paths) == 0:
|
|
537
|
+
raise HTTPException(
|
|
538
|
+
status_code=400, detail="'paths' must be a non-empty list"
|
|
539
|
+
)
|
|
540
|
+
paths = [ensure_absolute_path(path) for path in paths]
|
|
541
|
+
payload["paths"] = paths
|
|
542
|
+
route_info = payload.get("routeInfo")
|
|
543
|
+
|
|
544
|
+
client_addr: str | None = get_client_address(request)
|
|
545
|
+
# Reuse render session from header (set by middleware) or create new one
|
|
546
|
+
render = PulseContext.get().render
|
|
547
|
+
if render is not None:
|
|
548
|
+
render_id = render.id
|
|
549
|
+
else:
|
|
550
|
+
# Create new render session
|
|
551
|
+
render_id = new_sid()
|
|
552
|
+
render = self.create_render(
|
|
553
|
+
render_id, session, client_address=client_addr
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Schedule cleanup timeout (will cancel/reschedule on activity)
|
|
557
|
+
self._schedule_render_cleanup(render_id)
|
|
558
|
+
|
|
559
|
+
def _normalize_prerender_result(
|
|
560
|
+
captured: ServerInitMessage | ServerNavigateToMessage,
|
|
561
|
+
) -> Ok[ServerInitMessage] | Redirect | NotFound:
|
|
562
|
+
if captured["type"] == "vdom_init":
|
|
563
|
+
return Ok(captured)
|
|
564
|
+
if captured["type"] == "navigate_to":
|
|
565
|
+
nav_path = captured["path"]
|
|
566
|
+
replace = captured["replace"]
|
|
567
|
+
# Treat navigate to not_found (replace) as NotFound
|
|
568
|
+
if replace and nav_path == self.not_found:
|
|
569
|
+
return NotFound()
|
|
570
|
+
return Redirect(path=str(nav_path) if nav_path else "/")
|
|
571
|
+
# Fallback: shouldn't happen, return not found to be safe
|
|
572
|
+
return NotFound()
|
|
573
|
+
|
|
574
|
+
with PulseContext.update(render=render):
|
|
575
|
+
# Call top-level prerender middleware, which wraps the route processing
|
|
576
|
+
async def _process_routes() -> PrerenderResponse:
|
|
577
|
+
result_data: Prerender = {
|
|
578
|
+
"views": {},
|
|
579
|
+
"directives": {
|
|
580
|
+
"headers": {"X-Pulse-Render-Id": render_id},
|
|
581
|
+
"socketio": {
|
|
582
|
+
"auth": {"render_id": render_id},
|
|
583
|
+
"headers": {},
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
captured = render.prerender(paths, route_info)
|
|
589
|
+
|
|
590
|
+
for p in paths:
|
|
591
|
+
res = _normalize_prerender_result(captured[p])
|
|
592
|
+
if isinstance(res, Ok):
|
|
593
|
+
# Aggregate results
|
|
594
|
+
result_data["views"][p] = res.payload
|
|
595
|
+
elif isinstance(res, Redirect):
|
|
596
|
+
# Return redirect immediately
|
|
597
|
+
return Redirect(path=res.path or "/")
|
|
598
|
+
elif isinstance(res, NotFound):
|
|
599
|
+
# Return not found immediately
|
|
600
|
+
return NotFound()
|
|
601
|
+
else:
|
|
602
|
+
raise ValueError("Unexpected prerender response:", res)
|
|
603
|
+
|
|
604
|
+
return Ok(result_data)
|
|
605
|
+
|
|
606
|
+
result = await self.middleware.prerender(
|
|
607
|
+
payload=payload,
|
|
608
|
+
request=PulseRequest.from_fastapi(request),
|
|
609
|
+
session=session.data,
|
|
610
|
+
next=_process_routes,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Handle redirect/notFound responses
|
|
614
|
+
if isinstance(result, Redirect):
|
|
615
|
+
resp = JSONResponse({"redirect": result.path})
|
|
616
|
+
session.handle_response(resp)
|
|
617
|
+
return resp
|
|
618
|
+
if isinstance(result, NotFound):
|
|
619
|
+
resp = JSONResponse({"notFound": True})
|
|
620
|
+
session.handle_response(resp)
|
|
621
|
+
return resp
|
|
622
|
+
|
|
623
|
+
# Handle Ok result - serialize the payload (PrerenderResultData)
|
|
624
|
+
if isinstance(result, Ok):
|
|
625
|
+
resp = JSONResponse(serialize(result.payload))
|
|
626
|
+
session.handle_response(resp)
|
|
627
|
+
return resp
|
|
628
|
+
|
|
629
|
+
# Fallback (shouldn't happen)
|
|
630
|
+
raise ValueError("Unexpected prerender result type")
|
|
631
|
+
|
|
632
|
+
@self.fastapi.post(f"{prefix}/forms/{{render_id}}/{{form_id}}")
|
|
633
|
+
async def handle_form_submit( # pyright: ignore[reportUnusedFunction]
|
|
634
|
+
render_id: str, form_id: str, request: Request
|
|
635
|
+
) -> Response:
|
|
636
|
+
session = PulseContext.get().session
|
|
637
|
+
if session is None:
|
|
638
|
+
raise RuntimeError("Internal error: couldn't resolve user session")
|
|
639
|
+
|
|
640
|
+
render = self.render_sessions.get(render_id)
|
|
641
|
+
if not render:
|
|
642
|
+
raise HTTPException(status_code=410, detail="Render session expired")
|
|
643
|
+
|
|
644
|
+
return await render.forms.handle_submit(form_id, request, session)
|
|
645
|
+
|
|
646
|
+
# Call on_setup hooks after FastAPI routes/middleware are in place
|
|
647
|
+
for plugin in self.plugins:
|
|
648
|
+
plugin.on_setup(self)
|
|
649
|
+
|
|
650
|
+
# In single-server mode, add catch-all route to proxy unmatched requests to React server
|
|
651
|
+
# This route must be registered last so FastAPI tries all specific routes first
|
|
652
|
+
# FastAPI will match specific routes before this catch-all, but we add an explicit check
|
|
653
|
+
# as a safety measure to ensure API routes are never proxied
|
|
654
|
+
if self.mode == "single-server":
|
|
655
|
+
react_server_address = envvars.react_server_address
|
|
656
|
+
if not react_server_address:
|
|
657
|
+
raise RuntimeError(
|
|
658
|
+
"PULSE_REACT_SERVER_ADDRESS must be set in single-server mode. "
|
|
659
|
+
+ "Use 'pulse run' CLI command or set the environment variable."
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
proxy_handler = ReactProxy(
|
|
663
|
+
react_server_address=react_server_address,
|
|
664
|
+
server_address=server_address,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
|
|
668
|
+
# Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
|
|
669
|
+
if self.env == "dev":
|
|
670
|
+
|
|
671
|
+
@self.fastapi.websocket("/{path:path}")
|
|
672
|
+
async def websocket_proxy(websocket: WebSocket, path: str): # pyright: ignore[reportUnusedFunction]
|
|
673
|
+
await proxy_handler.proxy_websocket(websocket)
|
|
674
|
+
|
|
675
|
+
@self.fastapi.api_route(
|
|
676
|
+
"/{path:path}",
|
|
677
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
678
|
+
include_in_schema=False,
|
|
679
|
+
)
|
|
680
|
+
async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
|
|
681
|
+
# Proxy all unmatched HTTP requests to React Router
|
|
682
|
+
return await proxy_handler(request)
|
|
683
|
+
|
|
684
|
+
@self.sio.event
|
|
685
|
+
async def connect( # pyright: ignore[reportUnusedFunction]
|
|
686
|
+
sid: str, environ: dict[str, Any], auth: dict[str, str] | None
|
|
687
|
+
):
|
|
688
|
+
# Expect renderId during websocket auth and require a valid user session
|
|
689
|
+
rid = auth.get("render_id") if auth else None
|
|
690
|
+
|
|
691
|
+
# Parse cookies from environ and ensure a session exists
|
|
692
|
+
cookie = self.cookie.get_from_socketio(environ)
|
|
693
|
+
if cookie is None:
|
|
694
|
+
raise ConnectionRefusedError("Socket connect missing cookie")
|
|
695
|
+
session = await self.get_or_create_session(cookie)
|
|
696
|
+
|
|
697
|
+
if not rid:
|
|
698
|
+
# Still refuse connections without a renderId
|
|
699
|
+
raise ConnectionRefusedError(
|
|
700
|
+
f"Socket connect missing render_id session={session.sid}"
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Allow reconnects where the provided renderId no longer exists by creating a new RenderSession
|
|
704
|
+
render = self.render_sessions.get(rid)
|
|
705
|
+
if render is None:
|
|
706
|
+
render = self.create_render(
|
|
707
|
+
rid, session, client_address=get_client_address_socketio(environ)
|
|
708
|
+
)
|
|
709
|
+
else:
|
|
710
|
+
owner = self._render_to_user.get(render.id)
|
|
711
|
+
if owner != session.sid:
|
|
712
|
+
raise ConnectionRefusedError(
|
|
713
|
+
f"Socket connect session mismatch render={render.id} "
|
|
714
|
+
+ f"owner={owner} session={session.sid}"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def on_message(message: ServerMessage):
|
|
718
|
+
payload = serialize(message)
|
|
719
|
+
# `serialize` returns a tuple, which socket.io will mistake for multiple arguments
|
|
720
|
+
payload = list(payload)
|
|
721
|
+
create_task(self.sio.emit("message", list(payload), to=sid))
|
|
722
|
+
|
|
723
|
+
render.connect(on_message)
|
|
724
|
+
# Map socket sid to renderId for message routing
|
|
725
|
+
self._socket_to_render[sid] = rid
|
|
726
|
+
|
|
727
|
+
# Cancel any pending cleanup since session is now connected
|
|
728
|
+
self._cancel_render_cleanup(rid)
|
|
729
|
+
|
|
730
|
+
with PulseContext.update(session=session, render=render):
|
|
731
|
+
|
|
732
|
+
async def _next():
|
|
733
|
+
return Ok(None)
|
|
734
|
+
|
|
735
|
+
def _normalize_connect_response(res: Any) -> ConnectResponse:
|
|
736
|
+
if isinstance(res, (Ok, Deny)):
|
|
737
|
+
return res # type: ignore[return-value]
|
|
738
|
+
# Treat any other value as allow
|
|
739
|
+
return Ok(None)
|
|
740
|
+
|
|
741
|
+
try:
|
|
742
|
+
res = await self.middleware.connect(
|
|
743
|
+
request=PulseRequest.from_socketio_environ(environ, auth),
|
|
744
|
+
session=session.data,
|
|
745
|
+
next=_next,
|
|
746
|
+
)
|
|
747
|
+
res = _normalize_connect_response(res)
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
render.report_error("/", "connect", exc)
|
|
750
|
+
res = Ok(None)
|
|
751
|
+
if isinstance(res, Deny):
|
|
752
|
+
# Tear down the created session if denied
|
|
753
|
+
self.close_render(rid)
|
|
754
|
+
|
|
755
|
+
@self.sio.event
|
|
756
|
+
def disconnect(sid: str): # pyright: ignore[reportUnusedFunction]
|
|
757
|
+
rid = self._socket_to_render.pop(sid, None)
|
|
758
|
+
if rid is not None:
|
|
759
|
+
render = self.render_sessions.get(rid)
|
|
760
|
+
if render:
|
|
761
|
+
render.disconnect()
|
|
762
|
+
# Schedule cleanup after timeout (will keep session alive for reuse)
|
|
763
|
+
self._schedule_render_cleanup(rid)
|
|
764
|
+
|
|
765
|
+
@self.sio.event
|
|
766
|
+
async def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
|
|
767
|
+
rid = self._socket_to_render.get(sid)
|
|
768
|
+
if not rid:
|
|
769
|
+
return
|
|
770
|
+
render = self.render_sessions.get(rid)
|
|
771
|
+
if render is None:
|
|
772
|
+
return
|
|
773
|
+
# Cancel any pending cleanup for active sessions (connected sessions stay alive)
|
|
774
|
+
self._cancel_render_cleanup(rid)
|
|
775
|
+
# Use renderId mapping to user session
|
|
776
|
+
session = self.user_sessions[self._render_to_user[rid]]
|
|
777
|
+
# Make sure to properly deserialize the message contents
|
|
778
|
+
msg = cast(ClientMessage, deserialize(data))
|
|
779
|
+
try:
|
|
780
|
+
if msg["type"] == "channel_message":
|
|
781
|
+
await self._handle_channel_message(render, session, msg)
|
|
782
|
+
else:
|
|
783
|
+
await self._handle_pulse_message(render, session, msg)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
path = msg.get("path", "")
|
|
786
|
+
render.report_error(path, "server", e)
|
|
787
|
+
|
|
788
|
+
self.status = AppStatus.initialized
|
|
789
|
+
|
|
790
|
+
def _cancel_render_cleanup(self, rid: str):
|
|
791
|
+
"""Cancel any pending cleanup task for a render session."""
|
|
792
|
+
cleanup_handle = self._render_cleanups.pop(rid, None)
|
|
793
|
+
if cleanup_handle and not cleanup_handle.cancelled():
|
|
794
|
+
cleanup_handle.cancel()
|
|
795
|
+
|
|
796
|
+
def _schedule_render_cleanup(self, rid: str):
|
|
797
|
+
"""Schedule cleanup of a RenderSession after the configured timeout."""
|
|
798
|
+
render = self.render_sessions.get(rid)
|
|
799
|
+
if render is None:
|
|
800
|
+
return
|
|
801
|
+
# Don't schedule cleanup for connected sessions (they stay alive)
|
|
802
|
+
if render.connected:
|
|
803
|
+
return
|
|
804
|
+
|
|
805
|
+
# Cancel any existing cleanup task for this render session
|
|
806
|
+
self._cancel_render_cleanup(rid)
|
|
807
|
+
|
|
808
|
+
# Schedule new cleanup task
|
|
809
|
+
def _cleanup():
|
|
810
|
+
render = self.render_sessions.get(rid)
|
|
811
|
+
if render is None:
|
|
812
|
+
return
|
|
813
|
+
# Only cleanup if not connected (if connected, keep it alive)
|
|
814
|
+
if not render.connected:
|
|
815
|
+
logger.info(
|
|
816
|
+
f"RenderSession {rid} expired after {self.session_timeout}s timeout"
|
|
817
|
+
)
|
|
818
|
+
self.close_render(rid)
|
|
819
|
+
|
|
820
|
+
handle = later(self.session_timeout, _cleanup)
|
|
821
|
+
self._render_cleanups[rid] = handle
|
|
822
|
+
|
|
823
|
+
async def _handle_pulse_message(
|
|
824
|
+
self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
|
|
825
|
+
) -> None:
|
|
826
|
+
async def _next() -> Ok[None]:
|
|
827
|
+
if msg["type"] == "attach":
|
|
828
|
+
render.attach(msg["path"], msg["routeInfo"])
|
|
829
|
+
elif msg["type"] == "update":
|
|
830
|
+
render.update_route(msg["path"], msg["routeInfo"])
|
|
831
|
+
elif msg["type"] == "callback":
|
|
832
|
+
render.execute_callback(msg["path"], msg["callback"], msg["args"])
|
|
833
|
+
elif msg["type"] == "detach":
|
|
834
|
+
render.detach(msg["path"])
|
|
835
|
+
render.channels.remove_route(msg["path"])
|
|
836
|
+
elif msg["type"] == "api_result":
|
|
837
|
+
render.handle_api_result(dict(msg))
|
|
838
|
+
elif msg["type"] == "js_result":
|
|
839
|
+
render.handle_js_result(dict(msg))
|
|
840
|
+
else:
|
|
841
|
+
logger.warning("Unknown message type received: %s", msg)
|
|
842
|
+
return Ok()
|
|
843
|
+
|
|
844
|
+
def _normalize_message_response(res: Any) -> Ok[None] | Deny:
|
|
845
|
+
if isinstance(res, (Ok, Deny)):
|
|
846
|
+
return res # type: ignore[return-value]
|
|
847
|
+
# Treat any other value as allow
|
|
848
|
+
return Ok(None)
|
|
849
|
+
|
|
850
|
+
with PulseContext.update(session=session, render=render):
|
|
851
|
+
try:
|
|
852
|
+
res = await self.middleware.message(
|
|
853
|
+
data=msg,
|
|
854
|
+
session=session.data,
|
|
855
|
+
next=_next,
|
|
856
|
+
)
|
|
857
|
+
res = _normalize_message_response(res)
|
|
858
|
+
except Exception:
|
|
859
|
+
logger.exception("Error in message middleware")
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
if isinstance(res, Deny):
|
|
863
|
+
path = cast(str, msg.get("path", "api_response"))
|
|
864
|
+
render.report_error(
|
|
865
|
+
path,
|
|
866
|
+
"server",
|
|
867
|
+
Exception("Request denied by server"),
|
|
868
|
+
{"kind": "deny"},
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
async def _handle_channel_message(
|
|
872
|
+
self, render: RenderSession, session: UserSession, msg: ClientChannelMessage
|
|
873
|
+
) -> None:
|
|
874
|
+
if msg.get("responseTo"):
|
|
875
|
+
msg = cast(ClientChannelResponseMessage, msg)
|
|
876
|
+
render.channels.handle_client_response(msg)
|
|
877
|
+
else:
|
|
878
|
+
channel_id = str(msg.get("channel", ""))
|
|
879
|
+
msg = cast(ClientChannelRequestMessage, msg)
|
|
880
|
+
|
|
881
|
+
async def _next() -> Ok[None]:
|
|
882
|
+
render.channels.handle_client_event(
|
|
883
|
+
render=render, session=session, message=msg
|
|
884
|
+
)
|
|
885
|
+
return Ok(None)
|
|
886
|
+
|
|
887
|
+
def _normalize_message_response(res: Any) -> Ok[None] | Deny:
|
|
888
|
+
if isinstance(res, (Ok, Deny)):
|
|
889
|
+
return res # type: ignore[return-value]
|
|
890
|
+
# Treat any other value as allow
|
|
891
|
+
return Ok(None)
|
|
892
|
+
|
|
893
|
+
with PulseContext.update(session=session, render=render):
|
|
894
|
+
res = await self.middleware.channel(
|
|
895
|
+
channel_id=channel_id,
|
|
896
|
+
event=msg.get("event", ""),
|
|
897
|
+
payload=msg.get("payload"),
|
|
898
|
+
request_id=msg.get("requestId"),
|
|
899
|
+
session=session.data,
|
|
900
|
+
next=_next,
|
|
901
|
+
)
|
|
902
|
+
res = _normalize_message_response(res)
|
|
903
|
+
|
|
904
|
+
if isinstance(res, Deny):
|
|
905
|
+
if req_id := msg.get("requestId"):
|
|
906
|
+
render.channels.send_error(channel_id, req_id, "Denied")
|
|
907
|
+
|
|
908
|
+
def get_route(self, path: str):
|
|
909
|
+
return self.routes.find(path)
|
|
910
|
+
|
|
911
|
+
async def get_or_create_session(self, raw_cookie: str | None) -> UserSession:
|
|
912
|
+
if isinstance(self.session_store, CookieSessionStore):
|
|
913
|
+
if raw_cookie is not None:
|
|
914
|
+
session_data = self.session_store.decode(raw_cookie)
|
|
915
|
+
if session_data:
|
|
916
|
+
sid, data = session_data
|
|
917
|
+
existing = self.user_sessions.get(sid)
|
|
918
|
+
if existing is not None:
|
|
919
|
+
return existing
|
|
920
|
+
else:
|
|
921
|
+
session = UserSession(sid, data, self)
|
|
922
|
+
self.user_sessions[sid] = session
|
|
923
|
+
return session
|
|
924
|
+
# Invalid cookie = treat as no cookie
|
|
925
|
+
|
|
926
|
+
# No cookie: create fresh session
|
|
927
|
+
sid = new_sid()
|
|
928
|
+
|
|
929
|
+
session = UserSession(sid, {}, app=self)
|
|
930
|
+
session.refresh_session_cookie(self)
|
|
931
|
+
self.user_sessions[sid] = session
|
|
932
|
+
return session
|
|
933
|
+
|
|
934
|
+
if raw_cookie is not None and raw_cookie in self.user_sessions:
|
|
935
|
+
return self.user_sessions[raw_cookie]
|
|
936
|
+
|
|
937
|
+
# Server-backed store path
|
|
938
|
+
assert isinstance(self.session_store, SessionStore)
|
|
939
|
+
cookie_secure = self.cookie.secure
|
|
940
|
+
if cookie_secure is None:
|
|
941
|
+
raise RuntimeError(
|
|
942
|
+
"Cookie.secure is not resolved. Ensure App.setup() ran before sessions."
|
|
943
|
+
)
|
|
944
|
+
if raw_cookie is not None:
|
|
945
|
+
sid = raw_cookie
|
|
946
|
+
data = await self.session_store.get(sid) or await self.session_store.create(
|
|
947
|
+
sid
|
|
948
|
+
)
|
|
949
|
+
session = UserSession(sid, data, app=self)
|
|
950
|
+
session.set_cookie(
|
|
951
|
+
name=self.cookie.name,
|
|
952
|
+
value=sid,
|
|
953
|
+
domain=self.cookie.domain,
|
|
954
|
+
secure=cookie_secure,
|
|
955
|
+
samesite=self.cookie.samesite,
|
|
956
|
+
max_age_seconds=self.cookie.max_age_seconds,
|
|
957
|
+
)
|
|
958
|
+
else:
|
|
959
|
+
sid = new_sid()
|
|
960
|
+
data = await self.session_store.create(sid)
|
|
961
|
+
session = UserSession(
|
|
962
|
+
sid,
|
|
963
|
+
data,
|
|
964
|
+
app=self,
|
|
965
|
+
)
|
|
966
|
+
session.set_cookie(
|
|
967
|
+
name=self.cookie.name,
|
|
968
|
+
value=sid,
|
|
969
|
+
domain=self.cookie.domain,
|
|
970
|
+
secure=cookie_secure,
|
|
971
|
+
samesite=self.cookie.samesite,
|
|
972
|
+
max_age_seconds=self.cookie.max_age_seconds,
|
|
973
|
+
)
|
|
974
|
+
self.user_sessions[sid] = session
|
|
975
|
+
return session
|
|
976
|
+
|
|
977
|
+
def _get_render_for_session(
|
|
978
|
+
self, render_id: str | None, session: UserSession
|
|
979
|
+
) -> RenderSession | None:
|
|
980
|
+
"""
|
|
981
|
+
Get an existing render session for the given session, validating ownership.
|
|
982
|
+
Returns None if render_id is None, render doesn't exist, or doesn't belong to session.
|
|
983
|
+
"""
|
|
984
|
+
if not render_id:
|
|
985
|
+
return None
|
|
986
|
+
render = self.render_sessions.get(render_id)
|
|
987
|
+
if render is None:
|
|
988
|
+
return None
|
|
989
|
+
owner = self._render_to_user.get(render_id)
|
|
990
|
+
if owner != session.sid:
|
|
991
|
+
return None
|
|
992
|
+
return render
|
|
993
|
+
|
|
994
|
+
def create_render(
|
|
995
|
+
self, rid: str, session: UserSession, *, client_address: str | None = None
|
|
996
|
+
):
|
|
997
|
+
if rid in self.render_sessions:
|
|
998
|
+
raise ValueError(f"RenderSession {rid} already exists")
|
|
999
|
+
render = RenderSession(
|
|
1000
|
+
rid,
|
|
1001
|
+
self.routes,
|
|
1002
|
+
server_address=self.server_address,
|
|
1003
|
+
client_address=client_address,
|
|
1004
|
+
detach_queue_timeout=self.detach_queue_timeout,
|
|
1005
|
+
disconnect_queue_timeout=self.disconnect_queue_timeout,
|
|
1006
|
+
render_loop_limit=self.render_loop_limit,
|
|
1007
|
+
)
|
|
1008
|
+
self.render_sessions[rid] = render
|
|
1009
|
+
self._render_to_user[rid] = session.sid
|
|
1010
|
+
self._user_to_render[session.sid].append(rid)
|
|
1011
|
+
return render
|
|
1012
|
+
|
|
1013
|
+
def close_render(self, rid: str):
|
|
1014
|
+
# Cancel any pending cleanup task
|
|
1015
|
+
self._cancel_render_cleanup(rid)
|
|
1016
|
+
|
|
1017
|
+
render = self.render_sessions.pop(rid, None)
|
|
1018
|
+
if not render:
|
|
1019
|
+
return
|
|
1020
|
+
sid = self._render_to_user.pop(rid)
|
|
1021
|
+
session = self.user_sessions[sid]
|
|
1022
|
+
render.close()
|
|
1023
|
+
self._user_to_render[session.sid].remove(rid)
|
|
1024
|
+
|
|
1025
|
+
if len(self._user_to_render[session.sid]) == 0:
|
|
1026
|
+
later(60, self.close_session_if_inactive, sid)
|
|
1027
|
+
|
|
1028
|
+
def close_session(self, sid: str):
|
|
1029
|
+
session = self.user_sessions.pop(sid, None)
|
|
1030
|
+
self._user_to_render.pop(sid, None)
|
|
1031
|
+
if session:
|
|
1032
|
+
session.dispose()
|
|
1033
|
+
|
|
1034
|
+
def close_session_if_inactive(self, sid: str):
|
|
1035
|
+
if len(self._user_to_render[sid]) == 0:
|
|
1036
|
+
self.close_session(sid)
|
|
1037
|
+
|
|
1038
|
+
async def close(self):
|
|
1039
|
+
"""
|
|
1040
|
+
Close the app and clean up all sessions.
|
|
1041
|
+
This method is called automatically during shutdown.
|
|
1042
|
+
"""
|
|
1043
|
+
|
|
1044
|
+
# Cancel all pending cleanup tasks
|
|
1045
|
+
for rid in list(self._render_cleanups.keys()):
|
|
1046
|
+
self._cancel_render_cleanup(rid)
|
|
1047
|
+
|
|
1048
|
+
# Close all render sessions
|
|
1049
|
+
for rid in list(self.render_sessions.keys()):
|
|
1050
|
+
self.close_render(rid)
|
|
1051
|
+
|
|
1052
|
+
# Close all user sessions
|
|
1053
|
+
for sid in list(self.user_sessions.keys()):
|
|
1054
|
+
self.close_session(sid)
|
|
1055
|
+
|
|
1056
|
+
# Update status
|
|
1057
|
+
self.status = AppStatus.stopped
|
|
1058
|
+
# Call plugin on_shutdown hooks before closing
|
|
1059
|
+
for plugin in self.plugins:
|
|
1060
|
+
try:
|
|
1061
|
+
plugin.on_shutdown(self)
|
|
1062
|
+
except Exception:
|
|
1063
|
+
logger.exception("Error during plugin.on_shutdown()")
|
|
1064
|
+
|
|
1065
|
+
def refresh_cookies(self, sid: str):
|
|
1066
|
+
# If the session is currently inside an HTTP request, we don't need to schedule
|
|
1067
|
+
# set-cookies via WS; cookies will be attached on the HTTP response.
|
|
1068
|
+
if sid in self._sessions_in_request:
|
|
1069
|
+
return
|
|
1070
|
+
sess = self.user_sessions.get(sid)
|
|
1071
|
+
render_ids = self._user_to_render[sid]
|
|
1072
|
+
if not sess or len(render_ids) == 0:
|
|
1073
|
+
return
|
|
1074
|
+
|
|
1075
|
+
render = None
|
|
1076
|
+
for rid in render_ids:
|
|
1077
|
+
candidate = self.render_sessions[rid]
|
|
1078
|
+
if candidate.connected:
|
|
1079
|
+
render = candidate
|
|
1080
|
+
break
|
|
1081
|
+
if render is None:
|
|
1082
|
+
return # no active render for this user session
|
|
1083
|
+
|
|
1084
|
+
# We don't want to wait for this to resolve
|
|
1085
|
+
create_task(render.call_api(f"{self.api_prefix}/set-cookies", method="GET"))
|
|
1086
|
+
sess.scheduled_cookie_refresh = True
|