create-caspian-app 1.3.26 → 1.4.0
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.
- package/README.md +158 -326
- package/dist/.github/copilot-instructions.md +16 -10
- package/dist/AGENTS.md +28 -3
- package/dist/index.js +1 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/src/app/error.py +22 -39
- package/dist/src/app/not_found.py +3 -7
- package/dist/src/lib/auth/auth_config.py +0 -4
- package/dist/src/lib/websocket/sockets.py +15 -38
- package/package.json +1 -1
package/dist/src/app/error.py
CHANGED
|
@@ -20,21 +20,9 @@ def page(error_message: str, error_trace: Optional[str] = None):
|
|
|
20
20
|
fill="none"
|
|
21
21
|
xmlns="http://www.w3.org/2000/svg"
|
|
22
22
|
aria-hidden="true">
|
|
23
|
-
<path d="M11.001 2a1 1 0 0 1 .998 0L21 7v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7l8.001-5z"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
stroke-linecap="round"
|
|
27
|
-
stroke-linejoin="round" />
|
|
28
|
-
<path d="M12 8v4"
|
|
29
|
-
stroke="currentColor"
|
|
30
|
-
stroke-width="1.5"
|
|
31
|
-
stroke-linecap="round"
|
|
32
|
-
stroke-linejoin="round" />
|
|
33
|
-
<path d="M12 16h.01"
|
|
34
|
-
stroke="currentColor"
|
|
35
|
-
stroke-width="1.5"
|
|
36
|
-
stroke-linecap="round"
|
|
37
|
-
stroke-linejoin="round" />
|
|
23
|
+
<path d="M11.001 2a1 1 0 0 1 .998 0L21 7v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7l8.001-5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
24
|
+
<path d="M12 8v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
25
|
+
<path d="M12 16h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
38
26
|
</svg>
|
|
39
27
|
|
|
40
28
|
<div class="flex-1">
|
|
@@ -51,37 +39,29 @@ def page(error_message: str, error_trace: Optional[str] = None):
|
|
|
51
39
|
Home
|
|
52
40
|
</a>
|
|
53
41
|
<button class="inline-block rounded bg-blue-600 px-3 py-1.5 text-sm text-white"
|
|
54
|
-
onclick="window.location.reload()">
|
|
55
|
-
Reload
|
|
56
|
-
</button>
|
|
42
|
+
onclick="window.location.reload()">Reload</button>
|
|
57
43
|
{% if error_trace %}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{showTrace ? "Hide details" : "Show details"}
|
|
61
|
-
</button>
|
|
44
|
+
<button class="inline-block rounded border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-black"
|
|
45
|
+
onclick="setShowTrace(current => !current)">{showTrace ? "Hide details" : "Show details"}</button>
|
|
62
46
|
{% endif %}
|
|
63
47
|
</div>
|
|
64
48
|
</div>
|
|
65
49
|
</div>
|
|
66
50
|
|
|
67
51
|
{% if error_trace %}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
onclick="downloadTrace()">
|
|
78
|
-
Download
|
|
79
|
-
</button>
|
|
52
|
+
<section class="mt-4" hidden="{!showTrace}">
|
|
53
|
+
<div class="mb-2 flex items-center justify-between">
|
|
54
|
+
<div class="text-xs text-gray-500">Error details (development only)</div>
|
|
55
|
+
<div class="flex gap-2">
|
|
56
|
+
<button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
|
|
57
|
+
onclick="copyTrace()">{traceCopied ? "Copied" : "Copy"}</button>
|
|
58
|
+
<button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
|
|
59
|
+
onclick="downloadTrace()">Download</button>
|
|
60
|
+
</div>
|
|
80
61
|
</div>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
</section>
|
|
62
|
+
<pre pp-ref="{tracePre}"
|
|
63
|
+
class="overflow-auto rounded bg-gray-100 p-4 text-xs text-black">{{ error_trace }}</pre>
|
|
64
|
+
</section>
|
|
85
65
|
{% endif %}
|
|
86
66
|
|
|
87
67
|
<script>
|
|
@@ -109,4 +89,7 @@ def page(error_message: str, error_trace: Optional[str] = None):
|
|
|
109
89
|
</script>
|
|
110
90
|
</div>
|
|
111
91
|
</main>
|
|
112
|
-
""",
|
|
92
|
+
""",
|
|
93
|
+
error_message=error_message,
|
|
94
|
+
error_trace=error_trace,
|
|
95
|
+
)
|
|
@@ -15,16 +15,12 @@ def page():
|
|
|
15
15
|
<div class="text-center">
|
|
16
16
|
<h1 class="text-9xl font-black text-gray-200">404</h1>
|
|
17
17
|
|
|
18
|
-
<p class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl">
|
|
19
|
-
Uh-oh!
|
|
20
|
-
</p>
|
|
18
|
+
<p class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl">Uh-oh!</p>
|
|
21
19
|
|
|
22
20
|
<p class="mt-4 text-gray-500">We can't find that page.</p>
|
|
23
21
|
|
|
24
|
-
<a
|
|
25
|
-
|
|
26
|
-
class="mt-6 inline-block rounded bg-indigo-600 px-5 py-3 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring"
|
|
27
|
-
>
|
|
22
|
+
<a href="/"
|
|
23
|
+
class="mt-6 inline-block rounded bg-indigo-600 px-5 py-3 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring">
|
|
28
24
|
Go Back Home
|
|
29
25
|
</a>
|
|
30
26
|
</div>
|
|
@@ -21,7 +21,6 @@ def build_auth_settings() -> AuthSettings:
|
|
|
21
21
|
default_token_validity="1h",
|
|
22
22
|
# Sliding-session refresh only matters when the request flow calls auth.refresh_session().
|
|
23
23
|
token_auto_refresh=False,
|
|
24
|
-
|
|
25
24
|
# Route protection
|
|
26
25
|
# This app-owned starter config begins public-first; switch to True only when most routes require auth.
|
|
27
26
|
# Use all-private mode when only a few routes should stay public.
|
|
@@ -30,11 +29,9 @@ def build_auth_settings() -> AuthSettings:
|
|
|
30
29
|
# Sign-in and signup stay public by default; only change this when the app explicitly needs it.
|
|
31
30
|
auth_routes=["/signin", "/signup"],
|
|
32
31
|
private_routes=[], # unused when all-routes-private is True
|
|
33
|
-
|
|
34
32
|
# Role-based access
|
|
35
33
|
is_role_based=False,
|
|
36
34
|
role_identifier="role",
|
|
37
|
-
|
|
38
35
|
# RBAC policy is app-owned here; the runtime expects ROUTE/PATTERN -> [ROLES].
|
|
39
36
|
# Example (when enabled):
|
|
40
37
|
# role_based_routes={
|
|
@@ -42,7 +39,6 @@ def build_auth_settings() -> AuthSettings:
|
|
|
42
39
|
# "/admin": ["admin", "superadmin"],
|
|
43
40
|
# },
|
|
44
41
|
role_based_routes={},
|
|
45
|
-
|
|
46
42
|
# Redirects / prefixes
|
|
47
43
|
default_signin_redirect="/dashboard",
|
|
48
44
|
default_signout_redirect="/signin",
|
|
@@ -123,9 +123,7 @@ def _rate_window_seconds() -> int:
|
|
|
123
123
|
# Ceiling on simultaneous open sockets. Every open connection is a live task
|
|
124
124
|
# plus a broadcast target, so an unbounded count lets cheap clients grow
|
|
125
125
|
# server memory and turn each broadcast into an amplification.
|
|
126
|
-
MAX_WEBSOCKET_CONNECTIONS = max(
|
|
127
|
-
1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200))
|
|
128
|
-
)
|
|
126
|
+
MAX_WEBSOCKET_CONNECTIONS = max(1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200)))
|
|
129
127
|
|
|
130
128
|
|
|
131
129
|
def _is_production() -> bool:
|
|
@@ -147,11 +145,7 @@ def _configured_websocket_origins() -> set[str]:
|
|
|
147
145
|
):
|
|
148
146
|
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
149
147
|
|
|
150
|
-
return {
|
|
151
|
-
_normalized_origin(origin)
|
|
152
|
-
for origin in raw_values
|
|
153
|
-
if _normalized_origin(origin)
|
|
154
|
-
}
|
|
148
|
+
return {_normalized_origin(origin) for origin in raw_values if _normalized_origin(origin)}
|
|
155
149
|
|
|
156
150
|
|
|
157
151
|
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
@@ -208,8 +202,7 @@ class SocketEntry:
|
|
|
208
202
|
SOCKET_REGISTRY: dict[str, SocketEntry] = {}
|
|
209
203
|
|
|
210
204
|
|
|
211
|
-
def socket(require_auth: bool = False,
|
|
212
|
-
allowed_roles: list[str] | None = None):
|
|
205
|
+
def socket(require_auth: bool = False, allowed_roles: list[str] | None = None):
|
|
213
206
|
"""Register an async function as a named socket.
|
|
214
207
|
|
|
215
208
|
The function's own name is the name the browser connects with, so it must
|
|
@@ -309,9 +302,7 @@ class _Shared:
|
|
|
309
302
|
"""State the `Socket` and every cloned `SocketSender` see together."""
|
|
310
303
|
|
|
311
304
|
def __init__(self) -> None:
|
|
312
|
-
self.outgoing: asyncio.Queue[str | None] = asyncio.Queue(
|
|
313
|
-
maxsize=SEND_QUEUE_SIZE
|
|
314
|
-
)
|
|
305
|
+
self.outgoing: asyncio.Queue[str | None] = asyncio.Queue(maxsize=SEND_QUEUE_SIZE)
|
|
315
306
|
self.closed = False
|
|
316
307
|
self.last_activity = time.monotonic()
|
|
317
308
|
|
|
@@ -452,8 +443,7 @@ class Socket:
|
|
|
452
443
|
return json.loads(text)
|
|
453
444
|
except json.JSONDecodeError as e:
|
|
454
445
|
raise ValueError(
|
|
455
|
-
f"`{self._name}` could not read a frame -- {e}. Each frame is "
|
|
456
|
-
"one JSON value."
|
|
446
|
+
f"`{self._name}` could not read a frame -- {e}. Each frame is one JSON value."
|
|
457
447
|
) from e
|
|
458
448
|
|
|
459
449
|
async def recv_text(self) -> str | None:
|
|
@@ -467,9 +457,7 @@ class Socket:
|
|
|
467
457
|
idle_timeout = _idle_timeout_seconds()
|
|
468
458
|
while not self._shared.closed:
|
|
469
459
|
try:
|
|
470
|
-
text = await asyncio.wait_for(
|
|
471
|
-
self._websocket.receive_text(), timeout=idle_timeout
|
|
472
|
-
)
|
|
460
|
+
text = await asyncio.wait_for(self._websocket.receive_text(), timeout=idle_timeout)
|
|
473
461
|
except asyncio.TimeoutError:
|
|
474
462
|
# Outbound traffic counts as liveness: a passive listener in
|
|
475
463
|
# an active room is not idle, it is listening.
|
|
@@ -520,7 +508,7 @@ class Socket:
|
|
|
520
508
|
"""Let queued frames drain, then reclaim the writer task."""
|
|
521
509
|
try:
|
|
522
510
|
await asyncio.wait_for(self._writer, timeout=5)
|
|
523
|
-
except
|
|
511
|
+
except asyncio.CancelledError, Exception:
|
|
524
512
|
self._writer.cancel()
|
|
525
513
|
self._shared.closed = True
|
|
526
514
|
|
|
@@ -574,9 +562,7 @@ def _authorize(websocket: WebSocket, entry: SocketEntry) -> _Refusal | None:
|
|
|
574
562
|
|
|
575
563
|
if auth.is_authenticated():
|
|
576
564
|
payload = auth.get_payload() or {}
|
|
577
|
-
if entry.allowed_roles and not auth.check_role(
|
|
578
|
-
payload, list(entry.allowed_roles)
|
|
579
|
-
):
|
|
565
|
+
if entry.allowed_roles and not auth.check_role(payload, list(entry.allowed_roles)):
|
|
580
566
|
return _Refusal(f"The socket `{entry.name}` is not available to this account.")
|
|
581
567
|
return None
|
|
582
568
|
|
|
@@ -596,9 +582,7 @@ async def _first_frame(websocket: WebSocket) -> dict[str, Any] | _Refusal | None
|
|
|
596
582
|
mid-handshake. Not an error, and nobody left to tell.
|
|
597
583
|
"""
|
|
598
584
|
try:
|
|
599
|
-
text = await asyncio.wait_for(
|
|
600
|
-
websocket.receive_text(), timeout=ARGS_TIMEOUT_SECONDS
|
|
601
|
-
)
|
|
585
|
+
text = await asyncio.wait_for(websocket.receive_text(), timeout=ARGS_TIMEOUT_SECONDS)
|
|
602
586
|
except asyncio.TimeoutError:
|
|
603
587
|
return _Refusal(
|
|
604
588
|
"This socket opened and sent no arguments. The first frame is the "
|
|
@@ -631,14 +615,13 @@ def _call_kwargs(
|
|
|
631
615
|
from the wire.
|
|
632
616
|
"""
|
|
633
617
|
parameters = inspect.signature(entry.handler).parameters.values()
|
|
634
|
-
accepts_kwargs = any(
|
|
635
|
-
p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters
|
|
636
|
-
)
|
|
618
|
+
accepts_kwargs = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters)
|
|
637
619
|
|
|
638
620
|
accepted = {
|
|
639
621
|
p.name
|
|
640
622
|
for p in parameters
|
|
641
|
-
if p.kind
|
|
623
|
+
if p.kind
|
|
624
|
+
in (
|
|
642
625
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
643
626
|
inspect.Parameter.KEYWORD_ONLY,
|
|
644
627
|
)
|
|
@@ -647,15 +630,13 @@ def _call_kwargs(
|
|
|
647
630
|
missing = [
|
|
648
631
|
p.name
|
|
649
632
|
for p in parameters
|
|
650
|
-
if p.name in accepted
|
|
651
|
-
and p.default is inspect.Parameter.empty
|
|
652
|
-
and p.name not in args
|
|
633
|
+
if p.name in accepted and p.default is inspect.Parameter.empty and p.name not in args
|
|
653
634
|
]
|
|
654
635
|
if missing:
|
|
655
636
|
return _Refusal(
|
|
656
637
|
f"`{entry.name}` is missing its `{missing[0]}` argument. The "
|
|
657
638
|
f'browser opens pp.socket("{entry.name}", '
|
|
658
|
-
f
|
|
639
|
+
f"{{ {missing[0]}: ... }})."
|
|
659
640
|
)
|
|
660
641
|
|
|
661
642
|
if accepts_kwargs:
|
|
@@ -729,11 +710,7 @@ async def serve_named_socket(websocket: WebSocket) -> None:
|
|
|
729
710
|
except Exception as e:
|
|
730
711
|
print(f"[Socket Error] {entry.name}: {e}")
|
|
731
712
|
traceback.print_exc()
|
|
732
|
-
message = (
|
|
733
|
-
"Internal server error"
|
|
734
|
-
if is_production_environment()
|
|
735
|
-
else f"{entry.name}: {e}"
|
|
736
|
-
)
|
|
713
|
+
message = "Internal server error" if is_production_environment() else f"{entry.name}: {e}"
|
|
737
714
|
await sock.sender()._error(message)
|
|
738
715
|
await sock._finish()
|
|
739
716
|
finally:
|