pulse-framework 0.1.37__py3-none-any.whl → 0.1.38a2__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 CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  # Core app/session
19
19
  from pulse.app import App as App
20
- from pulse.app import DeploymentMode as DeploymentMode
20
+ from pulse.app import PulseMode as PulseMode
21
21
  from pulse.channel import (
22
22
  Channel as Channel,
23
23
  )
@@ -72,7 +72,7 @@ from pulse.decorators import effect as effect
72
72
  from pulse.decorators import query as query
73
73
 
74
74
  # Environment
75
- from pulse.env import PulseMode as PulseMode
75
+ from pulse.env import PulseEnv as PulseEnv
76
76
  from pulse.env import env as env
77
77
  from pulse.env import mode as mode
78
78
 
pulse/app.py CHANGED
@@ -5,17 +5,23 @@ This module provides the main App class that users instantiate in their main.py
5
5
  to define routes and configure their Pulse application.
6
6
  """
7
7
 
8
+ import asyncio
8
9
  import logging
10
+ import os
11
+ import subprocess
9
12
  from collections import defaultdict
10
13
  from collections.abc import Awaitable, Sequence
11
14
  from contextlib import asynccontextmanager
12
15
  from enum import IntEnum
16
+ from pathlib import Path
13
17
  from typing import Any, Callable, Literal, NotRequired, TypedDict, TypeVar, cast
14
18
 
15
19
  import socketio
20
+ import uvicorn
16
21
  from fastapi import FastAPI, HTTPException, Request, Response
17
22
  from fastapi.middleware.cors import CORSMiddleware
18
23
  from fastapi.responses import JSONResponse
24
+ from starlette.types import ASGIApp
19
25
 
20
26
  from pulse.codegen.codegen import Codegen, CodegenConfig
21
27
  from pulse.context import PULSE_CONTEXT, PulseContext
@@ -32,15 +38,14 @@ from pulse.css import (
32
38
  registered_css_imports,
33
39
  registered_css_modules,
34
40
  )
35
- from pulse.env import PulseMode, env
41
+ from pulse.env import ENV_PULSE_HOST, ENV_PULSE_PORT, PulseEnv
42
+ from pulse.env import env as envvars
36
43
  from pulse.helpers import (
37
44
  create_task,
38
- ensure_web_lock,
45
+ find_available_port,
39
46
  get_client_address,
40
47
  get_client_address_socketio,
41
48
  later,
42
- lock_path_for_web_root,
43
- remove_web_lock,
44
49
  )
45
50
  from pulse.hooks.core import hooks
46
51
  from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
@@ -63,6 +68,7 @@ from pulse.middleware import (
63
68
  Redirect,
64
69
  )
65
70
  from pulse.plugin import Plugin
71
+ from pulse.proxy import PulseProxy
66
72
  from pulse.react_component import ReactComponent, registered_react_components
67
73
  from pulse.render_session import RenderSession
68
74
  from pulse.request import PulseRequest
@@ -87,7 +93,7 @@ class AppStatus(IntEnum):
87
93
  stopped = 3
88
94
 
89
95
 
90
- DeploymentMode = Literal["dev", "same_host", "subdomains"]
96
+ PulseMode = Literal["subdomains", "single-server"]
91
97
 
92
98
 
93
99
  class PrerenderPayload(TypedDict):
@@ -120,11 +126,13 @@ class App:
120
126
  ```
121
127
  """
122
128
 
129
+ env: PulseEnv
123
130
  mode: PulseMode
124
- deployment: DeploymentMode
125
131
  status: AppStatus
126
132
  server_address: str | None
133
+ dev_server_address: str
127
134
  internal_server_address: str | None
135
+ api_prefix: str
128
136
  plugins: list[Plugin]
129
137
  routes: RouteTree
130
138
  not_found: str
@@ -135,8 +143,8 @@ class App:
135
143
  cors: CORSOptions | None
136
144
  codegen: Codegen
137
145
  fastapi: FastAPI
138
- sio: socketio.AsyncServer # type: ignore
139
- asgi: socketio.ASGIApp # type: ignore
146
+ sio: socketio.AsyncServer
147
+ asgi: ASGIApp
140
148
  middleware: MiddlewareStack
141
149
  _user_to_render: dict[str, list[str]]
