pulse-framework 0.1.38a1__tar.gz → 0.1.38a3__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.38a1 → pulse_framework-0.1.38a3}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/app.py +42 -111
  4. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/cmd.py +133 -15
  5. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/dependencies.py +87 -4
  6. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cookies.py +5 -0
  7. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/env.py +10 -2
  8. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/proxy.py +13 -10
  9. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/README.md +0 -0
  10. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/__init__.py +0 -0
  11. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/channel.py +0 -0
  12. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/__init__.py +0 -0
  13. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/folder_lock.py +0 -0
  14. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/helpers.py +0 -0
  15. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/models.py +0 -0
  16. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/packages.py +0 -0
  17. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/processes.py +0 -0
  18. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/secrets.py +0 -0
  19. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/cli/uvicorn_log_config.py +0 -0
  20. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/__init__.py +0 -0
  21. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/codegen.py +0 -0
  22. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/imports.py +0 -0
  23. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/js.py +0 -0
  24. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/templates/__init__.py +0 -0
  25. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/templates/layout.py +0 -0
  26. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/templates/route.py +0 -0
  27. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/templates/routes_ts.py +0 -0
  28. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/codegen/utils.py +0 -0
  29. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/components/__init__.py +0 -0
  30. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/components/for_.py +0 -0
  31. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/components/if_.py +0 -0
  32. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/components/react_router.py +0 -0
  33. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/context.py +0 -0
  34. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/css.py +0 -0
  35. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/decorators.py +0 -0
  36. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/form.py +0 -0
  37. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/helpers.py +0 -0
  38. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/__init__.py +0 -0
  39. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/core.py +0 -0
  40. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/effects.py +0 -0
  41. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/runtime.py +0 -0
  42. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/setup.py +0 -0
  43. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/stable.py +0 -0
  44. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/hooks/states.py +0 -0
  45. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/__init__.py +0 -0
  46. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/elements.py +0 -0
  47. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/events.py +0 -0
  48. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/props.py +0 -0
  49. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/svg.py +0 -0
  50. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/tags.py +0 -0
  51. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/html/tags.pyi +0 -0
  52. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/messages.py +0 -0
  53. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/middleware.py +0 -0
  54. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/plugin.py +0 -0
  55. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/py.typed +0 -0
  56. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/query.py +0 -0
  57. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/react_component.py +0 -0
  58. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/reactive.py +0 -0
  59. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/reactive_extensions.py +0 -0
  60. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/render_session.py +0 -0
  61. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/renderer.py +0 -0
  62. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/request.py +0 -0
  63. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/routing.py +0 -0
  64. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/serializer.py +0 -0
  65. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/state.py +0 -0
  66. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/types/__init__.py +0 -0
  67. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/types/event_handler.py +0 -0
  68. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/user_session.py +0 -0
  69. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/src/pulse/vdom.py +0 -0
  70. {pulse_framework-0.1.38a1 → pulse_framework-0.1.38a3}/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.38a1
3
+ Version: 0.1.38a3
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.38a1"
3
+ version = "0.1.38a3"
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,
@@ -165,7 +166,6 @@ class App:
165
166
  internal_server_address: str | None = None,
166
167
  not_found: str = "/not-found",
167
168
  # Deployment and integration options
168
- env: PulseEnv | None = None,
169
169
  mode: PulseMode = "single-server",
170
170
  api_prefix: str = "/_pulse",
171
171
  cors: CORSOptions | None = None,
@@ -179,7 +179,7 @@ class App:
179
179
  codegen: Optional codegen configuration.
