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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. 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