142
150
  _render_to_user: dict[str, str]
@@ -153,11 +161,12 @@ class App:
153
161
  cookie: Cookie | None = None,
154
162
  session_store: SessionStore | None = None,
155
163
  server_address: str | None = None,
164
+ dev_server_address: str = "http://localhost:8000",
156
165
  internal_server_address: str | None = None,
157
166
  not_found: str = "/not-found",
158
167
  # Deployment and integration options
159
- mode: PulseMode | None = None,
160
- deployment: DeploymentMode = "subdomains",
168
+ mode: PulseMode = "single-server",
169
+ api_prefix: str = "/_pulse",
161
170
  cors: CORSOptions | None = None,
162
171
  fastapi: dict[str, Any] | None = None,
163
172
  ):
@@ -169,14 +178,18 @@ class App:
169
178
  codegen: Optional codegen configuration.
170
179
  """
171
180
  # Resolve mode from environment and expose on the app instance
172
- self.mode = mode or env.pulse_mode
173
- self.deployment = "dev" if self.mode == "dev" else deployment
181
+ self.env = envvars.pulse_env
182
+ self.mode = mode
174
183
  self.status = AppStatus.created
175
184
  # Persist the server address for use by sessions (API calls, etc.)
176
185
  self.server_address = server_address
186
+ # Development server address (used in dev mode)
187
+ self.dev_server_address = dev_server_address
177
188
  # Optional internal address used by server-side loader fetches
178
189
  self.internal_server_address = internal_server_address
179
190
 
191
+ self.api_prefix = api_prefix
192
+
180
193
  # Resolve and store plugins (sorted by priority, highest first)
181
194
  self.plugins = []
182
195
  if plugins:
@@ -189,7 +202,7 @@ class App:
189
202
  # Add plugin routes after user-defined routes
190
203
  for plugin in self.plugins:
191
204
  all_routes.extend(plugin.routes())
192
- if self.mode == "dev":
205
+ if self.env == "dev":
193
206
  all_routes.extend(plugin.dev_routes())
194
207
 
195
208
  # Auto-add React components to all routes
@@ -203,7 +216,7 @@ class App:
203
216
  self.user_sessions = {}
204
217
  self.render_sessions = {}
205
218
  self.session_store = session_store or CookieSessionStore()
206
- self.cookie = cookie or session_cookie(mode=self.deployment)
219
+ self.cookie = cookie or session_cookie(mode=self.mode)
207
220
  self.cors = cors
208
221
 
209
222
  self._user_to_render = defaultdict(list)
@@ -212,56 +225,22 @@ class App:
212
225
  # Map websocket sid -> renderId for message routing
213
226
  self._socket_to_render = {}
214
227
 
228
+ # Subprocess management for single-server mode
229
+ self.web_server_proc: subprocess.Popen[str] | None = None
230
+ self.web_server_port: int | None = None
231
+
215
232
  self.codegen = Codegen(
216
233
  self.routes,
217
234
  config=codegen or CodegenConfig(),
218
235
  )
219
236
 
220
- @asynccontextmanager
221
- async def lifespan(_: FastAPI):
222
- try:
223
- if isinstance(self.session_store, SessionStore):
224
- await self.session_store.init()
225
- except Exception:
226
- logger.exception("Error during SessionStore.init()")
227
- # Create a lock file in the web project (unless the CLI manages it)
228
- lock_path = None
229
- try:
230
- if not env.lock_managed_by_cli:
231
- try:
232
- lock_path = lock_path_for_web_root(self.codegen.cfg.web_root)
233
- __ = ensure_web_lock(lock_path, owner="server")
234
- except RuntimeError as e:
235
- logger.error(str(e))
236
- raise
237
- except Exception:
238
- logger.exception("Failed to create Pulse dev lock file")
239
- raise
240
- # Call plugin on_startup hooks before serving
241
- for plugin in self.plugins:
242
- plugin.on_startup(self)
243
- try:
244
- yield
245
- finally:
246
- try:
247
- if isinstance(self.session_store, SessionStore):
248
- await self.session_store.close()
249
- except Exception:
250
- logger.exception("Error during SessionStore.close()")
251
- # Remove lock if we created it
252
- try:
253
- if not env.lock_managed_by_cli and lock_path:
254
- remove_web_lock(lock_path)
255
- except Exception:
256
- # Best-effort
257
- pass
258
-
259
237
  self.fastapi = FastAPI(
260
238
  title="Pulse UI Server",
261
- lifespan=lifespan,
239
+ lifespan=self.fastapi_lifespan,
262
240
  )
263
241
  self.sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
264
242
  self.asgi = socketio.ASGIApp(self.sio, self.fastapi)
243
+
265
244
  if middleware is None:
266
245
  mw_stack: list[PulseMiddleware] = [PulseCoreMiddleware()]
267
246
  elif isinstance(middleware, PulseMiddleware):
@@ -275,11 +254,133 @@ class App:
275
254
 
276
255
  self.middleware = MiddlewareStack(mw_stack)
277
256
 
257
+ @asynccontextmanager
258
+ async def fastapi_lifespan(self, _: FastAPI):
259
+ try:
260
+ if isinstance(self.session_store, SessionStore):
261
+ await self.session_store.init()
262
+ except Exception:
263
+ logger.exception("Error during SessionStore.init()")
264
+
265
+ # Call plugin on_startup hooks before serving
266
+ for plugin in self.plugins:
267
+ plugin.on_startup(self)
268
+
269
+ # Start React Router server in single-server mode
270
+ logger.info(f"Deployment mode: {self.mode}")
271
+ if self.mode == "single-server":
272
+ logger.info("Starting React Router server in single-server mode...")
273
+ try:
274
+ web_root = self.codegen.cfg.web_root
275
+ logger.info(f"Web root: {web_root}")
276
+ port = await self.start_web_server(web_root, self.env)
277
+ logger.info(
278
+ f"Single-server mode: React Router running on internal port {port}"
279
+ )
280
+ except Exception:
281
+ logger.exception("Failed to start React Router server")
282
+ raise
283
+
284
+ try:
285
+ yield
286
+ finally:
287
+ # Stop React Router server in single-server mode
288
+ if self.mode == "single-server":
289
+ try:
290
+ self.stop_web_server()
291
+ except Exception:
292
+ logger.exception("Error stopping React Router server")
293
+
294
+ try:
295
+ if isinstance(self.session_store, SessionStore):
296
+ await self.session_store.close()
297
+ except Exception:
298
+ logger.exception("Error during SessionStore.close()")
299
+
300
+ async def start_web_server(self, web_root: Path, mode: str) -> int:
301
+ """Start React Router server as subprocess and return its port."""
302
+
303
+ # Find available port
304
+ port = find_available_port(5173)
305
+
306
+ # Build command based on mode
307
+ if mode == "prod":
308
+ # Check if build exists
309
+ build_server = web_root / "build" / "server" / "index.js"
310
+ if not build_server.exists():
311
+ raise RuntimeError(
312
+ f"Production build not found at {build_server}. Run 'bun run build' in the web directory first."
313
+ )
314
+ cli_path = (
315
+ web_root
316
+ / "node_modules"
317
+ / "@react-router"
318
+ / "serve"
319
+ / "dist"
320
+ / "cli.js"
321
+ )
322
+ if not cli_path.exists():
323
+ raise RuntimeError(
324
+ f"React Router CLI not found at {cli_path}. Did you install web dependencies?"
325
+ )
326
+ # Production: use Node to serve the built bundle (Bun lacks renderToPipeableStream)
327
+ cmd = [
328
+ "bun",
329
+ "run",
330
+ "start",
331
+ "--port",
332
+ str(port),
333
+ ]
334
+ else:
335
+ # Development: use dev server
336
+ cmd = ["bun", "run", "dev", "--port", str(port)]
337
+
338
+ logger.info(f"Starting React Router server: {' '.join(cmd)}")
339
+
340
+ proc = subprocess.Popen(
341
+ cmd,
342
+ cwd=web_root,
343
+ stdout=subprocess.PIPE,
344
+ stderr=subprocess.STDOUT,
345
+ env=os.environ.copy(),
346
+ text=True,
347
+ )
348
+
349
+ # Wait for server to be ready
350
+ await asyncio.sleep(2.0 if mode == "prod" else 1.0)
351
+
352
+ # Check if process is still running
353
+ if proc.poll() is not None:
354
+ output = proc.stdout.read() if proc.stdout else ""
355
+ raise RuntimeError(f"React Router server failed to start: {output}")
356
+
357
+ self.web_server_proc = proc
358
+ self.web_server_port = port
359
+
360
+ logger.info(f"React Router server started on port {port}")
361
+ return port
362
+
363
+ def stop_web_server(self):
364
+ """Stop the React Router subprocess."""
365
+ if self.web_server_proc:
366
+ logger.info("Stopping React Router server...")
367
+ self.web_server_proc.terminate()
368
+ try:
369
+ self.web_server_proc.wait(timeout=5)
370
+ except subprocess.TimeoutExpired:
371
+ logger.warning(
372
+ "React Router server did not stop gracefully, killing..."
373
+ )
374
+ self.web_server_proc.kill()
375
+ self.web_server_proc.wait()
376
+ self.web_server_proc = None
377
+ self.web_server_port = None
378
+
278
379
  def run_codegen(
279
380
  self, address: str | None = None, internal_address: str | None = None
280
381
  ):
281
382
  # Allow the CLI to disable codegen in specific scenarios (e.g., prod server-only)
282
- if env.codegen_disabled:
383
+ if envvars.codegen_disabled:
283
384
  return
284
385
  if address:
285
386
  self.server_address = address
@@ -292,6 +393,7 @@ class App:
292
393
  self.codegen.generate_all(
293
394
  self.server_address,
294
395
  self.internal_server_address or self.server_address,
396
+ self.api_prefix,
295
397
  )
296
398
 
297
399
  def asgi_factory(self):
@@ -299,28 +401,49 @@ class App:
299
401
  ASGI factory for uvicorn. This is called on every reload.
300
402
  """
