create-caspian-app 0.3.0-rc.2 → 0.3.0-rc.21

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.
@@ -0,0 +1,1001 @@
1
+ from casp.components_compiler import transform_components
2
+ from casp.scripts_type import transform_scripts
3
+ import asyncio
4
+ import inspect
5
+ import os
6
+ import importlib.util
7
+ import secrets
8
+ import traceback
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+ from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, status
13
+ from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
14
+ from starlette.datastructures import MutableHeaders
15
+ from starlette.middleware.sessions import SessionMiddleware
16
+ from starlette.types import ASGIApp, Receive, Scope, Send
17
+ from starlette.exceptions import HTTPException as StarletteHTTPException
18
+ from dotenv import load_dotenv
19
+ import uvicorn
20
+ from casp.state_manager import StateManager
21
+ from casp.cache_handler import CacheHandler
22
+ from casp.caspian_config import get_files_index, get_config
23
+ from casp.auth import (
24
+ Auth,
25
+ GoogleProvider,
26
+ GithubProvider,
27
+ configure_auth,
28
+ auth,
29
+ )
30
+ from casp.rpc import register_rpc_routes
31
+ from casp.layout import (
32
+ render_with_nested_layouts,
33
+ compile_template,
34
+ load_template_file,
35
+ render_page,
36
+ _runtime_injections,
37
+ _runtime_metadata,
38
+ )
39
+ import hashlib
40
+ from casp.streaming import SSE
41
+ from typing import Any, Optional, get_args, get_origin, Union
42
+ from urllib.parse import urlparse
43
+ from src.lib.auth.auth_config import build_auth_settings
44
+ from casp.runtime_security import (
45
+ build_security_headers,
46
+ client_error_message,
47
+ get_session_secret,
48
+ public_file_response,
49
+ )
50
+ from src.lib.websocket.websocket_security import (
51
+ get_authenticated_payload_from_session,
52
+ get_websocket_session,
53
+ public_websocket_connections,
54
+ websocket_connections,
55
+ )
56
+
57
+ load_dotenv()
58
+ cfg = get_config()
59
+
60
+ # ====
61
+ # AUTH CONFIGURATION (App behavior - customize here)
62
+ # ====
63
+
64
+
65
+ def setup_auth():
66
+ configure_auth(build_auth_settings())
67
+ Auth.set_providers(GithubProvider(), GoogleProvider())
68
+
69
+
70
+ setup_auth()
71
+
72
+ app = FastAPI(
73
+ title=cfg.projectName,
74
+ version=cfg.version,
75
+ docs_url="/docs" if cfg.backendOnly else None,
76
+ redoc_url="/redoc" if cfg.backendOnly else None,
77
+ openapi_url="/openapi.json" if cfg.backendOnly else None,
78
+ )
79
+
80
+
81
+ @app.get("/health")
82
+ async def healthcheck():
83
+ return {"status": "ok"}
84
+
85
+ # ====
86
+ # Configuration
87
+ # ====
88
+ SESSION_LIFETIME_HOURS = int(os.getenv('SESSION_LIFETIME_HOURS', 7))
89
+ MAX_CONTENT_LENGTH_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', 16))
90
+ IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
91
+ CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
92
+ DEFAULT_TTL = int(os.getenv('CACHE_TTL', 600))
93
+ REQUEST_TIMEOUT_SECONDS = max(
94
+ 1.0,
95
+ float(os.getenv('CASPIAN_REQUEST_TIMEOUT_SECONDS', 20)),
96
+ )
97
+ MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
98
+ WEBSOCKET_PATH = "/ws/live"
99
+ PUBLIC_WEBSOCKET_PATH = "/ws/public"
100
+ WEBSOCKET_IDLE_TIMEOUT_SECONDS = max(
101
+ 10,
102
+ int(os.getenv('WEBSOCKET_IDLE_TIMEOUT_SECONDS', 120)),
103
+ )
104
+ MAX_WEBSOCKET_MESSAGE_BYTES = max(
105
+ 256,
106
+ int(os.getenv('MAX_WEBSOCKET_MESSAGE_BYTES', 4096)),
107
+ )
108
+ SHOW_ERROR_TRACES = (
109
+ not IS_PRODUCTION
110
+ and os.getenv('SHOW_ERRORS', 'false').lower() in {'1', 'true', 'yes', 'on'}
111
+ )
112
+
113
+
114
+ class RequestBodyTooLarge(Exception):
115
+ pass
116
+
117
+
118
+ def _client_error_message(exc: Exception) -> str:
119
+ return client_error_message(exc, is_production=IS_PRODUCTION)
120
+
121
+
122
+ def _get_session_secret() -> str:
123
+ return get_session_secret(is_production=IS_PRODUCTION)
124
+
125
+
126
+ def _build_security_headers() -> dict[str, str]:
127
+ return build_security_headers(is_production=IS_PRODUCTION)
128
+
129
+
130
+ def _request_is_local(request: Request) -> bool:
131
+ client_host = request.client.host if request.client else ""
132
+ return client_host in {"127.0.0.1", "::1", "localhost"}
133
+
134
+
135
+ def _should_show_error_trace(request: Request) -> bool:
136
+ return SHOW_ERROR_TRACES and _request_is_local(request)
137
+
138
+
139
+ def _dev_cookie_scope() -> str:
140
+ if IS_PRODUCTION:
141
+ return ""
142
+
143
+ scope = os.getenv("CASPIAN_BROWSER_SYNC_PORT")
144
+ if scope and scope.isdigit():
145
+ return scope
146
+
147
+ if not scope:
148
+ bs_config_path = Path("settings/bs-config.json")
149
+ if bs_config_path.exists():
150
+ try:
151
+ local_url = json.loads(
152
+ bs_config_path.read_text(encoding="utf-8")
153
+ ).get("local", "")
154
+ parsed_url = urlparse(local_url)
155
+ if parsed_url.hostname in {"localhost", "127.0.0.1"}:
156
+ scope = str(parsed_url.port or "")
157
+ else:
158
+ scope = ""
159
+ except (OSError, json.JSONDecodeError):
160
+ scope = ""
161
+
162
+ return scope if scope and scope.isdigit() else ""
163
+
164
+
165
+ def _scoped_cookie_name(base_name: str) -> str:
166
+ scope = _dev_cookie_scope()
167
+ return f"{base_name}_{scope}" if scope else base_name
168
+
169
+
170
+ def _normalized_origin(value: str) -> str:
171
+ return (value or "").strip().rstrip("/")
172
+
173
+
174
+ def _configured_websocket_origins() -> set[str]:
175
+ raw_values = []
176
+ for env_name in (
177
+ "WEBSOCKET_ALLOWED_ORIGINS",
178
+ "CORS_ALLOWED_ORIGINS",
179
+ "APP_BASE_URL",
180
+ "PUBLIC_BASE_URL",
181
+ "SITE_URL",
182
+ ):
183
+ raw_values.extend(os.getenv(env_name, "").split(","))
184
+
185
+ return {
186
+ _normalized_origin(origin)
187
+ for origin in raw_values
188
+ if _normalized_origin(origin)
189
+ }
190
+
191
+
192
+ def _websocket_same_origin(websocket: WebSocket) -> str:
193
+ scheme = "https" if websocket.url.scheme == "wss" else "http"
194
+ return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
195
+
196
+
197
+ def _is_websocket_origin_allowed(websocket: WebSocket) -> bool:
198
+ origin = _normalized_origin(websocket.headers.get("origin", ""))
199
+ if not origin:
200
+ return not IS_PRODUCTION
201
+
202
+ parsed_origin = urlparse(origin)
203
+ if not parsed_origin.scheme or not parsed_origin.netloc:
204
+ return False
205
+
206
+ if not IS_PRODUCTION and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
207
+ return parsed_origin.scheme == "http"
208
+
209
+ allowed_origins = _configured_websocket_origins()
210
+ allowed_origins.add(_websocket_same_origin(websocket))
211
+ return origin in allowed_origins
212
+
213
+
214
+ CSRF_COOKIE_NAME = _scoped_cookie_name("pp_csrf")
215
+ SESSION_COOKIE_NAME = _scoped_cookie_name(
216
+ os.getenv('AUTH_COOKIE_NAME', 'session')
217
+ )
218
+
219
+ # ====
220
+ # Static File Routes
221
+ # ====
222
+
223
+
224
+ @app.get('/css/{filename:path}')
225
+ async def serve_css(filename: str):
226
+ return public_file_response('public/css', filename, media_type='text/css')
227
+
228
+
229
+ @app.get('/js/{filename:path}')
230
+ async def serve_js(filename: str):
231
+ return public_file_response(
232
+ 'public/js',
233
+ filename,
234
+ media_type='application/javascript',
235
+ )
236
+
237
+
238
+ @app.get('/assets/{filename:path}')
239
+ async def serve_assets(filename: str):
240
+ return public_file_response('public/assets', filename)
241
+
242
+
243
+ @app.get('/uploads/{filename:path}')
244
+ async def serve_uploads(filename: str):
245
+ return public_file_response('public/uploads', filename)
246
+
247
+
248
+ @app.get('/favicon.ico')
249
+ async def favicon():
250
+ file_path = Path('public/favicon.ico')
251
+ if not file_path.exists():
252
+ return Response(status_code=404)
253
+ return FileResponse(file_path, media_type='image/x-icon')
254
+
255
+ # ====
256
+ # Pure ASGI Middleware Classes
257
+ # ====
258
+
259
+
260
+ class CSRFMiddleware:
261
+ """CSRF middleware that properly handles session modifications."""
262
+
263
+ def __init__(self, app: ASGIApp): self.app = app
264
+
265
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
266
+ if scope["type"] != "http":
267
+ await self.app(scope, receive, send)
268
+ return
269
+ request = Request(scope, receive, send)
270
+ csrf_token = request.session.get("csrf_token")
271
+ if not csrf_token:
272
+ csrf_token = secrets.token_hex(32)
273
+ request.session["csrf_token"] = csrf_token
274
+
275
+ async def send_wrapper(message):
276
+ if message["type"] == "http.response.start":
277
+ cookie_value = f"{CSRF_COOKIE_NAME}={csrf_token}; Path=/; SameSite=Lax"
278
+ if IS_PRODUCTION:
279
+ cookie_value += "; Secure"
280
+ new_headers = list(message.get("headers", []))
281
+ new_headers.append((b"set-cookie", cookie_value.encode()))
282
+ message = {**message, "headers": new_headers}
283
+ await send(message)
284
+ await self.app(scope, receive, send_wrapper)
285
+
286
+
287
+ class SecurityHeadersMiddleware:
288
+ """Attach baseline browser security headers to HTTP responses."""
289
+
290
+ def __init__(self, app: ASGIApp): self.app = app
291
+
292
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
293
+ if scope["type"] != "http":
294
+ await self.app(scope, receive, send)
295
+ return
296
+
297
+ async def send_wrapper(message):
298
+ if message["type"] == "http.response.start":
299
+ raw_headers = list(message.get("headers", []))
300
+ headers = MutableHeaders(raw=raw_headers)
301
+ for name, value in _build_security_headers().items():
302
+ if headers.get(name) is None:
303
+ headers[name] = value
304
+ message = {**message, "headers": raw_headers}
305
+ await send(message)
306
+
307
+ await self.app(scope, receive, send_wrapper)
308
+
309
+
310
+ class BodySizeLimitMiddleware:
311
+ """Reject oversized HTTP request bodies before route or RPC parsing."""
312
+
313
+ def __init__(self, app: ASGIApp): self.app = app
314
+
315
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
316
+ if scope["type"] != "http":
317
+ await self.app(scope, receive, send)
318
+ return
319
+
320
+ headers = MutableHeaders(scope=scope)
321
+ content_length = headers.get("content-length")
322
+ if content_length:
323
+ try:
324
+ if int(content_length) > MAX_CONTENT_LENGTH_BYTES:
325
+ await self._send_too_large(send)
326
+ return
327
+ except ValueError:
328
+ pass
329
+
330
+ received = 0
331
+ response_started = False
332
+
333
+ async def limited_receive():
334
+ nonlocal received
335
+ message = await receive()
336
+ if message["type"] == "http.request":
337
+ received += len(message.get("body", b""))
338
+ if received > MAX_CONTENT_LENGTH_BYTES:
339
+ raise RequestBodyTooLarge()
340
+ return message
341
+
342
+ async def send_wrapper(message):
343
+ nonlocal response_started
344
+ if message["type"] == "http.response.start":
345
+ response_started = True
346
+ await send(message)
347
+
348
+ try:
349
+ await self.app(scope, limited_receive, send_wrapper)
350
+ except RequestBodyTooLarge:
351
+ if not response_started:
352
+ await self._send_too_large(send)
353
+
354
+ async def _send_too_large(self, send: Send):
355
+ response = Response(
356
+ content="Request body too large.",
357
+ status_code=413,
358
+ media_type="text/plain",
359
+ )
360
+
361
+ async def receive_empty_body():
362
+ return {"type": "http.request", "body": b"", "more_body": False}
363
+
364
+ await response(
365
+ {"type": "http", "method": "POST", "path": "/", "headers": []},
366
+ receive=receive_empty_body,
367
+ send=send,
368
+ )
369
+
370
+
371
+ class AuthMiddleware:
372
+ """Auth middleware using pure ASGI pattern for proper session handling."""
373
+
374
+ def __init__(self, app: ASGIApp): self.app = app
375
+
376
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
377
+ if scope["type"] != "http":
378
+ await self.app(scope, receive, send)
379
+ return
380
+ request = Request(scope, receive, send)
381
+ path = request.url.path
382
+ if path.startswith(('/css/', '/js/', '/assets/', '/favicon.ico')):
383
+ await self.app(scope, receive, send)
384
+ return
385
+ StateManager.init(request)
386
+ Auth.set_request(request)
387
+ auth_inst = Auth.get_instance()
388
+ providers = Auth.get_providers()
389
+
390
+ if providers:
391
+ oauth_response = await auth_inst.auth_providers(*providers)
392
+ if oauth_response:
393
+ await oauth_response(scope, receive, send)
394
+ return
395
+ is_authenticated = auth_inst.is_authenticated()
396
+ if is_authenticated:
397
+ auth_inst.refresh_session()
398
+ if auth_inst.is_public_route(path):
399
+ await self.app(scope, receive, send)
400
+ return
401
+ if auth_inst.is_auth_route(path):
402
+ if is_authenticated:
403
+ await RedirectResponse(
404
+ url=auth_inst.settings.default_signin_redirect,
405
+ status_code=303
406
+ )(scope, receive, send)
407
+ return
408
+ await self.app(scope, receive, send)
409
+ return
410
+
411
+ if auth_inst.settings.is_role_based:
412
+ required_roles = auth_inst.get_required_roles(path)
413
+ if required_roles:
414
+ if not is_authenticated:
415
+ await RedirectResponse(url=f'/signin?next={path}', status_code=303)(scope, receive, send)
416
+ return
417
+ if not auth_inst.check_role(auth_inst.get_payload(), required_roles):
418
+ await RedirectResponse(url='/unauthorized', status_code=303)(scope, receive, send)
419
+ return
420
+
421
+ if auth_inst.is_private_route(path):
422
+ if not is_authenticated:
423
+ await RedirectResponse(url=f'/signin?next={path}', status_code=303)(scope, receive, send)
424
+ return
425
+
426
+ await self.app(scope, receive, send)
427
+
428
+
429
+ class RPCMiddleware:
430
+ """RPC middleware using pure ASGI pattern."""
431
+
432
+ def __init__(self, app: ASGIApp): self.app = app
433
+
434
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
435
+ if scope["type"] != "http":
436
+ await self.app(scope, receive, send)
437
+ return
438
+ request = Request(scope, receive, send)
439
+ if request.headers.get('X-PP-RPC') == 'true' and request.method == 'POST':
440
+ from casp.rpc import _handle_rpc_request
441
+ session = dict(request.session) if hasattr(
442
+ request, 'session') else {}
443
+ response = await _handle_rpc_request(request, session)
444
+ await response(scope, receive, send)
445
+ return
446
+ await self.app(scope, receive, send)
447
+
448
+
449
+ class RequestDiagnosticsMiddleware:
450
+ """Log request start/end in dev and fail visibly when a route stalls."""
451
+
452
+ def __init__(self, app: ASGIApp): self.app = app
453
+
454
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
455
+ if scope["type"] != "http":
456
+ await self.app(scope, receive, send)
457
+ return
458
+
459
+ method = scope.get("method", "GET")
460
+ path = scope.get("path", "")
461
+ should_log = not path.startswith(
462
+ ('/css/', '/js/', '/assets/', '/favicon.ico'))
463
+ started = time.perf_counter()
464
+
465
+ if should_log and not IS_PRODUCTION:
466
+ print(f"[request:start] {method} {path}", flush=True)
467
+
468
+ try:
469
+ await asyncio.wait_for(
470
+ self.app(scope, receive, send),
471
+ timeout=REQUEST_TIMEOUT_SECONDS,
472
+ )
473
+ except asyncio.TimeoutError:
474
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
475
+ print(
476
+ f"[request:timeout] {method} {path} exceeded "
477
+ f"{REQUEST_TIMEOUT_SECONDS:g}s after {elapsed_ms}ms",
478
+ flush=True,
479
+ )
480
+ response = HTMLResponse(
481
+ content=(
482
+ "<h1>504 - Request Timeout</h1>"
483
+ "<p>The route took too long to respond. "
484
+ "Check the development terminal for the stalled path.</p>"
485
+ ),
486
+ status_code=504,
487
+ )
488
+ await response(scope, receive, send)
489
+ return
490
+ except Exception:
491
+ if should_log and not IS_PRODUCTION:
492
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
493
+ print(
494
+ f"[request:error] {method} {path} after {elapsed_ms}ms", flush=True)
495
+ raise
496
+ finally:
497
+ if should_log and not IS_PRODUCTION:
498
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
499
+ print(
500
+ f"[request:end] {method} {path} {elapsed_ms}ms", flush=True)
501
+
502
+
503
+ # ====
504
+ # WebSocket Routes
505
+ # ====
506
+
507
+
508
+ async def _run_websocket_channel(
509
+ websocket: WebSocket,
510
+ manager: Any,
511
+ payload: dict[str, Any] | None,
512
+ ready_message: str,
513
+ ):
514
+ await manager.connect(websocket)
515
+ ready_payload: dict[str, Any] = {
516
+ "type": "ready",
517
+ "message": ready_message,
518
+ }
519
+ if payload is not None:
520
+ ready_payload["payload"] = payload
521
+ await websocket.send_json(ready_payload)
522
+
523
+ try:
524
+ while True:
525
+ try:
526
+ raw_message = await asyncio.wait_for(
527
+ websocket.receive_text(),
528
+ timeout=WEBSOCKET_IDLE_TIMEOUT_SECONDS,
529
+ )
530
+ except asyncio.TimeoutError:
531
+ await websocket.close(code=status.WS_1000_NORMAL_CLOSURE)
532
+ return
533
+
534
+ if len(raw_message.encode("utf-8")) > MAX_WEBSOCKET_MESSAGE_BYTES:
535
+ await websocket.close(code=status.WS_1009_MESSAGE_TOO_BIG)
536
+ return
537
+
538
+ try:
539
+ message = json.loads(raw_message)
540
+ except json.JSONDecodeError:
541
+ await websocket.send_json({
542
+ "type": "error",
543
+ "message": "Messages must be valid JSON.",
544
+ })
545
+ continue
546
+
547
+ if not isinstance(message, dict):
548
+ await websocket.send_json({
549
+ "type": "error",
550
+ "message": "Messages must be JSON objects.",
551
+ })
552
+ continue
553
+
554
+ message_type = str(message.get("type", "message"))
555
+ if message_type == "ping":
556
+ await websocket.send_json({"type": "pong", "time": int(time.time())})
557
+ continue
558
+
559
+ text = str(message.get("text", "")).strip()
560
+ if not text:
561
+ await websocket.send_json({
562
+ "type": "error",
563
+ "message": "Message text is required.",
564
+ })
565
+ continue
566
+
567
+ outgoing_payload: dict[str, Any] = {
568
+ "type": "message",
569
+ "text": text[:1000],
570
+ "time": int(time.time()),
571
+ }
572
+ if payload is not None:
573
+ outgoing_payload["payload"] = payload
574
+ await manager.broadcast_json(outgoing_payload)
575
+ except WebSocketDisconnect:
576
+ return
577
+ finally:
578
+ manager.disconnect(websocket)
579
+
580
+
581
+ @app.websocket(WEBSOCKET_PATH)
582
+ async def websocket_live_endpoint(websocket: WebSocket):
583
+ if not _is_websocket_origin_allowed(websocket):
584
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
585
+ return
586
+
587
+ session = get_websocket_session(websocket)
588
+ user_payload = get_authenticated_payload_from_session(
589
+ session,
590
+ Auth.get_instance(),
591
+ )
592
+ if user_payload is None:
593
+ await websocket.accept()
594
+ await websocket.send_json({
595
+ "type": "error",
596
+ "message": "Authentication required.",
597
+ })
598
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
599
+ return
600
+
601
+ await _run_websocket_channel(
602
+ websocket,
603
+ websocket_connections,
604
+ None,
605
+ "Private WebSocket connected.",
606
+ )
607
+
608
+ @app.websocket(PUBLIC_WEBSOCKET_PATH)
609
+ async def websocket_public_endpoint(websocket: WebSocket):
610
+ if not _is_websocket_origin_allowed(websocket):
611
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
612
+ return
613
+
614
+ await _run_websocket_channel(
615
+ websocket,
616
+ public_websocket_connections,
617
+ {"guest": True, "scope": "public"},
618
+ "Public WebSocket connected.",
619
+ )
620
+
621
+ # ====
622
+ # Route Registration
623
+ # ====
624
+
625
+
626
+ _route_module_cache = {}
627
+ _route_signature_cache = {}
628
+
629
+
630
+ def load_route_module(file_path: str):
631
+ abs_path = os.path.abspath(file_path)
632
+ try:
633
+ mtime_ns = os.stat(abs_path).st_mtime_ns
634
+ except OSError:
635
+ raise FileNotFoundError(f"Route module not found: {abs_path}")
636
+
637
+ cached = _route_module_cache.get(abs_path)
638
+ if cached is not None and cached[0] == mtime_ns:
639
+ return cached[1]
640
+
641
+ unique_id = hashlib.md5(abs_path.encode()).hexdigest()[:8]
642
+ module_name = f"page_{unique_id}"
643
+ spec = importlib.util.spec_from_file_location(module_name, abs_path)
644
+ assert spec is not None and spec.loader is not None, f"Cannot load spec for {file_path}"
645
+ module = importlib.util.module_from_spec(spec)
646
+ spec.loader.exec_module(module)
647
+ setattr(module, 'render_page', render_page)
648
+ _route_module_cache[abs_path] = (mtime_ns, module)
649
+ _route_signature_cache.pop(abs_path, None)
650
+ return module
651
+
652
+
653
+ def get_page_signature(file_path: str, page_func):
654
+ abs_path = os.path.abspath(file_path)
655
+ cached = _route_signature_cache.get(abs_path)
656
+ if cached is not None and cached[0] is page_func:
657
+ return cached[1]
658
+
659
+ sig = inspect.signature(page_func)
660
+ _route_signature_cache[abs_path] = (page_func, sig)
661
+ return sig
662
+
663
+
664
+ def _unwrap_optional(annotation: Any) -> Any:
665
+ """
666
+ Optional[T] is Union[T, NoneType]. Return T when applicable.
667
+ """
668
+ origin = get_origin(annotation)
669
+ if origin is Union:
670
+ args = [a for a in get_args(annotation) if a is not type(None)]
671
+ if len(args) == 1:
672
+ return args[0]
673
+ return annotation
674
+
675
+
676
+ def _coerce_scalar(value: Optional[str], annotation: Any) -> Any:
677
+ """
678
+ Coerce a single query value based on annotation (best-effort).
679
+ If value is None -> returns None.
680
+ If coercion fails -> returns original string.
681
+ """
682
+ if value is None:
683
+ return None
684
+
685
+ ann = _unwrap_optional(annotation)
686
+
687
+ try:
688
+ if ann is inspect._empty or ann is str or ann is Any:
689
+ return value
690
+ if ann is int:
691
+ return int(value)
692
+ if ann is float:
693
+ return float(value)
694
+ if ann is bool:
695
+ v = value.strip().lower()
696
+ if v in ("1", "true", "t", "yes", "y", "on"):
697
+ return True
698
+ if v in ("0", "false", "f", "no", "n", "off"):
699
+ return False
700
+ return bool(value)
701
+ return value
702
+ except Exception:
703
+ return value
704
+
705
+
706
+ def _coerce_query_param(request: Request, name: str, param: inspect.Parameter) -> Any:
707
+ """
708
+ Supports:
709
+ - scalar types: str/int/float/bool/Optional[...]
710
+ - list types: list[str], list[int], etc. via ?x=a&x=b
711
+ - Optional[list[T]]
712
+ """
713
+ ann = param.annotation
714
+ origin = get_origin(ann)
715
+
716
+ # list[T]
717
+ if origin is list:
718
+ inner = get_args(ann)[0] if get_args(ann) else str
719
+ values = request.query_params.getlist(name)
720
+ return [_coerce_scalar(v, inner) for v in values]
721
+
722
+ # Optional[list[T]] -> Union[list[T], None]
723
+ unwrapped = _unwrap_optional(ann)
724
+ if get_origin(unwrapped) is list:
725
+ inner = get_args(unwrapped)[0] if get_args(unwrapped) else str
726
+ values = request.query_params.getlist(name)
727
+ return [_coerce_scalar(v, inner) for v in values]
728
+
729
+ # scalar
730
+ return _coerce_scalar(request.query_params.get(name), ann)
731
+
732
+
733
+ def register_routes():
734
+ idx = get_files_index()
735
+ for route in idx.routes:
736
+ base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
737
+ file_name = "index.py" if route.has_py else "index.html"
738
+ full_path = f"{base_path}/{file_name}".replace('//', '/')
739
+ register_single_route(route.fastapi_rule, full_path)
740
+
741
+
742
+ def register_single_route(url_pattern: str, file_path: str):
743
+ async def make_handler(request: Request):
744
+ _runtime_metadata.set(None)
745
+ _runtime_injections.set({"head": [], "body": []})
746
+
747
+ kwargs = dict(request.path_params)
748
+ current_uri = request.url.path
749
+
750
+ # 1. Cache Check (Fast Path)
751
+ if CACHE_ENABLED and request.method == 'GET':
752
+ cached_resp = CacheHandler.serve_cache(current_uri, DEFAULT_TTL)
753
+ if cached_resp:
754
+ return HTMLResponse(content=cached_resp)
755
+
756
+ route_dir = os.path.dirname(file_path)
757
+ page_metadata = {}
758
+ page_layout_props = {}
759
+ content = ""
760
+
761
+ req_should_cache = None
762
+ req_cache_ttl = 0
763
+
764
+ page_content_source = file_path
765
+
766
+ if file_path.endswith('.py'):
767
+ module = load_route_module(file_path)
768
+ if not hasattr(module, 'page'):
769
+ raise AttributeError(f"Missing 'def page():' in {file_path}")
770
+
771
+ sig = get_page_signature(file_path, module.page)
772
+ call_kwargs = {}
773
+ call_args = []
774
+
775
+ if kwargs:
776
+ call_args.append(kwargs)
777
+ if 'request' in sig.parameters:
778
+ call_kwargs['request'] = request
779
+
780
+ for name, param in sig.parameters.items():
781
+ if name in call_kwargs:
782
+ continue
783
+ if name in ("kwargs",):
784
+ continue
785
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
786
+ continue
787
+ if name in request.query_params:
788
+ call_kwargs[name] = _coerce_query_param(
789
+ request, name, param)
790
+
791
+ if inspect.iscoroutinefunction(module.page):
792
+ result = await module.page(*call_args, **call_kwargs)
793
+ else:
794
+ result = module.page(*call_args, **call_kwargs)
795
+
796
+ if isinstance(result, Response):
797
+ return result
798
+
799
+ if inspect.isasyncgen(result) or inspect.isgenerator(result):
800
+ return SSE(result)
801
+
802
+ cache_settings = getattr(module, 'cache_settings', None)
803
+ if cache_settings:
804
+ req_should_cache = cache_settings.enabled
805
+ req_cache_ttl = cache_settings.ttl
806
+
807
+ if isinstance(result, tuple):
808
+ page_content = result[0]
809
+ content = str(page_content)
810
+ page_content_source = getattr(
811
+ page_content, 'source_path', file_path)
812
+ if len(result) >= 2 and isinstance(result[1], dict):
813
+ page_layout_props = result[1]
814
+ else:
815
+ content = str(result)
816
+ page_content_source = getattr(result, 'source_path', file_path)
817
+
818
+ dynamic_meta = _runtime_metadata.get()
819
+ static_meta = getattr(module, 'metadata', None)
820
+
821
+ def extract_meta(obj):
822
+ d = {}
823
+ if not obj:
824
+ return d
825
+ if obj.title:
826
+ d['title'] = obj.title
827
+ if obj.description:
828
+ d['description'] = obj.description
829
+ if obj.extra:
830
+ d.update(obj.extra)
831
+ return d
832
+
833
+ page_metadata.update(extract_meta(static_meta))
834
+ page_metadata.update(extract_meta(dynamic_meta))
835
+ else:
836
+ content = load_template_file(file_path)
837
+
838
+ content = await transform_components(content, base_dir=route_dir)
839
+ full_context = {**kwargs, "request": request, **page_layout_props}
840
+
841
+ html_output, root_layout_id = await render_with_nested_layouts(
842
+ children=content,
843
+ route_dir=route_dir,
844
+ page_metadata=page_metadata,
845
+ page_layout_props=page_layout_props,
846
+ context_data=full_context,
847
+ page_component_source=page_content_source,
848
+ control_mode=True,
849
+ component_compiler=transform_components
850
+ )
851
+
852
+ html_output = transform_scripts(html_output)
853
+ response = HTMLResponse(content=html_output)
854
+ response.headers['X-PP-Root-Layout'] = root_layout_id
855
+
856
+ # Cache Save Logic
857
+ should_cache = False
858
+ if req_should_cache is True:
859
+ should_cache = True
860
+ elif req_should_cache is False:
861
+ should_cache = False
862
+ else:
863
+ should_cache = CACHE_ENABLED
864
+
865
+ if should_cache and request.method == 'GET':
866
+ ttl_to_save = req_cache_ttl if req_cache_ttl > 0 else DEFAULT_TTL
867
+ CacheHandler.save_cache(current_uri, html_output, ttl_to_save)
868
+
869
+ return response
870
+
871
+ endpoint = file_path.replace('/', '_').replace('\\', '_').replace(
872
+ '.', '_').replace('[', '').replace(']', '').replace('(', '').replace(')', '')
873
+
874
+ route_methods = ['GET', 'POST']
875
+ if file_path.endswith('.py'):
876
+ module = load_route_module(file_path)
877
+ declared_route_methods = getattr(module, 'route_methods', None)
878
+ if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
879
+ normalized_methods = [
880
+ str(method).strip().upper()
881
+ for method in declared_route_methods
882
+ if str(method).strip()
883
+ ]
884
+ if normalized_methods:
885
+ route_methods = list(dict.fromkeys(normalized_methods))
886
+
887
+ app.add_api_route(url_pattern, make_handler,
888
+ methods=route_methods, name=endpoint)
889
+
890
+
891
+ register_routes()
892
+ register_rpc_routes(app)
893
+
894
+ # ====
895
+ # Custom Exception Handlers (404 & 500)
896
+ # ====
897
+
898
+
899
+ @app.exception_handler(StarletteHTTPException)
900
+ async def custom_404_handler(request: Request, exc: StarletteHTTPException):
901
+ if exc.status_code == 404:
902
+ not_found_path = os.path.join('src', 'app', 'not-found.html')
903
+ if os.path.exists(not_found_path):
904
+ with open(not_found_path, 'r', encoding='utf-8') as f:
905
+ content = f.read()
906
+ html_output, root_layout_id = await render_with_nested_layouts(
907
+ children=content,
908
+ route_dir='src/app',
909
+ page_metadata={
910
+ 'title': "Page Not Found",
911
+ 'description': "The page you are looking for does not exist."
912
+ },
913
+ page_layout_props=None,
914
+ context_data={'request': request},
915
+ page_component_source=not_found_path,
916
+ control_mode=True,
917
+ transform_fn=transform_scripts
918
+ )
919
+ resp = HTMLResponse(content=html_output, status_code=404)
920
+ resp.headers['X-PP-Root-Layout'] = root_layout_id
921
+ return resp
922
+ return HTMLResponse(content=f"<h1>{exc.detail}</h1>", status_code=exc.status_code)
923
+
924
+
925
+ @app.exception_handler(Exception)
926
+ async def custom_general_exception_handler(request: Request, exc: Exception):
927
+ full_trace = traceback.format_exc()
928
+ if IS_PRODUCTION:
929
+ print(
930
+ f"[error] {request.method} {request.url.path}: {exc.__class__.__name__}",
931
+ flush=True,
932
+ )
933
+ else:
934
+ print(full_trace)
935
+ error_message = _client_error_message(exc)
936
+ error_trace = full_trace if _should_show_error_trace(request) else None
937
+
938
+ error_page_path = os.path.join('src', 'app', 'error.html')
939
+ if os.path.exists(error_page_path):
940
+ with open(error_page_path, 'r', encoding='utf-8') as f:
941
+ raw_content = f.read()
942
+ context_data = {'request': request,
943
+ 'error_message': error_message, 'error_trace': error_trace}
944
+ try:
945
+ rendered_content = compile_template(
946
+ raw_content).render(**context_data)
947
+ html_output, root_layout_id = await render_with_nested_layouts(
948
+ children=rendered_content,
949
+ route_dir='src/app',
950
+ page_metadata={
951
+ 'title': 'Application Error',
952
+ 'description': 'An unexpected error occurred.'
953
+ },
954
+ page_layout_props=None,
955
+ context_data=context_data,
956
+ page_component_source=error_page_path,
957
+ control_mode=True,
958
+ transform_fn=transform_scripts
959
+ )
960
+ resp = HTMLResponse(content=html_output, status_code=500)
961
+ resp.headers['X-PP-Root-Layout'] = root_layout_id
962
+ return resp
963
+ except Exception as render_exc:
964
+ print("Error rendering error.html:", render_exc)
965
+ return HTMLResponse(
966
+ content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
967
+ status_code=500
968
+ )
969
+
970
+ # ====
971
+ # Middleware Order (LAST added runs FIRST)
972
+ # ====
973
+ app.add_middleware(RPCMiddleware)
974
+ app.add_middleware(AuthMiddleware)
975
+ app.add_middleware(CSRFMiddleware)
976
+
977
+ app.add_middleware(
978
+ SessionMiddleware,
979
+ secret_key=_get_session_secret(),
980
+ session_cookie=SESSION_COOKIE_NAME,
981
+ max_age=SESSION_LIFETIME_HOURS * 3600,
982
+ same_site='lax',
983
+ https_only=IS_PRODUCTION,
984
+ path='/',
985
+ )
986
+ app.add_middleware(BodySizeLimitMiddleware)
987
+ app.add_middleware(SecurityHeadersMiddleware)
988
+
989
+ if not IS_PRODUCTION:
990
+ app.add_middleware(RequestDiagnosticsMiddleware)
991
+
992
+ if __name__ == '__main__':
993
+ port = int(os.getenv('PORT', 5091))
994
+ workers = max(1, int(os.getenv('UVICORN_WORKERS', '1')))
995
+ uvicorn.run(
996
+ "main:app",
997
+ host="0.0.0.0",
998
+ port=port,
999
+ reload=False,
1000
+ workers=workers,
1001
+ )