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