301
403
 
302
- # In prod, prefer the public server address passed to App(...).
303
- # In dev/ci, derive from environment variables which the CLI populates
304
- # based on the actual bind host/port.
305
- if self.mode == "prod":
404
+ # In prod/ci, use the server_address provided to App(...).
405
+ if self.env in ("prod", "ci"):
306
406
  if not self.server_address:
307
407
  raise RuntimeError(
308
- "In prod, please provide an explicit server_address to App(...)."
408
+ f"In {self.env}, please provide an explicit server_address to App(...)."
309
409
  )
310
410
  server_address = self.server_address
411
+ # In dev, prefer env vars set by CLI (--address/--port), otherwise use dev_server_address.
311
412
  else:
312
- host = env.pulse_host # defaults to "localhost"
313
- port = env.pulse_port # defaults to 8000
314
- protocol = "http" if host in ("127.0.0.1", "localhost") else "https"
315
- server_address = f"{protocol}://{host}:{port}"
413
+ # In dev mode, check if CLI set PULSE_HOST/PULSE_PORT env vars
414
+ # If env vars were explicitly set (not just defaults), use them
415
+ host = os.environ.get(ENV_PULSE_HOST)
416
+ port = os.environ.get(ENV_PULSE_PORT)
417
+ if host is not None and port is not None:
418
+ protocol = "http" if host in ("127.0.0.1", "localhost") else "https"
419
+ server_address = f"{protocol}://{host}:{port}"
420
+ else:
421
+ server_address = self.dev_server_address
316
422
 
