pulse-framework 0.1.38a2__tar.gz → 0.1.38a4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/app.py +36 -128
  4. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/cmd.py +79 -31
  5. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/env.py +9 -0
  6. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/proxy.py +13 -10
  7. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/README.md +0 -0
  8. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/__init__.py +0 -0
  9. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/channel.py +0 -0
  10. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/__init__.py +0 -0
  11. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/dependencies.py +0 -0
  12. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/folder_lock.py +0 -0
  13. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/helpers.py +0 -0
  14. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/models.py +0 -0
  15. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/packages.py +0 -0
  16. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/processes.py +0 -0
  17. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/secrets.py +0 -0
  18. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cli/uvicorn_log_config.py +0 -0
  19. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/__init__.py +0 -0
  20. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/codegen.py +0 -0
  21. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/imports.py +0 -0
  22. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/js.py +0 -0
  23. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/templates/__init__.py +0 -0
  24. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/templates/layout.py +0 -0
  25. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/templates/route.py +0 -0
  26. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/templates/routes_ts.py +0 -0
  27. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/codegen/utils.py +0 -0
  28. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/components/__init__.py +0 -0
  29. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/components/for_.py +0 -0
  30. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/components/if_.py +0 -0
  31. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/components/react_router.py +0 -0
  32. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/context.py +0 -0
  33. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/cookies.py +0 -0
  34. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/css.py +0 -0
  35. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/decorators.py +0 -0
  36. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/form.py +0 -0
  37. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/helpers.py +0 -0
  38. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/__init__.py +0 -0
  39. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/core.py +0 -0
  40. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/effects.py +0 -0
  41. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/runtime.py +0 -0
  42. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/setup.py +0 -0
  43. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/stable.py +0 -0
  44. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/hooks/states.py +0 -0
  45. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/__init__.py +0 -0
  46. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/elements.py +0 -0
  47. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/events.py +0 -0
  48. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/props.py +0 -0
  49. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/svg.py +0 -0
  50. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/tags.py +0 -0
  51. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/html/tags.pyi +0 -0
  52. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/messages.py +0 -0
  53. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/middleware.py +0 -0
  54. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/plugin.py +0 -0
  55. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/py.typed +0 -0
  56. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/query.py +0 -0
  57. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/react_component.py +0 -0
  58. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/reactive.py +0 -0
  59. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/reactive_extensions.py +0 -0
  60. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/render_session.py +0 -0
  61. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/renderer.py +0 -0
  62. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/request.py +0 -0
  63. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/routing.py +0 -0
  64. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/serializer.py +0 -0
  65. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/state.py +0 -0
  66. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/types/__init__.py +0 -0
  67. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/types/event_handler.py +0 -0
  68. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/user_session.py +0 -0
  69. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/vdom.py +0 -0
  70. {pulse_framework-0.1.38a2 → pulse_framework-0.1.38a4}/src/pulse/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.38a2
3
+ Version: 0.1.38a4
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.38a2"
3
+ version = "0.1.38a4"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,15 +5,12 @@ 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
9
8
  import logging
10
9
  import os
11
- import subprocess
12
10
  from collections import defaultdict
13
11
  from collections.abc import Awaitable, Sequence
14
12
  from contextlib import asynccontextmanager
15
13
  from enum import IntEnum
16
- from pathlib import Path
17
14
  from typing import Any, Callable, Literal, NotRequired, TypedDict, TypeVar, cast
18
15
 
19
16
  import socketio
@@ -38,7 +35,11 @@ from pulse.css import (
38
35
  registered_css_imports,
39
36
  registered_css_modules,
40
37
  )
41
- from pulse.env import ENV_PULSE_HOST, ENV_PULSE_PORT, PulseEnv
38
+ from pulse.env import (
39
+ ENV_PULSE_HOST,
40
+ ENV_PULSE_PORT,
41
+ PulseEnv,
42
+ )
42
43
  from pulse.env import env as envvars