180
180
  """
181
181
  # Resolve mode from environment and expose on the app instance
182
- self.env = env or envvars.pulse_env
182
+ self.env = envvars.pulse_env
183
183
  self.mode = mode
184
184
  self.status = AppStatus.created
185
185
  # Persist the server address for use by sessions (API calls, etc.)
@@ -226,10 +226,6 @@ class App:
226
226
  # Map websocket sid -> renderId for message routing
227
227
  self._socket_to_render = {}
228
228
 
229
- # Subprocess management for single-server mode
230
- self.web_server_proc: subprocess.Popen[str] | None = None
231
- self.web_server_port: int | None = None
232
-
233
229
  self.codegen = Codegen(
234
230
  self.routes,
235
231
  config=codegen or CodegenConfig(),
@@ -267,116 +263,26 @@ class App:
267
263
  for plugin in self.plugins:
268
264
  plugin.on_startup(self)
269
265
 
270
- # Start React Router server in single-server mode
271
- logger.info(f"Deployment mode: {self.mode}")
272
266
  if self.mode == "single-server":
273
- logger.info("Starting React Router server in single-server mode...")
274
- try:
275
- web_root = self.codegen.cfg.web_root
276
- logger.info(f"Web root: {web_root}")
277
- port = await self.start_web_server(web_root, self.env)
267
+ react_server_address = envvars.react_server_address
268
+ if react_server_address:
278
269
  logger.info(
279
- 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."
280
275
  )
281
- except Exception:
282
- logger.exception("Failed to start React Router server")
283
- raise
284
276
 
285
277
  try:
286
278
  yield
287
279
  finally:
288
- # Stop React Router server in single-server mode
289
- if self.mode == "single-server":
290
- try:
291
- self.stop_web_server()
292
- except Exception:
293
- logger.exception("Error stopping React Router server")
294
-
295
280
  try:
296
281
  if isinstance(self.session_store, SessionStore):
297
282
  await self.session_store.close()
298
283
  except Exception:
299
284
  logger.exception("Error during SessionStore.close()")
300
285
 
301
- async def start_web_server(self, web_root: Path, mode: str) -> int:
302
- """Start React Router server as subprocess and return its port."""
303
-
304
- # Find available port
305
- port = find_available_port(5173)
306
-
307
- # Build command based on mode
308
- if mode == "prod":
309
- # Check if build exists
310
- build_server = web_root / "build" / "server" / "index.js"
311
- if not build_server.exists():
312
- raise RuntimeError(
313
- f"Production build not found at {build_server}. Run 'bun run build' in the web directory first."
314
- )
315
- cli_path = (
316
- web_root
317
- / "node_modules"
318
- / "@react-router"
319
- / "serve"
320
- / "dist"
321
- / "cli.js"
322
- )
323
- if not cli_path.exists():
324
- raise RuntimeError(
325
- f"React Router CLI not found at {cli_path}. Did you install web dependencies?"
326
- )
327
- # Production: use Node to serve the built bundle (Bun lacks renderToPipeableStream)
328
- cmd = [
329
- "node",
330
- str(cli_path),
331
- "./build/server/index.js",
332
- "--port",
333
- str(port),
334
- ]
335
- else:
336
- # Development: use dev server
337
- cmd = ["bun", "run", "dev", "--port", str(port)]
338
-
339
- logger.info(f"Starting React Router server: {' '.join(cmd)}")
340
-
341
- proc = subprocess.Popen(
342
- cmd,
343
- cwd=web_root,
344
- stdout=subprocess.PIPE,
345
- stderr=subprocess.STDOUT,
346
- env=os.environ.copy(),
347
- text=True,
348
- )
349
-
350
- # Wait for server to be ready
351
- await asyncio.sleep(2.0 if mode == "prod" else 1.0)
352
-
353
- # Check if process is still running
354
- if proc.poll() is not None:
355
- output = proc.stdout.read() if proc.stdout else ""
356
- raise RuntimeError(f"React Router server failed to start: {output}")
357
-
358
- self.web_server_proc = proc
359
- self.web_server_port = port
360
-
361
- logger.info(f"React Router server started on port {port}")
362
- return port
363
-
364
- def stop_web_server(self):
365
- """Stop the React Router subprocess."""
366
- if self.web_server_proc:
367
- logger.info("Stopping React Router server...")
368
- self.web_server_proc.terminate()
369
- try:
370
- self.web_server_proc.wait(timeout=5)
371
- except subprocess.TimeoutExpired:
372
- logger.warning(
373
- "React Router server did not stop gracefully, killing..."
374
- )
375
- self.web_server_proc.kill()
376
- self.web_server_proc.wait()
377
- self.web_server_proc = None
378
- self.web_server_port = None
379
-
380
286
  def run_codegen(
381
287
  self, address: str | None = None, internal_address: str | None = None
382
288
  ):
@@ -427,10 +333,11 @@ class App:
427
333
  self.setup(server_address)
428
334
  self.status = AppStatus.running
429
335
 
430
- # Wrap with proxy in single-server mode. Do it here instead of within
431
- # __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
432
337
  if self.mode == "single-server":
433
- 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
+ )
434
341
  return self.asgi
435
342
 
436
343
  def run(
@@ -464,11 +371,35 @@ class App:
464
371
  self.fastapi.add_middleware(CORSMiddleware, **self.cors)
465
372
  else:
466
373
  # Use deployment-specific CORS settings
374
+ cors_config = cors_options(self.mode, self.server_address)
375
+ print(f"CORS config: {cors_config}")
467
376
  self.fastapi.add_middleware(
468
377
  CORSMiddleware,
469
- **cors_options(self.mode, self.server_address),
378
+ **cors_config,
470
379
  )
471
380
 
381
+ # Debug middleware to log CORS-related request details
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
402
+
472
403
  # Mount PulseContext for all FastAPI routes (no route info). Other API
473
404
  # routes / middleware should be added at the module-level, which means
474
405
  # this middleware will wrap all of them.
@@ -21,6 +21,7 @@ from pulse.cli.dependencies import (
21
21
  DependencyError,
22
22
  DependencyPlan,
23
23
  DependencyResolutionError,
24
+ check_web_dependencies,
24
25
  prepare_web_dependencies,
25
26
  )
26
27
  from pulse.cli.folder_lock import FolderLock
@@ -33,6 +34,7 @@ from pulse.env import (
33
34
  ENV_PULSE_DISABLE_CODEGEN,
34
35
  ENV_PULSE_HOST,
35
36
  ENV_PULSE_PORT,
37
+ ENV_PULSE_REACT_SERVER_ADDRESS,
36
38
  ENV_PULSE_SECRET,
37
39
  PulseEnv,
38
40
  env,
@@ -68,8 +70,16 @@ def run(
68
70
  prod: bool = typer.Option(False, "--prod", help="Run in production env"),
69
71
  server_only: bool = typer.Option(False, "--server-only", "--backend-only"),
70
72
  web_only: bool = typer.Option(False, "--web-only"),
71
- reload: bool = typer.Option(True, "--reload/--no-reload"),
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
+ ),
78
+ reload: bool | None = typer.Option(None, "--reload/--no-reload"),
72
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
+ ),
73
83
  ):
74
84
  """Run the Pulse server and web development server together."""
75
85
  extra_flags = list(ctx.args)
@@ -88,6 +98,10 @@ def run(
88
98
  if len(env_flags) == 1:
89
99
  env.pulse_env = cast(PulseEnv, env_flags[0])
90
100
 
101
+ # Turn on reload in dev only
102
+ if reload is None:
103
+ reload = env.pulse_env == "dev"
104
+
91
105
  if server_only and web_only:
92
106
  typer.echo("❌ Cannot use --server-only and --web-only at the same time.")
93
107
  raise typer.Exit(1)
@@ -101,14 +115,19 @@ def run(
101
115
  _apply_app_context_to_env(app_ctx)
102
116
  app_instance = app_ctx.app
103
117
 
104
- # Override mode to subdomains if server-only (single-server + server-only makes no sense)
105
- if server_only and app_instance.mode == "single-server":
106
- app_instance.mode = "subdomains"
107
-
108
118
  is_single_server = app_instance.mode == "single-server"
109
119
  if is_single_server:
110
120
  console.log("🔧 [cyan]Single-server mode[/cyan]")
111
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
+
112
131
  web_root = app_instance.codegen.cfg.web_root
113
132
  if not web_root.exists() and not server_only:
114
133
  console.log(f"❌ Directory not found: {web_root.absolute()}")
@@ -121,10 +140,10 @@ def run(
121
140
  )
122
141
 
123
142
  if not server_only:
124
- # Skip dependency installation in production mode (assumes pre-built)
125
- if app_instance.env != "prod":
143
+ # Skip dependency checking and installation in production/CI mode (assumes pre-built)
144
+ if env.pulse_env == "dev":
126
145
  try:
127
- dep_plan = prepare_web_dependencies(
146
+ to_add = check_web_dependencies(
128
147
  web_root,
129
148
  pulse_version=PULSE_PY_VERSION,
130
149
  )
@@ -135,9 +154,14 @@ def run(
135
154
  console.log(f"❌ {exc}")
136
155
  raise typer.Exit(1) from None
137
156
 
138
- if dep_plan:
157
+ if to_add:
139
158
  try:
140
- _run_dependency_plan(console, web_root, dep_plan)
159
+ dep_plan = prepare_web_dependencies(
160
+ web_root,
161
+ pulse_version=PULSE_PY_VERSION,
162
+ )
163
+ if dep_plan:
164
+ _run_dependency_plan(console, web_root, dep_plan)
141
165
  except subprocess.CalledProcessError:
142
166
  console.log("❌ Failed to install web dependencies with Bun.")
143
167
  raise typer.Exit(1) from None
@@ -159,10 +183,25 @@ def run(
159
183
  console=console,
160
184
  web_root=web_root,
161
185
  announce_url=is_single_server,
186
+ verbose=verbose,
162
187
  )
163
188
  )
164
189
 
165
- if not is_single_server and not server_only:
190
+ # In single-server mode, start web server separately (not managed by app lifecycle)
191
+ if is_single_server and not server_only:
192
+ web_port = find_available_port(5173)
193
+ commands.append(
194
+ build_web_command(
195
+ web_root=web_root,
196
+ extra_args=web_args,
197
+ port=web_port,
198
+ mode=app_instance.env,
199
+ )
200
+ )
201
+ # Set env var so app can read the React server address
202
+ react_server_address = f"http://localhost:{web_port}"
203
+ os.environ[ENV_PULSE_REACT_SERVER_ADDRESS] = react_server_address
204
+ elif not is_single_server and not server_only:
166
205
  commands.append(build_web_command(web_root=web_root, extra_args=web_args))
167
206
 
168
207
  tag_colors = {"server": "cyan", "web": "orange1"}
@@ -219,6 +258,66 @@ def generate(
219
258
  console.log("⚠️ No routes found to generate")
220
259
 
221
260
 
261
+ @cli.command("check")
262
+ def check(
263
+ app_file: str = typer.Argument(
264
+ ..., help="App target: 'path.py[:var]' (default :app) or 'module:var'"
265
+ ),
266
+ fix: bool = typer.Option(
267
+ False, "--fix", help="Install missing or outdated dependencies"
268
+ ),
269
+ ):
270
+ """Check if web project dependencies are in sync with Pulse app requirements."""
271
+ console = Console()
272
+
273
+ console.log(f"📁 Loading app from: {app_file}")
274
+ app_ctx = load_app_from_target(app_file)
275
+ _apply_app_context_to_env(app_ctx)
276
+ app_instance = app_ctx.app
277
+
278
+ web_root = app_instance.codegen.cfg.web_root
279
+ if not web_root.exists():
280
+ console.log(f"❌ Directory not found: {web_root.absolute()}")
281
+ raise typer.Exit(1)
282
+
283
+ try:
284
+ to_add = check_web_dependencies(
285
+ web_root,
286
+ pulse_version=PULSE_PY_VERSION,
287
+ )
288
+ except DependencyResolutionError as exc:
289
+ console.log(f"❌ {exc}")
290
+ raise typer.Exit(1) from None
291
+ except DependencyError as exc:
292
+ console.log(f"❌ {exc}")
293
+ raise typer.Exit(1) from None
294
+
295
+ if not to_add:
296
+ console.log("✅ Web dependencies are in sync")
297
+ return
298
+
299
+ console.log("📦 Web dependencies are out of sync:")
300
+ for pkg in to_add:
301
+ console.log(f" - {pkg}")
302
+
303
+ if not fix:
304
+ console.log("💡 Run 'pulse check --fix' to install missing dependencies")
305
+ return
306
+
307
+ # Apply fix
308
+ try:
309
+ dep_plan = prepare_web_dependencies(
310
+ web_root,
311
+ pulse_version=PULSE_PY_VERSION,
312
+ )
313
+ if dep_plan:
314
+ _run_dependency_plan(console, web_root, dep_plan)
315
+ console.log("✅ Web dependencies synced successfully")
316
+ except subprocess.CalledProcessError:
317
+ console.log("❌ Failed to install web dependencies with Bun.")
318
+ raise typer.Exit(1) from None
319
+
320
+
222
321
  def build_uvicorn_command(
223
322
  *,
224
323
  app_ctx: AppLoadResult,
@@ -231,6 +330,7 @@ def build_uvicorn_command(
231
330
  console: Console,
232
331
  web_root: Path,
233
332
  announce_url: bool,
333
+ verbose: bool = False,
234
334
  ) -> CommandSpec:
235
335
  app_import = f"{app_ctx.module_name}:{app_ctx.app_var}.asgi_factory"
236
336
  args: list[str] = [
@@ -245,7 +345,7 @@ def build_uvicorn_command(
245
345
  "--factory",
246
346
  ]
247
347
 
248
- if reload_enabled and app_ctx.app.env != "prod":
348
+ if reload_enabled:
249
349
  args.append("--reload")
250
350
  args.extend(["--reload-include", "*.css"])
251
351
  app_dir = app_ctx.app_dir or Path.cwd()
@@ -268,6 +368,11 @@ def build_uvicorn_command(
268
368
  ENV_PULSE_PORT: str(port),
269
369
  }
270
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
+ ]
271
376
  if app_ctx.app.env == "prod" and server_only:
272
377
  command_env[ENV_PULSE_DISABLE_CODEGEN] = "1"
273
378
  if dev_secret:
@@ -276,7 +381,7 @@ def build_uvicorn_command(
276
381
  cwd = app_ctx.server_cwd or app_ctx.app_dir or Path.cwd()
277
382
 
278
383
  # Apply custom log config to filter noisy requests (dev/ci only)
279
- if app_ctx.app.env != "prod":
384
+ if app_ctx.app.env != "prod" and not verbose:
280
385
  import json
281
386
  import tempfile
282
387
 
@@ -304,8 +409,21 @@ def build_uvicorn_command(
304
409
  )
305
410
 
306
411
 
307
- def build_web_command(*, web_root: Path, extra_args: Sequence[str]) -> CommandSpec:
308
- 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)])
309
427
  if extra_args:
310
428
  args.extend(extra_args)
311
429
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from collections.abc import Callable, Iterable, Sequence
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
@@ -18,6 +19,53 @@ from pulse.cli.packages import (
18
19
  from pulse.react_component import ReactComponent, registered_react_components
19
20
 
20
21
 
22
+ def convert_pep440_to_semver(python_version: str) -> str:
23
+ """Convert PEP 440 version format to NPM semver format.
24
+
25
+ PEP 440 formats:
26
+ - 0.1.37a1 -> 0.1.37-alpha.1
27
+ - 0.1.37b1 -> 0.1.37-beta.1
28
+ - 0.1.37rc1 -> 0.1.37-rc.1
29
+ - 0.1.37.dev1 -> 0.1.37-dev.1
30
+
31
+ Non-pre-release versions are returned unchanged.
32
+ """
33
+ # Match pre-release patterns: version followed by a/b/rc/dev + number
34
+ # PEP 440: a1, b1, rc1, dev1, alpha1, beta1, etc.
35
+ pattern = r"^(\d+\.\d+\.\d+)([a-z]+)(\d+)$"
36
+ match = re.match(pattern, python_version)
37
+
38
+ if match:
39
+ base_version = match.group(1)
40
+ prerelease_type = match.group(2)
41
+ prerelease_num = match.group(3)
42
+
43
+ # Map PEP 440 prerelease types to NPM semver
44
+ type_map = {
45
+ "a": "alpha",
46
+ "alpha": "alpha",
47
+ "b": "beta",
48
+ "beta": "beta",
49
+ "rc": "rc",
50
+ "c": "rc", # PEP 440 also allows 'c' for release candidate
51
+ "dev": "dev",
52
+ }
53
+
54
+ npm_type = type_map.get(prerelease_type.lower(), prerelease_type)
55
+ return f"{base_version}-{npm_type}.{prerelease_num}"
56
+
57
+ # Also handle .dev format (e.g., 0.1.37.dev1)
58
+ pattern2 = r"^(\d+\.\d+\.\d+)\.dev(\d+)$"
59
+ match2 = re.match(pattern2, python_version)
60
+ if match2:
61
+ base_version = match2.group(1)
62
+ dev_num = match2.group(2)
63
+ return f"{base_version}-dev.{dev_num}"
64
+
65
+ # No pre-release, return as-is
66
+ return python_version
67
+
68
+
21
69
  class DependencyError(RuntimeError):
22
70
  """Base error for dependency preparation failures."""
23
71
 
@@ -38,15 +86,15 @@ class DependencyPlan:
38
86
  to_add: Sequence[str]
39
87
 
40
88
 
41
- def prepare_web_dependencies(
89
+ def get_required_dependencies(
42
90
  web_root: Path,
43
91
  *,
44
92
  pulse_version: str,
45
93
  component_provider: Callable[
46
94
  [], Iterable[ReactComponent[Any]]
47
95
  ] = registered_react_components,
48
- ) -> DependencyPlan | None:
49
- """Inspect registered components and return the Bun command needed to sync dependencies."""
96
+ ) -> dict[str, str | None]:
97
+ """Get the required dependencies for a Pulse app."""
50
98
  if not web_root.exists():
51
99
  raise DependencyError(f"Directory not found: {web_root}")
52
100
 
@@ -102,13 +150,30 @@ def prepare_web_dependencies(
102
150
  ]:
103
151
  desired.setdefault(pkg, "^7")
104
152
 
153
+ return desired
154
+
155
+
156
+ def check_web_dependencies(
157
+ web_root: Path,
158
+ *,
159
+ pulse_version: str,
160
+ component_provider: Callable[
161
+ [], Iterable[ReactComponent[Any]]
162
+ ] = registered_react_components,
163
+ ) -> list[str]:
164
+ """Check if web dependencies are in sync and return list of packages that need to be added/updated."""
165
+ desired = get_required_dependencies(
166
+ web_root=web_root,
167
+ pulse_version=pulse_version,
168
+ component_provider=component_provider,
169
+ )
105
170
  pkg_json = load_package_json(web_root)
106
171
 
107
172
  to_add: list[str] = []
108
173
  for name, req_ver in sorted(desired.items()):
109
174
  effective = req_ver
110
175
  if name == "pulse-ui-client":
111
- effective = pulse_version
176
+ effective = convert_pep440_to_semver(pulse_version)
112
177
 
113
178
  existing = get_pkg_spec(pkg_json, name)
114
179
  if existing is None:
@@ -123,6 +188,24 @@ def prepare_web_dependencies(
123
188
 
124
189
  to_add.append(f"{name}@{effective}" if effective else name)
125
190
 
191
+ return to_add
192
+
193
+
194
+ def prepare_web_dependencies(
195
+ web_root: Path,
196
+ *,
197
+ pulse_version: str,
198
+ component_provider: Callable[
199
+ [], Iterable[ReactComponent[Any]]
200
+ ] = registered_react_components,
201
+ ) -> DependencyPlan | None:
202
+ """Inspect registered components and return the Bun command needed to sync dependencies."""
203
+ to_add = check_web_dependencies(
204
+ web_root=web_root,
205
+ pulse_version=pulse_version,
206
+ component_provider=component_provider,
207
+ )
208
+
126
209
  if to_add:
127
210
  return DependencyPlan(command=["bun", "add", *to_add], to_add=to_add)
128
211
 
@@ -155,10 +155,15 @@ def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
155
155
  }
156
156
  if mode == "subdomains":
157
157
  base = _base_domain(host)
158
+ # Escape dots in base domain for regex (doesn't affect localhost since it has no dots)
159
+ base = base.replace(".", r"\.")
160
+ # Allow any subdomain and any port for the base domain
158
161
  opts["allow_origin_regex"] = rf"^https?://([a-z0-9-]+\\.)?{base}(:\\d+)?$"
159
162
  return opts
160
163
  elif mode == "single-server":
161
164
  # For single-server mode, allow same origin
165
+ # Escape dots in host for regex (doesn't affect localhost since it has no dots)
166
+ host = host.replace(".", r"\.")
162
167
  opts["allow_origin_regex"] = rf"^https?://{host}(:\\d+)?$"
163
168
  return opts
164
169
  else:
@@ -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
 
@@ -39,13 +40,12 @@ class EnvVars:
39
40
  else:
40
41
  os.environ[key] = value
41
42
 
42
- # Pulse mode
43
43
  @property
44
44
  def pulse_env(self) -> PulseEnv:
45
45
  value = (self._get(ENV_PULSE_MODE) or "dev").lower()
46
46
  if value not in ("dev", "ci", "prod"):
47
47
  value = "dev"
48
- return value # type: ignore[return-value]
48
+ return value
49
49
 
50
50
  @pulse_env.setter
51
51
  def pulse_env(self, value: PulseEnv) -> None:
@@ -88,6 +88,14 @@ class EnvVars:
88
88
  def pulse_port(self, value: int) -> None:
89
89
  self._set(ENV_PULSE_PORT, str(value))
90
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
+
91
99
  # Secrets
92
100
  @property
93
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