317
423
  # Use internal server address for server-side loader if provided; fallback to public
318
424
  internal_address = self.internal_server_address or server_address
319
425
  self.run_codegen(server_address, internal_address)
320
426
  self.setup(server_address)
321
427
  self.status = AppStatus.running
428
+
429
+ # Wrap with proxy in single-server mode. Do it here instead of within
430
+ # __init__ to allow the CLI to override the mode.
431
+ if self.mode == "single-server":
432
+ return PulseProxy(self.asgi, lambda: self.web_server_port, self.api_prefix)
322
433
  return self.asgi
323
434
 
435
+ def run(
436
+ self,
437
+ address: str = "localhost",
438
+ port: int = 8000,
439
+ find_port: bool = True,
440
+ reload: bool = True,
441
+ ):
442
+ if find_port:
443
+ port = find_available_port(port)
444
+
445
+ uvicorn.run(self.asgi_factory, reload=reload)
446
+
324
447
  def setup(self, server_address: str):
325
448
  if self.status >= AppStatus.initialized:
326
449
  logger.warning("Called App.setup() on an already initialized application")
@@ -333,19 +456,42 @@ class App:
333
456
 
334
457
  # Compute cookie domain from deployment/server address if not explicitly provided
335
458
  if self.cookie.domain is None:
