pulse-framework 0.1.38a1__py3-none-any.whl → 0.1.38a3__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/app.py +42 -111
- pulse/cli/cmd.py +133 -15
- pulse/cli/dependencies.py +87 -4
- pulse/cookies.py +5 -0
- pulse/env.py +10 -2
- pulse/proxy.py +13 -10
- {pulse_framework-0.1.38a1.dist-info → pulse_framework-0.1.38a3.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.38a1.dist-info → pulse_framework-0.1.38a3.dist-info}/RECORD +10 -10
- {pulse_framework-0.1.38a1.dist-info → pulse_framework-0.1.38a3.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.38a1.dist-info → pulse_framework-0.1.38a3.dist-info}/entry_points.txt +0 -0
pulse/app.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
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
|
-
#
|
|
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(
|
|
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
|
-
**
|
|
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.
|
pulse/cli/cmd.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
143
|
+
# Skip dependency checking and installation in production/CI mode (assumes pre-built)
|
|
144
|
+
if env.pulse_env == "dev":
|
|
126
145
|
try:
|
|
127
|
-
|
|
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
|
|
157
|
+
if to_add:
|
|
139
158
|
try:
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
308
|
-
|
|
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
|
|
pulse/cli/dependencies.py
CHANGED
|
@@ -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
|
|
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
|
-
) ->
|
|
49
|
-
"""
|
|
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
|
|
pulse/cookies.py
CHANGED
|
@@ -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:
|
pulse/env.py
CHANGED
|
@@ -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
|
|
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:
|
pulse/proxy.py
CHANGED
|
@@ -24,7 +24,7 @@ class PulseProxy:
|
|
|
24
24
|
def __init__(
|
|
25
25
|
self,
|
|
26
26
|
app: ASGIApp,
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
#
|
|
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:
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
pulse/__init__.py,sha256=aJg4LvIeLYalXvEe1u47_A9ktS3HGIf4ZeAo6w1tbMQ,31463
|
|
2
|
-
pulse/app.py,sha256=
|
|
2
|
+
pulse/app.py,sha256=iBdqEt1SFD_28rDVAaZsaSixu4uhyRUAnwKTjGSEy0c,26906
|
|
3
3
|
pulse/channel.py,sha256=DuD1mg_xWvkpAWSKZ-EtBYdUzJ8IuKH0fxdgGOvFXpg,13041
|
|
4
4
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
pulse/cli/cmd.py,sha256=
|
|
6
|
-
pulse/cli/dependencies.py,sha256=
|
|
5
|
+
pulse/cli/cmd.py,sha256=bkWxrQy9DmB8l8gok_9l9ofbj0zu5_PC3Ny-K5fuKrM,13418
|
|
6
|
+
pulse/cli/dependencies.py,sha256=ZBqBAfMvMBQUvh4THdPDztTMQ_dyR52S1IuotP_eEZs,5623
|
|
7
7
|
pulse/cli/folder_lock.py,sha256=kvUmZBg869lwCTIZFoge9dhorv8qPXHTWwVv_jQg1k8,3477
|
|
8
8
|
pulse/cli/helpers.py,sha256=8bRlV3d7w3w-jHaFvFYt9Pzue6_CbKOq_Z3jBsBOeUk,8820
|
|
9
9
|
pulse/cli/models.py,sha256=hRmIWmhXmGf2otzVm1do4Dm19rkWkmTwAA3Am3kw2tE,692
|
|
@@ -25,10 +25,10 @@ pulse/components/for_.py,sha256=LUyJEUlDM6b9oPjvUFgSsddxu6b6usF4BQdXe8FIiGI,1302
|
|
|
25
25
|
pulse/components/if_.py,sha256=rQywsmdirNpkb-61ZEdF-tgzUh-37JWd4YFGblkzIdQ,1624
|
|
26
26
|
pulse/components/react_router.py,sha256=TbRec-NVliUqrvAMeFXCrnDWV1rh6TGTPfRhqLuLubk,1129
|
|
27
27
|
pulse/context.py,sha256=x_nCbCEUGygAdCZiTfko5uuYxVSAeCNhYa59zBq015M,1692
|
|
28
|
-
pulse/cookies.py,sha256=
|
|
28
|
+
pulse/cookies.py,sha256=c7ua1Lv6mNe1nYnA4SFVvewvRQAbYy9fN5G3Hr_Dr5c,5000
|
|
29
29
|
pulse/css.py,sha256=-FyQQQ0EZI1Ins30qiF3l4z9yDb1V9qWuJKWxHcKGkw,3910
|
|
30
30
|
pulse/decorators.py,sha256=8At1HQTFs9KG7nd83miGMe3KkhTBVGDviaqZaY62bHI,6651
|
|
31
|
-
pulse/env.py,sha256=
|
|
31
|
+
pulse/env.py,sha256=p3XI8KG1ZCcXPD3LJP7fW8JPYfyvoYY5ENwae2o0PiA,2889
|
|
32
32
|
pulse/form.py,sha256=M87QwG4KFOrI8Nba7BTDoJ_wZ1-jzJW7QN4JweYCpuM,9004
|
|
33
33
|
pulse/helpers.py,sha256=q54JGen1lBIEGyLnNKvmW_7nnTFqFZBDMYXsoXvth7o,11628
|
|
34
34
|
pulse/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -48,7 +48,7 @@ pulse/html/tags.pyi,sha256=I8dFoft9w4RvneZ3li1weAdijY1krj9jfO_p2SU6e04,13953
|
|
|
48
48
|
pulse/messages.py,sha256=SKfNHYCDkoRa5X0CPlBQbsFfqIVMZQ7z34vWxQY-bUs,3206
|
|
49
49
|
pulse/middleware.py,sha256=26ETGBLrIj8m0DOOUkafhLueQDzUurgjcqtI1L-6DNs,6518
|
|
50
50
|
pulse/plugin.py,sha256=rYk1tR4SSoQQM8ZD2rWC-AnMfcN7xOscW92RbyK6LKA,594
|
|
51
|
-
pulse/proxy.py,sha256=
|
|
51
|
+
pulse/proxy.py,sha256=lIt1W8FpItpwl85IxzdzgTwPN8G7kvM5OFYB7YW9iaE,5158
|
|
52
52
|
pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
53
|
pulse/query.py,sha256=u0KVFNt0d36sfmoxpQXvJ8DjctIHZaM_szlEonRiyRg,12849
|
|
54
54
|
pulse/react_component.py,sha256=Rw1J6cHOX8-K3BnkswVOu2COgneVvRz1OYmyXkX17RM,25993
|
|
@@ -65,7 +65,7 @@ pulse/types/event_handler.py,sha256=OF7sOgYBb6iUs59RH1vQIH7aOrGPfs3nAaF7how-4PQ,
|
|
|
65
65
|
pulse/user_session.py,sha256=kCZtQpYZe2keDXzusd6jsjjw075am0dXrb25jKLg5JU,7578
|
|
66
66
|
pulse/vdom.py,sha256=KTNBh2dVvDy9eXRzhneBJgk7F35MyWec8R_puQ4tSRY,12420
|
|
67
67
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
68
|
-
pulse_framework-0.1.
|
|
69
|
-
pulse_framework-0.1.
|
|
70
|
-
pulse_framework-0.1.
|
|
71
|
-
pulse_framework-0.1.
|
|
68
|
+
pulse_framework-0.1.38a3.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
69
|
+
pulse_framework-0.1.38a3.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
70
|
+
pulse_framework-0.1.38a3.dist-info/METADATA,sha256=ox_UWflZQSQ2pOHJkuzdArLYIDvtkKPUNNCky7A_syU,582
|
|
71
|
+
pulse_framework-0.1.38a3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|