43
44
  from pulse.helpers import (
44
45
  create_task,
@@ -225,10 +226,6 @@ class App:
225
226
  # Map websocket sid -> renderId for message routing
226
227
  self._socket_to_render = {}
227
228
 
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
-
232
229
  self.codegen = Codegen(
233
230
  self.routes,
234
231
  config=codegen or CodegenConfig(),
@@ -266,116 +263,26 @@ class App:
266
263
  for plugin in self.plugins:
267
264
  plugin.on_startup(self)
268
265
 
269
- # Start React Router server in single-server mode
270
- logger.info(f"Deployment mode: {self.mode}")
271
266
  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)
267
+ react_server_address = envvars.react_server_address
268
+ if react_server_address:
277
269
  logger.info(
278
- f"Single-server mode: React Router running on internal port {port}"
270
+ f"Single-server mode: React Router running at {react_server_address}"
271
+ )
272
+ else:
273
+ logger.warning(
274
+ "Single-server mode: PULSE_REACT_SERVER_ADDRESS not set."
279
275
  )
280
- except Exception:
281
- logger.exception("Failed to start React Router server")
282
- raise
283
276
 
284
277
  try:
285
278
  yield
286
279
  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
280
  try:
295
281
  if isinstance(self.session_store, SessionStore):
296
282
  await self.session_store.close()
297
283
  except Exception:
298
284
  logger.exception("Error during SessionStore.close()")
299
285
 
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
-
379
286
  def run_codegen(
380
287
  self, address: str | None = None, internal_address: str | None = None
381
288
  ):
@@ -426,10 +333,11 @@ class App:
426
333
  self.setup(server_address)
427
334
  self.status = AppStatus.running
428
335
 
429
- # Wrap with proxy in single-server mode. Do it here instead of within
430
- # __init__ to allow the CLI to override the mode.
336
+ # In single-server mode, the Pulse server acts as reverse proxy to the React server
431
337
  if self.mode == "single-server":
432
- return PulseProxy(self.asgi, lambda: self.web_server_port, self.api_prefix)
338
+ return PulseProxy(
339
+ self.asgi, lambda: envvars.react_server_address, self.api_prefix
340
+ )
433
341
  return self.asgi
434
342
 
435
343
  def run(
@@ -471,26 +379,26 @@ class App:
471
379
  )
472
380
 
473
381
  # 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
382
+ # @self.fastapi.middleware("http")
383
+ # async def cors_debug_middleware( # pyright: ignore[reportUnusedFunction]
384
+ # request: Request, call_next: Callable[[Request], Awaitable[Response]]
385
+ # ):
386
+ # origin = request.headers.get("origin")
387
+ # method = request.method
388
+ # path = request.url.path
389
+ # print(
390
+ # f"[CORS Debug] {method} {path} | Origin: {origin} | "
391
+ # + f"Mode: {self.mode} | Server: {self.server_address}"
392
+ # )
393
+ # response = await call_next(request)
394
+ # allow_origin = response.headers.get("access-control-allow-origin")
395
+ # if allow_origin:
396
+ # print(f"[CORS Debug] Response allows origin: {allow_origin}")
397
+ # elif origin:
398
+ # logger.warning(
399
+ # f"[CORS Debug] Origin {origin} present but no Access-Control-Allow-Origin header set"
400
+ # )
401
+ # return response
494
402
 
495
403
  # Mount PulseContext for all FastAPI routes (no route info). Other API
496
404
  # routes / middleware should be added at the module-level, which means
@@ -34,6 +34,7 @@ from pulse.env import (
34
34
  ENV_PULSE_DISABLE_CODEGEN,
35
35
  ENV_PULSE_HOST,
36
36
  ENV_PULSE_PORT,
37
+ ENV_PULSE_REACT_SERVER_ADDRESS,
37
38
  ENV_PULSE_SECRET,
38
39
  PulseEnv,
39
40
  env,
@@ -69,8 +70,16 @@ def run(
69
70
  prod: bool = typer.Option(False, "--prod", help="Run in production env"),
70
71
  server_only: bool = typer.Option(False, "--server-only", "--backend-only"),
71
72
  web_only: bool = typer.Option(False, "--web-only"),
73
+ react_server_address: str | None = typer.Option(
74
+ None,
75
+ "--react-server-address",
76
+ help="Full URL of React server (required for single-server + --server-only)",
77
+ ),
72
78
  reload: bool | None = typer.Option(None, "--reload/--no-reload"),
73
79
  find_port: bool = typer.Option(True, "--find-port/--no-find-port"),
80
+ verbose: bool = typer.Option(
81
+ False, "--verbose", help="Show all logs without filtering"
82
+ ),
74
83
  ):
75
84
  """Run the Pulse server and web development server together."""
76
85
  extra_flags = list(ctx.args)
@@ -106,14 +115,19 @@ def run(
106
115
  _apply_app_context_to_env(app_ctx)
107
116
  app_instance = app_ctx.app
108
117
 
109
- # Override mode to subdomains if server-only (single-server + server-only makes no sense)
110
- if server_only and app_instance.mode == "single-server":
111
- app_instance.mode = "subdomains"
112
-
113
118
  is_single_server = app_instance.mode == "single-server"
114
119
  if is_single_server:
115
120
  console.log("🔧 [cyan]Single-server mode[/cyan]")
116
121
 
122
+ # In single-server + server-only mode, require explicit React server address
123
+ if is_single_server and server_only:
124
+ if not react_server_address:
125
+ typer.echo(
126
+ "❌ --react-server-address is required when using single-server mode with --server-only."
127
+ )
128
+ raise typer.Exit(1)
129
+ os.environ[ENV_PULSE_REACT_SERVER_ADDRESS] = react_server_address
130
+
117
131
  web_root = app_instance.codegen.cfg.web_root
118
132
  if not web_root.exists() and not server_only:
119
133
  console.log(f"❌ Directory not found: {web_root.absolute()}")
@@ -125,37 +139,54 @@ def run(
125
139
  web_root if web_root.exists() else app_ctx.app_file
126
140
  )
127
141
 
128
- if not server_only:
129
- # Skip dependency checking and installation in production/CI mode (assumes pre-built)
130
- if env.pulse_env == "dev":
142
+ if env.pulse_env == "dev" and not server_only:
143
+ try:
144
+ to_add = check_web_dependencies(
145
+ web_root,
146
+ pulse_version=PULSE_PY_VERSION,
147
+ )
148
+ except DependencyResolutionError as exc:
149
+ console.log(f"❌ {exc}")
150
+ raise typer.Exit(1) from None
151
+ except DependencyError as exc:
152
+ console.log(f"❌ {exc}")
153
+ raise typer.Exit(1) from None
154
+
155
+ if to_add:
131
156
  try:
132
- to_add = check_web_dependencies(
157
+ dep_plan = prepare_web_dependencies(
133
158
  web_root,
134
159
  pulse_version=PULSE_PY_VERSION,
135
160
  )
136
- except DependencyResolutionError as exc:
137
- console.log(f"❌ {exc}")
161
+ if dep_plan:
162
+ _run_dependency_plan(console, web_root, dep_plan)
163
+ except subprocess.CalledProcessError:
164
+ console.log("❌ Failed to install web dependencies with Bun.")
138
165
  raise typer.Exit(1) from None
139
- except DependencyError as exc:
140
- console.log(f"❌ {exc}")
141
- raise typer.Exit(1) from None
142
-
143
- if to_add:
144
- try:
145
- dep_plan = prepare_web_dependencies(
146
- web_root,
147
- pulse_version=PULSE_PY_VERSION,
148
- )
149
- if dep_plan:
150
- _run_dependency_plan(console, web_root, dep_plan)
151
- except subprocess.CalledProcessError:
152
- console.log("❌ Failed to install web dependencies with Bun.")
153
- raise typer.Exit(1) from None
154
166
 
155
167
  server_args = extra_flags if not web_only else []
156
168
  web_args = extra_flags if web_only else []
157
169
 
158
170
  commands: list[CommandSpec] = []
171
+ # Build web command first (when needed) so we can set PULSE_REACT_SERVER_ADDRESS
172
+ # before building the uvicorn command, which needs that env var
173
+ if not server_only:
174
+ if is_single_server:
175
+ web_port = find_available_port(5173)
176
+ commands.append(
177
+ build_web_command(
178
+ web_root=web_root,
179
+ extra_args=web_args,
180
+ port=web_port,
181
+ mode=app_instance.env,
182
+ )
183
+ )
184
+ # Set env var so app can read the React server address
185
+ react_server_address = f"http://localhost:{web_port}"
186
+ os.environ[ENV_PULSE_REACT_SERVER_ADDRESS] = react_server_address
187
+ else:
188
+ commands.append(build_web_command(web_root=web_root, extra_args=web_args))
189
+
159
190
  if not web_only:
160
191
  commands.append(
161
192
  build_uvicorn_command(
@@ -169,12 +200,10 @@ def run(
169
200
  console=console,
170
201
  web_root=web_root,
171
202
  announce_url=is_single_server,
203
+ verbose=verbose,
172
204
  )
173
205
  )
174
206
 
175
- if not is_single_server and not server_only:
176
- commands.append(build_web_command(web_root=web_root, extra_args=web_args))
177
-
178
207
  tag_colors = {"server": "cyan", "web": "orange1"}
179
208
 
180
209
  with FolderLock(web_root):
@@ -301,6 +330,7 @@ def build_uvicorn_command(
301
330
  console: Console,
302
331
  web_root: Path,
303
332
  announce_url: bool,
333
+ verbose: bool = False,
304
334
  ) -> CommandSpec:
305
335
  app_import = f"{app_ctx.module_name}:{app_ctx.app_var}.asgi_factory"
306
336
  args: list[str] = [
@@ -338,6 +368,11 @@ def build_uvicorn_command(
338
368
  ENV_PULSE_PORT: str(port),
339
369
  }
340
370
  )
371
+ # Pass React server address to uvicorn process if set
372
+ if ENV_PULSE_REACT_SERVER_ADDRESS in os.environ:
373
+ command_env[ENV_PULSE_REACT_SERVER_ADDRESS] = os.environ[
374
+ ENV_PULSE_REACT_SERVER_ADDRESS
375
+ ]
341
376
  if app_ctx.app.env == "prod" and server_only:
342
377
  command_env[ENV_PULSE_DISABLE_CODEGEN] = "1"
343
378
  if dev_secret:
@@ -346,7 +381,7 @@ def build_uvicorn_command(
346
381
  cwd = app_ctx.server_cwd or app_ctx.app_dir or Path.cwd()
347
382
 
348
383
  # Apply custom log config to filter noisy requests (dev/ci only)
349
- if app_ctx.app.env != "prod":
384
+ if app_ctx.app.env != "prod" and not verbose:
350
385
  import json
351
386
  import tempfile
352
387
 
@@ -374,8 +409,21 @@ def build_uvicorn_command(
374
409
  )
375
410
 
376
411
 
377
- def build_web_command(*, web_root: Path, extra_args: Sequence[str]) -> CommandSpec:
378
- args = ["bun", "run", "dev"]
412
+ def build_web_command(
413
+ *,
414
+ web_root: Path,
415
+ extra_args: Sequence[str],
416
+ port: int | None = None,
417
+ mode: PulseEnv = "dev",
418
+ ) -> CommandSpec:
419
+ if mode == "prod":
420
+ # Production: use built server
421
+ args = ["bun", "run", "start"]
422
+ else:
423
+ # Development: use dev server
424
+ args = ["bun", "run", "dev"]
425
+ if port is not None:
426
+ args.extend(["--port", str(port)])
379
427
  if extra_args:
380
428
  args.extend(extra_args)
381
429
 
@@ -25,6 +25,7 @@ ENV_PULSE_APP_FILE = "PULSE_APP_FILE"
25
25
  ENV_PULSE_APP_DIR = "PULSE_APP_DIR"
26
26
  ENV_PULSE_HOST = "PULSE_HOST"
27
27
  ENV_PULSE_PORT = "PULSE_PORT"
28
+ ENV_PULSE_REACT_SERVER_ADDRESS = "PULSE_REACT_SERVER_ADDRESS"
28
29
  ENV_PULSE_SECRET = "PULSE_SECRET"
29
30
  ENV_PULSE_DISABLE_CODEGEN = "PULSE_DISABLE_CODEGEN"
30
31
 
@@ -87,6 +88,14 @@ class EnvVars:
87
88
  def pulse_port(self, value: int) -> None:
88
89
  self._set(ENV_PULSE_PORT, str(value))
89
90
 
91
+ @property
92
+ def react_server_address(self) -> str | None:
93
+ return self._get(ENV_PULSE_REACT_SERVER_ADDRESS)
94
+
95
+ @react_server_address.setter
96
+ def react_server_address(self, value: str | None) -> None:
97
+ self._set(ENV_PULSE_REACT_SERVER_ADDRESS, value)
98
+
90
99
  # Secrets
91
100
  @property
92
101
  def pulse_secret(self) -> str | None:
@@ -24,7 +24,7 @@ class PulseProxy:
24
24
  def __init__(
25
25
  self,
26
26
  app: ASGIApp,
27
- get_web_port: Callable[[], int | None],
27
+ get_react_server_address: Callable[[], str | None],
28
28
  api_prefix: str = "/_pulse",
29
29
  ):
30
30
  """
@@ -32,11 +32,13 @@ class PulseProxy:
32
32
 
33
33
  Args:
34
34
  app: The ASGI application to wrap (socketio.ASGIApp)
35
- get_web_port: Callable that returns the React Router port (or None if not started)
35
+ get_react_server_address: Callable that returns the React Router server full URL (or None if not started)
36
36
  api_prefix: Prefix for API routes that should NOT be proxied (default: "/_pulse")
37
37
  """
38
38
  self.app: ASGIApp = app
39
- self.get_web_port: Callable[[], int | None] = get_web_port
39
+ self.get_react_server_address: Callable[[], str | None] = (
40
+ get_react_server_address
41
+ )
40
42
  self.api_prefix: str = api_prefix
41
43
  self._client: httpx.AsyncClient | None = None
42
44
 
@@ -84,10 +86,10 @@ class PulseProxy:
84
86
  """
85
87
  Forward HTTP request to React Router server and stream response back.
86
88
  """
87
- # Get the web server port
88
- port = self.get_web_port()
89
- if port is None:
90
- # Web server not started yet, return error
89
+ # Get the React server address
90
+ react_server_address = self.get_react_server_address()
91
+ if react_server_address is None:
92
+ # React server not started yet, return error
91
93
  await send(
92
94
  {
93
95
  "type": "http.response.start",
@@ -98,7 +100,7 @@ class PulseProxy:
98
100
  await send(
99
101
  {
100
102
  "type": "http.response.body",
101
- "body": b"Service Unavailable: Web server not ready",
103
+ "body": b"Service Unavailable: React server not ready",
102
104
  }
103
105
  )
104
106
  return
@@ -106,8 +108,9 @@ class PulseProxy:
106
108
  # Build target URL
107
109
  path = scope["path"]
108
110
  query_string = scope.get("query_string", b"").decode("utf-8")
109
- target_url = f"http://localhost:{port}"
110
- target_path = f"{target_url}{path}"
111
+ # Ensure react_server_address doesn't end with /
112
+ base_url = react_server_address.rstrip("/")
113
+ target_path = f"{base_url}{path}"
111
114
  if query_string:
112
115
  target_path += f"?{query_string}"
113
116