336
- self.cookie.domain = compute_cookie_domain(
337
- self.deployment, self.server_address
338
- )
459
+ self.cookie.domain = compute_cookie_domain(self.mode, self.server_address)
339
460
 
340
461
  # Add CORS middleware (configurable/overridable)
341
462
  if self.cors is not None:
342
463
  self.fastapi.add_middleware(CORSMiddleware, **self.cors)
343
464
  else:
465
+ # Use deployment-specific CORS settings
466
+ cors_config = cors_options(self.mode, self.server_address)
467
+ print(f"CORS config: {cors_config}")
344
468
  self.fastapi.add_middleware(
345
469
  CORSMiddleware,
346
- **cors_options(self.deployment, self.server_address),
470
+ **cors_config,
347
471
  )
348
472
 
473
+ # Debug middleware to log CORS-related request details
474
+ @self.fastapi.middleware("http")
475
+ async def cors_debug_middleware( # pyright: ignore[reportUnusedFunction]
476
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
477
+ ):
478
+ origin = request.headers.get("origin")
479
+ method = request.method
480
+ path = request.url.path
481
+ print(
482
+ f"[CORS Debug] {method} {path} | Origin: {origin} | "
483
+ + f"Mode: {self.mode} | Server: {self.server_address}"
484
+ )
485
+ response = await call_next(request)
486
+ allow_origin = response.headers.get("access-control-allow-origin")
487
+ if allow_origin:
488
+ print(f"[CORS Debug] Response allows origin: {allow_origin}")
489
+ elif origin:
490
+ logger.warning(
491
+ f"[CORS Debug] Origin {origin} present but no Access-Control-Allow-Origin header set"
492
+ )
493
+ return response
494
+
349
495
  # Mount PulseContext for all FastAPI routes (no route info). Other API
350
496
  # routes / middleware should be added at the module-level, which means
351
497
  # this middleware will wrap all of them.
@@ -362,9 +508,9 @@ class App:
362
508
  self._sessions_in_request[session.sid] = (
363
509
  self._sessions_in_request.get(session.sid, 0) + 1
364
510
  )
365
- header_sid = request.headers.get("x-pulse-render-id")
366
- if header_sid:
367
- render = self.render_sessions.get(header_sid)
511
+ render_id = request.headers.get("x-pulse-render-id")
512
+ if render_id:
513
+ render = self.render_sessions.get(render_id)
368
514
  else:
369
515
  render = None
370
516
  with PulseContext.update(session=session, render=render):
@@ -377,16 +523,19 @@ class App:
377
523
 
378
524
  return res
379
525
 
380
- @self.fastapi.get("/health")
526
+ # Apply prefix to all routes
527
+ prefix = self.api_prefix
528
+
529
+ @self.fastapi.get(f"{prefix}/health")
381
530
  def healthcheck(): # pyright: ignore[reportUnusedFunction]
382
531
  return {"health": "ok", "message": "Pulse server is running"}
383
532
 
384
- @self.fastapi.get("/set-cookies")
533
+ @self.fastapi.get(f"{prefix}/set-cookies")
385
534
  def set_cookies(): # pyright: ignore[reportUnusedFunction]
386
535
  return {"health": "ok", "message": "Cookies updated"}
387
536
 
388
537
  # RouteInfo is the request body
389
- @self.fastapi.post("/prerender")
538
+ @self.fastapi.post(f"{prefix}/prerender")
390
539
  async def prerender(payload: PrerenderPayload, request: Request): # pyright: ignore[reportUnusedFunction]
391
540
  """
392
541
  POST /prerender
@@ -492,7 +641,7 @@ class App:
492
641
  session.handle_response(resp)
493
642
  return resp
494
643
 
495
- @self.fastapi.post("/pulse/forms/{render_id}/{form_id}")
644
+ @self.fastapi.post(f"{prefix}/forms/{{render_id}}/{{form_id}}")
496
645
  async def handle_form_submit( # pyright: ignore[reportUnusedFunction]
497
646
  render_id: str, form_id: str, request: Request
498
647
  ) -> Response: