create-caspian-app 1.2.0 → 1.3.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/dist/main.py CHANGED
@@ -48,7 +48,6 @@ from casp.auth import (
48
48
  from casp.rpc import register_rpc_routes, rpc_limiter
49
49
  from casp.layout import (
50
50
  render_with_nested_layouts,
51
- compile_template,
52
51
  _finalize_page_region,
53
52
  _runtime_injections,
54
53
  _runtime_metadata,
@@ -1396,25 +1395,83 @@ if mcp_app is not None:
1396
1395
  # ====
1397
1396
 
1398
1397
 
1398
+ async def _render_special_page(
1399
+ page_path: str,
1400
+ request: Request,
1401
+ default_metadata: dict[str, str],
1402
+ context_data: dict[str, Any],
1403
+ ) -> tuple[str, str]:
1404
+ """Render an app-level Python page through the normal page/layout pipeline."""
1405
+ _runtime_metadata.set(None)
1406
+ _runtime_injections.set({"head": [], "body": []})
1407
+
1408
+ module = load_route_module(page_path)
1409
+ if not hasattr(module, "page"):
1410
+ raise AttributeError(f"Missing 'def page():' in {page_path}")
1411
+
1412
+ signature = get_page_signature(page_path, module.page)
1413
+ accepts_kwargs = any(
1414
+ parameter.kind is inspect.Parameter.VAR_KEYWORD
1415
+ for parameter in signature.parameters.values()
1416
+ )
1417
+ call_context = {"request": request, **context_data}
1418
+ call_kwargs = {
1419
+ name: value
1420
+ for name, value in call_context.items()
1421
+ if accepts_kwargs or name in signature.parameters
1422
+ }
1423
+
1424
+ result = module.page(**call_kwargs)
1425
+ if inspect.isawaitable(result):
1426
+ result = await result
1427
+ if isinstance(result, Response):
1428
+ raise TypeError(f"Special page {page_path} must return markup, not a Response")
1429
+
1430
+ page_layout_props: dict[str, Any] = {}
1431
+ page_content = result
1432
+ if isinstance(result, tuple):
1433
+ page_content = result[0]
1434
+ if len(result) >= 2 and isinstance(result[1], dict):
1435
+ page_layout_props = result[1]
1436
+
1437
+ page_metadata = default_metadata.copy()
1438
+ for metadata_obj in (getattr(module, "metadata", None), _runtime_metadata.get()):
1439
+ if not metadata_obj:
1440
+ continue
1441
+ if metadata_obj.title:
1442
+ page_metadata["title"] = metadata_obj.title
1443
+ if metadata_obj.description:
1444
+ page_metadata["description"] = metadata_obj.description
1445
+ if metadata_obj.extra:
1446
+ page_metadata.update(metadata_obj.extra)
1447
+
1448
+ page_source = getattr(page_content, "source_path", page_path)
1449
+ html_output, root_layout_id = await render_with_nested_layouts(
1450
+ children=str(page_content),
1451
+ route_dir=os.path.dirname(page_path),
1452
+ page_metadata=page_metadata,
1453
+ page_layout_props=page_layout_props,
1454
+ context_data={**call_context, **page_layout_props},
1455
+ page_component_source=page_source,
1456
+ control_mode=True,
1457
+ component_compiler=transform_components,
1458
+ )
1459
+ return finalize_html(html_output), root_layout_id
1460
+
1461
+
1399
1462
  @app.exception_handler(StarletteHTTPException)
1400
1463
  async def custom_404_handler(request: Request, exc: StarletteHTTPException):
1401
1464
  if exc.status_code == 404:
1402
- not_found_path = os.path.join('src', 'app', 'not-found.html')
1465
+ not_found_path = os.path.join('src', 'app', 'not_found.py')
1403
1466
  if os.path.exists(not_found_path):
1404
- with open(not_found_path, 'r', encoding='utf-8') as f:
1405
- content = f.read()
1406
- html_output, root_layout_id = await render_with_nested_layouts(
1407
- children=content,
1408
- route_dir='src/app',
1409
- page_metadata={
1467
+ html_output, root_layout_id = await _render_special_page(
1468
+ page_path=not_found_path,
1469
+ request=request,
1470
+ default_metadata={
1410
1471
  'title': "Page Not Found",
1411
1472
  'description': "The page you are looking for does not exist."
1412
1473
  },
1413
- page_layout_props=None,
1414
- context_data={'request': request},
1415
- page_component_source=not_found_path,
1416
- control_mode=True,
1417
- transform_fn=finalize_html
1474
+ context_data={},
1418
1475
  )
1419
1476
  resp = HTMLResponse(content=html_output, status_code=404)
1420
1477
  resp.headers['X-PP-Root-Layout'] = root_layout_id
@@ -1429,33 +1486,25 @@ async def custom_general_exception_handler(request: Request, exc: Exception):
1429
1486
  error_message = _client_error_message(exc)
1430
1487
  error_trace = full_trace if not IS_PRODUCTION else None
1431
1488
 
1432
- error_page_path = os.path.join('src', 'app', 'error.html')
1489
+ error_page_path = os.path.join('src', 'app', 'error.py')
1433
1490
  if os.path.exists(error_page_path):
1434
- with open(error_page_path, 'r', encoding='utf-8') as f:
1435
- raw_content = f.read()
1436
1491
  context_data = {'request': request,
1437
1492
  'error_message': error_message, 'error_trace': error_trace}
1438
1493
  try:
1439
- rendered_content = compile_template(
1440
- raw_content).render(**context_data)
1441
- html_output, root_layout_id = await render_with_nested_layouts(
1442
- children=rendered_content,
1443
- route_dir='src/app',
1444
- page_metadata={
1494
+ html_output, root_layout_id = await _render_special_page(
1495
+ page_path=error_page_path,
1496
+ request=request,
1497
+ default_metadata={
1445
1498
  'title': 'Application Error',
1446
1499
  'description': 'An unexpected error occurred.'
1447
1500
  },
1448
- page_layout_props=None,
1449
1501
  context_data=context_data,
1450
- page_component_source=error_page_path,
1451
- control_mode=True,
1452
- transform_fn=finalize_html
1453
1502
  )
1454
1503
  resp = HTMLResponse(content=html_output, status_code=500)
1455
1504
  resp.headers['X-PP-Root-Layout'] = root_layout_id
1456
1505
  return resp
1457
1506
  except Exception as render_exc:
1458
- print("Error rendering error.html:", render_exc)
1507
+ print("Error rendering error.py:", render_exc)
1459
1508
  return HTMLResponse(
1460
1509
  content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
1461
1510
  status_code=500
@@ -0,0 +1,740 @@
1
+ """Named sockets: the server half of `pp.socket(...)`.
2
+
3
+ An rpc is a question with one answer, and an rpc stream is an answer that
4
+ arrives in pieces. A socket is the third shape: both sides may speak, at any
5
+ time, for as long as the page is open. The client half is `pp.socket(...)` in
6
+ the shipped PulsePoint runtime; this module is the server half, and
7
+ `@socket()` is what puts a function on it:
8
+
9
+ from src.lib.websocket.sockets import Socket, socket
10
+
11
+ @socket()
12
+ async def echo(label: str, socket: Socket):
13
+ while (text := await socket.recv()) is not None:
14
+ if not await socket.send(f"{label}: {text}"):
15
+ break # The browser is gone.
16
+
17
+ From the browser:
18
+
19
+ const sock = pp.socket("echo", { label: "you" }, {
20
+ onMessage: (value) => append(value),
21
+ });
22
+ sock.send("hello");
23
+
24
+ ## The wire
25
+
26
+ Every socket connects to one endpoint, `SOCKET_PATH`, naming its function in
27
+ the `name` query parameter. The arguments do not travel in the URL -- a URL is
28
+ logged by every proxy on the way -- but as the first text frame, one JSON
29
+ object, exactly the payload `pp.rpc` would have posted. Every frame after
30
+ that is one JSON value, in either direction.
31
+
32
+ One endpoint rather than one per page because an upgrade is a GET, and the
33
+ page already owns GET on its own URL. The names are therefore
34
+ application-wide, like global component rpc names, and a duplicate is refused
35
+ at registration time.
36
+
37
+ ## Failure
38
+
39
+ There is no status line inside an open connection, so failure is a frame:
40
+ `{"error": "..."}` -- that key alone -- followed by a close. The client
41
+ runtime routes it to `onError` rather than `onMessage`.
42
+
43
+ ## Registration timing
44
+
45
+ A `@socket()` in a route's `index.py` registers when that module is first
46
+ imported, which happens when the route first renders. The page that opens
47
+ `pp.socket(...)` has necessarily rendered first, so its sockets exist by the
48
+ time the browser connects. A shared socket used by several routes belongs in
49
+ `src/lib/**`, imported by each owning route.
50
+
51
+ ## Security posture
52
+
53
+ The endpoint keeps the same protections as the channel endpoints in
54
+ `main.py`: the anti-CSWSH origin check runs before the handshake is accepted,
55
+ auth delegates to Caspian's `Auth` (HTTP middleware never sees websocket
56
+ scopes), and every connection is subject to the shared connection cap,
57
+ message-size limit, per-connection message rate, and idle timeout. The socket
58
+ session is read-only: mutations are not persisted back to the cookie over a
59
+ WebSocket.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import asyncio
65
+ import inspect
66
+ import json
67
+ import os
68
+ import time
69
+ import traceback
70
+ from dataclasses import dataclass, field
71
+ from typing import Any, Awaitable, Callable
72
+ from urllib.parse import urlparse
73
+
74
+ from fastapi import WebSocket, status
75
+
76
+ from casp.auth import Auth
77
+
78
+ # Private, but deliberately the same serializer rpc responses go through, so a
79
+ # dataclass or model travels identically over both wires.
80
+ from casp.rpc import _serialize_result
81
+ from casp.runtime_security import is_production_environment
82
+
83
+ # Where every named socket connects. The path carries the PulsePoint
84
+ # runtime's name, not the server framework's, because `pp.socket(...)` is the
85
+ # same client whichever backend serves it — its DEFAULT_PATH and this
86
+ # constant must stay identical.
87
+ SOCKET_PATH = "/__pulsepoint/ws"
88
+
89
+ # How long the server waits for the first frame -- the arguments -- before
90
+ # giving up on a connection that opened and said nothing.
91
+ ARGS_TIMEOUT_SECONDS = 10
92
+
93
+ # How far a sender may run ahead of the wire before `send` applies
94
+ # backpressure by awaiting queue space.
95
+ SEND_QUEUE_SIZE = 32
96
+
97
+
98
+ def _idle_timeout_seconds() -> int:
99
+ return max(10, int(os.getenv("WEBSOCKET_IDLE_TIMEOUT_SECONDS", 120)))
100
+
101
+
102
+ def _max_message_bytes() -> int:
103
+ return max(256, int(os.getenv("MAX_WEBSOCKET_MESSAGE_BYTES", 4096)))
104
+
105
+
106
+ def _messages_per_window() -> int:
107
+ return max(1, int(os.getenv("MAX_WEBSOCKET_MESSAGES_PER_WINDOW", 20)))
108
+
109
+
110
+ def _rate_window_seconds() -> int:
111
+ return max(1, int(os.getenv("WEBSOCKET_RATE_WINDOW_SECONDS", 10)))
112
+
113
+
114
+ # ==== HANDSHAKE SECURITY ====
115
+ #
116
+ # The anti-CSWSH origin check and the connection ceiling, run before the
117
+ # handshake upgrades. Authorization itself is per socket (`require_auth=` /
118
+ # `allowed_roles=` delegated to Caspian's `Auth`), because HTTP middleware in
119
+ # `main.py` early-returns on every non-`http` scope -- a WebSocket handshake
120
+ # (`scope["type"] == "websocket"`) is never seen by `AuthMiddleware`, so this
121
+ # endpoint authorizes each connection itself.
122
+
123
+ # Ceiling on simultaneous open sockets. Every open connection is a live task
124
+ # plus a broadcast target, so an unbounded count lets cheap clients grow
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
+ )
129
+
130
+
131
+ def _is_production() -> bool:
132
+ # Shared fail-closed resolution: an unset or misspelled APP_ENV must not
133
+ # silently enable the development handshake relaxations below.
134
+ return is_production_environment()
135
+
136
+
137
+ def _normalized_origin(value: str) -> str:
138
+ return (value or "").strip().rstrip("/")
139
+
140
+
141
+ def _configured_websocket_origins() -> set[str]:
142
+ raw_values: list[str] = []
143
+ for env_name in (
144
+ "WEBSOCKET_ALLOWED_ORIGINS",
145
+ "CORS_ALLOWED_ORIGINS",
146
+ "APP_BASE_URL",
147
+ ):
148
+ raw_values.extend(os.getenv(env_name, "").split(","))
149
+
150
+ return {
151
+ _normalized_origin(origin)
152
+ for origin in raw_values
153
+ if _normalized_origin(origin)
154
+ }
155
+
156
+
157
+ def _websocket_same_origin(websocket: WebSocket) -> str:
158
+ scheme = "https" if websocket.url.scheme == "wss" else "http"
159
+ return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
160
+
161
+
162
+ def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
163
+ """Anti-CSWSH origin check. NOT authentication.
164
+
165
+ A browser cannot forge the `Origin` header, so this blocks cross-site
166
+ script-driven handshakes. A raw client (wscat, python websockets) can send
167
+ any origin, which is exactly why authentication is a separate gate.
168
+ """
169
+ origin = _normalized_origin(websocket.headers.get("origin", ""))
170
+ if not origin:
171
+ # No Origin header: tolerate local tooling in dev, reject in production.
172
+ return not _is_production()
173
+
174
+ parsed_origin = urlparse(origin)
175
+ if not parsed_origin.scheme or not parsed_origin.netloc:
176
+ return False
177
+
178
+ if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
179
+ return parsed_origin.scheme == "http"
180
+
181
+ allowed_origins = _configured_websocket_origins()
182
+
183
+ # The same-origin fallback is derived from the Host header, which a client
184
+ # controls directly and a misconfigured proxy will forward verbatim: sending
185
+ # `Host: evil.tld` with `Origin: https://evil.tld` would otherwise satisfy
186
+ # this check against itself. It is a convenience for development only --
187
+ # production must name its origins explicitly.
188
+ if not _is_production():
189
+ allowed_origins.add(_websocket_same_origin(websocket))
190
+
191
+ return origin in allowed_origins
192
+
193
+
194
+ # ==== REGISTRY ====
195
+
196
+ SocketHandler = Callable[..., Awaitable[None]]
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class SocketEntry:
201
+ name: str
202
+ source: str
203
+ require_auth: bool
204
+ allowed_roles: tuple[str, ...]
205
+ handler: SocketHandler
206
+
207
+
208
+ SOCKET_REGISTRY: dict[str, SocketEntry] = {}
209
+
210
+
211
+ def socket(require_auth: bool = False,
212
+ allowed_roles: list[str] | None = None):
213
+ """Register an async function as a named socket.
214
+
215
+ The function's own name is the name the browser connects with, so it must
216
+ be unique application-wide. The function declares its arguments as normal
217
+ parameters -- they arrive in the connection's first frame -- plus one
218
+ parameter named `socket`, which receives the open `Socket`.
219
+
220
+ - `require_auth=True` refuses the connection unless the request carries an
221
+ authenticated session, before the handler runs.
222
+ - `allowed_roles=[...]` adds RBAC via `Auth.check_role`, the same rule as
223
+ HTTP routes and rpc.
224
+ """
225
+
226
+ def decorator(func: SocketHandler) -> SocketHandler:
227
+ if not inspect.iscoroutinefunction(func):
228
+ raise TypeError(
229
+ f"@socket() function `{func.__name__}` must be `async def`: "
230
+ "a socket is a long-lived conversation, not a call."
231
+ )
232
+
233
+ parameters = inspect.signature(func).parameters
234
+ if "socket" not in parameters:
235
+ raise TypeError(
236
+ f"@socket() function `{func.__name__}` must declare a "
237
+ "`socket` parameter -- it receives the open connection."
238
+ )
239
+
240
+ source = f"{func.__module__}.{func.__qualname__}"
241
+ existing = SOCKET_REGISTRY.get(func.__name__)
242
+ if existing is not None and existing.source != source:
243
+ raise ValueError(
244
+ f"Two sockets are named `{func.__name__}`:\n"
245
+ f" {existing.source}\n {source}\n"
246
+ "The client connects with a name and nothing else, so socket "
247
+ "names must be unique application-wide."
248
+ )
249
+
250
+ SOCKET_REGISTRY[func.__name__] = SocketEntry(
251
+ name=func.__name__,
252
+ source=source,
253
+ require_auth=require_auth,
254
+ allowed_roles=tuple(allowed_roles or ()),
255
+ handler=func,
256
+ )
257
+ return func
258
+
259
+ return decorator
260
+
261
+
262
+ # ==== WIRE HELPERS ====
263
+
264
+
265
+ def _error_frame(message: str) -> str:
266
+ """`{"error": "..."}` -- the frame shape the client routes to `onError`.
267
+
268
+ Reserved on this wire the way the `error` key is reserved in an rpc
269
+ failure; `send` refuses to emit it as an ordinary message.
270
+ """
271
+ return json.dumps({"error": message})
272
+
273
+
274
+ def _is_reserved_error_shape(value: Any) -> bool:
275
+ return (
276
+ isinstance(value, dict)
277
+ and set(value.keys()) == {"error"}
278
+ and isinstance(value.get("error"), str)
279
+ )
280
+
281
+
282
+ class _MessageRate:
283
+ """Sliding-window receive budget for one connection.
284
+
285
+ Per-socket rather than per-IP: whatever pool the handler broadcasts into
286
+ is the shared resource being protected, and one abusive connection must
287
+ not spend another client's budget.
288
+ """
289
+
290
+ def __init__(self, limit: int, window_seconds: int):
291
+ self._limit = limit
292
+ self._window_seconds = window_seconds
293
+ self._timestamps: list[float] = []
294
+
295
+ def allow(self) -> bool:
296
+ now = time.monotonic()
297
+ cutoff = now - self._window_seconds
298
+ self._timestamps = [t for t in self._timestamps if t > cutoff]
299
+ if len(self._timestamps) >= self._limit:
300
+ return False
301
+ self._timestamps.append(now)
302
+ return True
303
+
304
+
305
+ # ==== SOCKET ====
306
+
307
+
308
+ class _Shared:
309
+ """State the `Socket` and every cloned `SocketSender` see together."""
310
+
311
+ def __init__(self) -> None:
312
+ self.outgoing: asyncio.Queue[str | None] = asyncio.Queue(
313
+ maxsize=SEND_QUEUE_SIZE
314
+ )
315
+ self.closed = False
316
+ self.last_activity = time.monotonic()
317
+
318
+
319
+ class SocketSender:
320
+ """The sending half of a [`Socket`], detached from the conversation.
321
+
322
+ Cheap to hand around, so a broadcast pool is a list of these: keep one per
323
+ connection in shared state, and a send that returns False is a connection
324
+ to forget. See [`SocketPool`].
325
+ """
326
+
327
+ def __init__(self, shared: _Shared) -> None:
328
+ self._shared = shared
329
+
330
+ @property
331
+ def is_open(self) -> bool:
332
+ return not self._shared.closed
333
+
334
+ async def send(self, value: Any) -> bool:
335
+ """Send one JSON value. False means nobody is listening any more --
336
+ the browser navigated away or closed the tab. That is the signal to
337
+ stop, not an error to report."""
338
+ if self._shared.closed:
339
+ return False
340
+ serialized = _serialize_result(value)
341
+ if _is_reserved_error_shape(serialized):
342
+ raise ValueError(
343
+ 'The frame shape {"error": "..."} is reserved for failures. '
344
+ "Wrap the value or rename the key."
345
+ )
346
+ try:
347
+ frame = json.dumps(serialized)
348
+ except (TypeError, ValueError) as e:
349
+ print(f"[Socket] A frame cannot be written as JSON: {e}")
350
+ return False
351
+ await self._shared.outgoing.put(frame)
352
+ return not self._shared.closed
353
+
354
+ async def _error(self, message: str) -> None:
355
+ """The error frame, then the close. The conversation is over."""
356
+ if self._shared.closed:
357
+ return
358
+ await self._shared.outgoing.put(_error_frame(message))
359
+ await self._shared.outgoing.put(None)
360
+
361
+
362
+ class SocketPool:
363
+ """A broadcast pool: one `SocketSender` per open connection.
364
+
365
+ Holds senders rather than raw websockets so a handler can share its
366
+ connection without sharing the receive side. Keep authenticated and guest
367
+ traffic in separate pools so a private broadcast can never fan out to a
368
+ guest connection.
369
+ """
370
+
371
+ def __init__(self) -> None:
372
+ self._senders: list[SocketSender] = []
373
+
374
+ @property
375
+ def count(self) -> int:
376
+ return len(self._senders)
377
+
378
+ def add(self, sender: SocketSender) -> None:
379
+ self._senders.append(sender)
380
+
381
+ def discard(self, sender: SocketSender) -> None:
382
+ self._senders = [s for s in self._senders if s is not sender]
383
+
384
+ async def broadcast(self, value: Any) -> None:
385
+ """Send one value to everyone; connections whose browser is gone are
386
+ pruned on the way."""
387
+ stale: list[SocketSender] = []
388
+ for sender in list(self._senders):
389
+ if not await sender.send(value):
390
+ stale.append(sender)
391
+ for sender in stale:
392
+ self.discard(sender)
393
+
394
+
395
+ class Socket:
396
+ """One open connection, as the handler holds it.
397
+
398
+ The `socket` parameter of every `@socket()` function. Receiving is the
399
+ handler's alone; sending may be shared -- `sender()` hands out a handle
400
+ another task or a [`SocketPool`] may hold, which is how a chat room
401
+ reaches the people in it.
402
+
403
+ When the handler returns, the connection closes: a handler that returns
404
+ is a conversation that ends.
405
+ """
406
+
407
+ def __init__(self, name: str, websocket: WebSocket) -> None:
408
+ self._name = name
409
+ self._websocket = websocket
410
+ self._shared = _Shared()
411
+ self._sender = SocketSender(self._shared)
412
+ self._rate = _MessageRate(_messages_per_window(), _rate_window_seconds())
413
+ # One task owns the write side of the wire, so cloned senders on other
414
+ # tasks never interleave partial sends.
415
+ self._writer = asyncio.create_task(self._pump_outgoing())
416
+
417
+ async def _pump_outgoing(self) -> None:
418
+ try:
419
+ while True:
420
+ frame = await self._shared.outgoing.get()
421
+ if frame is None:
422
+ break
423
+ await self._websocket.send_text(frame)
424
+ self._shared.last_activity = time.monotonic()
425
+ except Exception:
426
+ # The browser is gone; every later `send` answers False.
427
+ pass
428
+ finally:
429
+ self._shared.closed = True
430
+ try:
431
+ await self._websocket.close(code=status.WS_1000_NORMAL_CLOSURE)
432
+ except Exception:
433
+ pass
434
+
435
+ async def send(self, value: Any) -> bool:
436
+ """Send one JSON value. False means the browser is gone."""
437
+ return await self._sender.send(value)
438
+
439
+ async def recv(self) -> Any | None:
440
+ """The next value the browser sent, or None when the connection
441
+ closes -- which is how every socket conversation eventually ends. The
442
+ natural loop is `while (value := await socket.recv()) is not None:`.
443
+
444
+ Raises ValueError on a frame that arrived and is not valid JSON: a
445
+ frame the handler cannot read is a client bug worth surfacing, and an
446
+ uncaught raise travels back as the error frame.
447
+ """
448
+ text = await self.recv_text()
449
+ if text is None:
450
+ return None
451
+ try:
452
+ return json.loads(text)
453
+ except json.JSONDecodeError as e:
454
+ raise ValueError(
455
+ f"`{self._name}` could not read a frame -- {e}. Each frame is "
456
+ "one JSON value."
457
+ ) from e
458
+
459
+ async def recv_text(self) -> str | None:
460
+ """The next frame as it arrived, for a handler that would rather
461
+ parse it itself. None is the connection closing.
462
+
463
+ Enforces the shared limits: an oversized frame closes with 1009, a
464
+ flooding connection closes with 1008, and a connection idle in both
465
+ directions past the timeout closes with 1000.
466
+ """
467
+ idle_timeout = _idle_timeout_seconds()
468
+ while not self._shared.closed:
469
+ try:
470
+ text = await asyncio.wait_for(
471
+ self._websocket.receive_text(), timeout=idle_timeout
472
+ )
473
+ except asyncio.TimeoutError:
474
+ # Outbound traffic counts as liveness: a passive listener in
475
+ # an active room is not idle, it is listening.
476
+ if time.monotonic() - self._shared.last_activity >= idle_timeout:
477
+ await self.close()
478
+ return None
479
+ continue
480
+ except Exception:
481
+ # Disconnect, or receive after close: the conversation ended.
482
+ self._shared.closed = True
483
+ return None
484
+
485
+ self._shared.last_activity = time.monotonic()
486
+
487
+ if len(text.encode("utf-8")) > _max_message_bytes():
488
+ await self._close_with(status.WS_1009_MESSAGE_TOO_BIG)
489
+ return None
490
+ if not self._rate.allow():
491
+ await self._sender._error("Too many messages. Slow down.")
492
+ return None
493
+ return text
494
+ return None
495
+
496
+ def sender(self) -> SocketSender:
497
+ """A sending handle another task, or a [`SocketPool`], may hold."""
498
+ return self._sender
499
+
500
+ @property
501
+ def is_open(self) -> bool:
502
+ return not self._shared.closed
503
+
504
+ async def close(self) -> None:
505
+ """Say goodbye first. Returning from the handler closes too; this is
506
+ for closing mid-conversation."""
507
+ if not self._shared.closed:
508
+ await self._shared.outgoing.put(None)
509
+ await self._finish()
510
+
511
+ async def _close_with(self, code: int) -> None:
512
+ self._shared.closed = True
513
+ self._writer.cancel()
514
+ try:
515
+ await self._websocket.close(code=code)
516
+ except Exception:
517
+ pass
518
+
519
+ async def _finish(self) -> None:
520
+ """Let queued frames drain, then reclaim the writer task."""
521
+ try:
522
+ await asyncio.wait_for(self._writer, timeout=5)
523
+ except (asyncio.CancelledError, Exception):
524
+ self._writer.cancel()
525
+ self._shared.closed = True
526
+
527
+
528
+ # ==== ENDPOINT ====
529
+
530
+ # Named sockets share one cap with everything else that holds a connection
531
+ # open: every open socket is a live task plus a broadcast target.
532
+ _open_connections = 0
533
+
534
+
535
+ def open_connection_count() -> int:
536
+ return _open_connections
537
+
538
+
539
+ @dataclass
540
+ class _Refusal:
541
+ message: str
542
+ close_code: int = field(default=status.WS_1008_POLICY_VIOLATION)
543
+
544
+
545
+ def _resolve_entry(websocket: WebSocket) -> SocketEntry | _Refusal:
546
+ name = (websocket.query_params.get("name") or "").strip()
547
+ if not name:
548
+ return _Refusal(
549
+ "This connection named no socket. Open it as "
550
+ 'pp.socket("name", { ... }) -- the client runtime sends the name '
551
+ "in the `name` query parameter."
552
+ )
553
+ entry = SOCKET_REGISTRY.get(name)
554
+ if entry is None:
555
+ return _Refusal(
556
+ f"No socket named `{name}`. Mark the function @socket() -- the "
557
+ "name the client connects with is the function's own -- and note "
558
+ "that a socket declared in a route's index.py registers when that "
559
+ "route first renders."
560
+ )
561
+ return entry
562
+
563
+
564
+ def _authorize(websocket: WebSocket, entry: SocketEntry) -> _Refusal | None:
565
+ """Auth for one handshake, delegated to Caspian's `Auth`.
566
+
567
+ HTTP middleware never sees websocket scopes, so the endpoint authorizes
568
+ itself by binding the socket as the request context -- `Auth` reads only
569
+ `.session`, which `SessionMiddleware` exposes on websockets too. Failures
570
+ travel as this wire's `{"error": ...}` frame. The session is read-only.
571
+ """
572
+ Auth.set_request(websocket) # type: ignore[arg-type]
573
+ auth = Auth.get_instance()
574
+
575
+ if auth.is_authenticated():
576
+ payload = auth.get_payload() or {}
577
+ if entry.allowed_roles and not auth.check_role(
578
+ payload, list(entry.allowed_roles)
579
+ ):
580
+ return _Refusal(f"The socket `{entry.name}` is not available to this account.")
581
+ return None
582
+
583
+ if entry.require_auth or entry.allowed_roles:
584
+ return _Refusal(
585
+ f"The socket `{entry.name}` needs a signed-in session. It is "
586
+ "@socket(require_auth=True), so it answers only while the browser "
587
+ "carries one."
588
+ )
589
+ return None
590
+
591
+
592
+ async def _first_frame(websocket: WebSocket) -> dict[str, Any] | _Refusal | None:
593
+ """The arguments: one JSON object, as the first text frame.
594
+
595
+ None is a connection that closed before saying anything -- a refresh
596
+ mid-handshake. Not an error, and nobody left to tell.
597
+ """
598
+ try:
599
+ text = await asyncio.wait_for(
600
+ websocket.receive_text(), timeout=ARGS_TIMEOUT_SECONDS
601
+ )
602
+ except asyncio.TimeoutError:
603
+ return _Refusal(
604
+ "This socket opened and sent no arguments. The first frame is the "
605
+ "payload -- one JSON object, {} when the function takes nothing. "
606
+ "pp.socket sends it on open."
607
+ )
608
+ except Exception:
609
+ return None
610
+
611
+ try:
612
+ value = json.loads(text)
613
+ except json.JSONDecodeError:
614
+ value = None
615
+ if not isinstance(value, dict):
616
+ return _Refusal(
617
+ "The first frame of a socket is not a JSON object. Arguments are "
618
+ 'named, so they arrive as { "room": ... } -- '
619
+ 'pp.socket("name", { room }) is what sends them.'
620
+ )
621
+ return value
622
+
623
+
624
+ def _call_kwargs(
625
+ entry: SocketEntry, args: dict[str, Any], sock: Socket
626
+ ) -> dict[str, Any] | _Refusal:
627
+ """Filter the payload against the handler's own signature.
628
+
629
+ The payload is client-controlled, so -- exactly as with rpc -- a parameter
630
+ is settable only when declared, and `socket` itself can never be supplied
631
+ from the wire.
632
+ """
633
+ parameters = inspect.signature(entry.handler).parameters.values()
634
+ accepts_kwargs = any(
635
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters
636
+ )
637
+
638
+ accepted = {
639
+ p.name
640
+ for p in parameters
641
+ if p.kind in (
642
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
643
+ inspect.Parameter.KEYWORD_ONLY,
644
+ )
645
+ and p.name != "socket"
646
+ }
647
+ missing = [
648
+ p.name
649
+ 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
653
+ ]
654
+ if missing:
655
+ return _Refusal(
656
+ f"`{entry.name}` is missing its `{missing[0]}` argument. The "
657
+ f'browser opens pp.socket("{entry.name}", '
658
+ f'{{ {missing[0]}: ... }}).'
659
+ )
660
+
661
+ if accepts_kwargs:
662
+ kwargs = {k: v for k, v in args.items() if k != "socket"}
663
+ else:
664
+ kwargs = {k: v for k, v in args.items() if k in accepted}
665
+ kwargs["socket"] = sock
666
+ return kwargs
667
+
668
+
669
+ async def _refuse_open(websocket: WebSocket, refusal: _Refusal) -> None:
670
+ """Refuse after the handshake: the error frame, then the close, so the
671
+ browser gets a readable message instead of a bare close code."""
672
+ try:
673
+ await websocket.send_text(_error_frame(refusal.message))
674
+ await websocket.close(code=refusal.close_code)
675
+ except Exception:
676
+ pass
677
+
678
+
679
+ async def serve_named_socket(websocket: WebSocket) -> None:
680
+ """The whole endpoint: who is calling, whether they may, then the pump.
681
+
682
+ Wired in `main.py` as `@app.websocket(SOCKET_PATH)`, gated on
683
+ `caspian.config.json` `websocket: true`.
684
+ """
685
+ global _open_connections
686
+
687
+ # Refused before the handshake upgrades, like every channel endpoint.
688
+ if not is_websocket_origin_allowed(websocket):
689
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
690
+ return
691
+ if _open_connections >= MAX_WEBSOCKET_CONNECTIONS:
692
+ await websocket.close(code=status.WS_1013_TRY_AGAIN_LATER)
693
+ return
694
+
695
+ await websocket.accept()
696
+
697
+ entry = _resolve_entry(websocket)
698
+ if isinstance(entry, _Refusal):
699
+ await _refuse_open(websocket, entry)
700
+ return
701
+
702
+ refusal = _authorize(websocket, entry)
703
+ if refusal is not None:
704
+ await _refuse_open(websocket, refusal)
705
+ return
706
+
707
+ args = await _first_frame(websocket)
708
+ if args is None:
709
+ return
710
+ if isinstance(args, _Refusal):
711
+ await _refuse_open(websocket, args)
712
+ return
713
+
714
+ sock = Socket(entry.name, websocket)
715
+ kwargs = _call_kwargs(entry, args, sock)
716
+ if isinstance(kwargs, _Refusal):
717
+ await sock.sender()._error(kwargs.message)
718
+ await sock._finish()
719
+ return
720
+
721
+ _open_connections += 1
722
+ try:
723
+ await entry.handler(**kwargs)
724
+ await sock.close()
725
+ except ValueError as e:
726
+ # The rpc convention: a ValueError is a message meant for the caller.
727
+ await sock.sender()._error(str(e))
728
+ await sock._finish()
729
+ except Exception as e:
730
+ print(f"[Socket Error] {entry.name}: {e}")
731
+ traceback.print_exc()
732
+ message = (
733
+ "Internal server error"
734
+ if is_production_environment()
735
+ else f"{entry.name}: {e}"
736
+ )
737
+ await sock.sender()._error(message)
738
+ await sock._finish()
739
+ finally:
740
+ _open_connections -= 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,192 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from typing import Any
5
- from urllib.parse import urlparse
6
-
7
- from fastapi import WebSocket, status
8
-
9
- from casp.auth import Auth
10
- from casp.runtime_security import is_production_environment
11
-
12
- # ====
13
- # WebSocket security: ONE guard that reuses Caspian's `Auth` as the source of
14
- # truth, so socket auth lines up with HTTP route auth instead of duplicating it.
15
- #
16
- # Why this lives here and not in `AuthMiddleware`:
17
- # HTTP middleware in `main.py` early-returns on every non-`http` scope, so a
18
- # WebSocket handshake (`scope["type"] == "websocket"`) is never seen by
19
- # `AuthMiddleware`. Each socket endpoint must therefore authorize itself. We do
20
- # that by delegating to the same `Auth` instance that powers page privacy.
21
- # ====
22
-
23
-
24
- def _is_production() -> bool:
25
- # Shared fail-closed resolution: an unset or misspelled APP_ENV must not
26
- # silently enable the development handshake relaxations below.
27
- return is_production_environment()
28
-
29
-
30
- def _normalized_origin(value: str) -> str:
31
- return (value or "").strip().rstrip("/")
32
-
33
-
34
- def _configured_websocket_origins() -> set[str]:
35
- raw_values: list[str] = []
36
- for env_name in (
37
- "WEBSOCKET_ALLOWED_ORIGINS",
38
- "CORS_ALLOWED_ORIGINS",
39
- "APP_BASE_URL",
40
- ):
41
- raw_values.extend(os.getenv(env_name, "").split(","))
42
-
43
- return {
44
- _normalized_origin(origin)
45
- for origin in raw_values
46
- if _normalized_origin(origin)
47
- }
48
-
49
-
50
- def _websocket_same_origin(websocket: WebSocket) -> str:
51
- scheme = "https" if websocket.url.scheme == "wss" else "http"
52
- return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
53
-
54
-
55
- def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
56
- """Anti-CSWSH origin check. NOT authentication.
57
-
58
- A browser cannot forge the `Origin` header, so this blocks cross-site
59
- script-driven handshakes. A raw client (wscat, python websockets) can send
60
- any origin, which is exactly why authentication is a separate gate.
61
- """
62
- origin = _normalized_origin(websocket.headers.get("origin", ""))
63
- if not origin:
64
- # No Origin header: tolerate local tooling in dev, reject in production.
65
- return not _is_production()
66
-
67
- parsed_origin = urlparse(origin)
68
- if not parsed_origin.scheme or not parsed_origin.netloc:
69
- return False
70
-
71
- if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
72
- return parsed_origin.scheme == "http"
73
-
74
- allowed_origins = _configured_websocket_origins()
75
-
76
- # The same-origin fallback is derived from the Host header, which a client
77
- # controls directly and a misconfigured proxy will forward verbatim: sending
78
- # `Host: evil.tld` with `Origin: https://evil.tld` would otherwise satisfy
79
- # this check against itself. It is a convenience for development only --
80
- # production must name its origins explicitly.
81
- if not _is_production():
82
- allowed_origins.add(_websocket_same_origin(websocket))
83
-
84
- return origin in allowed_origins
85
-
86
-
87
- async def authorize_websocket(
88
- websocket: WebSocket,
89
- *,
90
- require_auth: bool,
91
- roles: list[str] | None = None,
92
- ) -> dict[str, Any] | None:
93
- """Single entry point for socket authorization.
94
-
95
- Returns the connecting identity on success, or `None` after closing the
96
- socket on failure. The return value gates the connection only; do not echo
97
- it to other clients (it may contain the authenticated user's payload).
98
-
99
- - `require_auth=True` -> authenticated session required, else close 1008.
100
- - `require_auth=False` -> guests allowed; authenticated users keep identity.
101
- - `roles` -> RBAC via `Auth.check_role`, same rule as HTTP routes.
102
-
103
- Auth is read from the session cookie that `SessionMiddleware` (the one
104
- middleware that runs on websocket scopes) exposes as `websocket.session`.
105
- Treat this as read-only: session writes are not persisted back to the cookie
106
- over a WebSocket, so this guard must not rely on `refresh_session`.
107
- """
108
- # 1. Origin / CSWSH check runs before accept so rejected handshakes never
109
- # upgrade.
110
- if not is_websocket_origin_allowed(websocket):
111
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
112
- return None
113
-
114
- # 2. Reuse Caspian `Auth` as the single source of truth. `Auth` only reads
115
- # `request.session`, and a WebSocket exposes `.session`, so binding the
116
- # socket as the request context lets us reuse the exact HTTP auth logic.
117
- Auth.set_request(websocket) # type: ignore[arg-type]
118
- auth = Auth.get_instance()
119
-
120
- if auth.is_authenticated():
121
- payload = auth.get_payload() or {}
122
- if roles and not auth.check_role(payload, roles):
123
- await _reject(websocket, "Forbidden.")
124
- return None
125
- return payload
126
-
127
- if require_auth:
128
- await _reject(websocket, "Authentication required.")
129
- return None
130
-
131
- return {"guest": True, "scope": "public"}
132
-
133
-
134
- async def _reject(websocket: WebSocket, message: str) -> None:
135
- """Accept, surface a JSON error so the browser UI can react, then close."""
136
- await websocket.accept()
137
- await websocket.send_json({"type": "error", "message": message})
138
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
139
-
140
-
141
- # Ceiling on simultaneous sockets in one pool. Every open connection is a live
142
- # task plus a broadcast target, so an unbounded pool lets cheap clients grow
143
- # server memory and turn each broadcast into an amplification.
144
- MAX_WEBSOCKET_CONNECTIONS = max(
145
- 1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200))
146
- )
147
-
148
-
149
- class WebSocketConnectionManager:
150
- def __init__(self, max_connections: int = MAX_WEBSOCKET_CONNECTIONS) -> None:
151
- self._connections: set[WebSocket] = set()
152
- self._max_connections = max_connections
153
-
154
- @property
155
- def connection_count(self) -> int:
156
- return len(self._connections)
157
-
158
- async def connect(self, websocket: WebSocket) -> bool:
159
- """Accept and register the socket. Returns False when the pool is full.
160
-
161
- The capacity check runs before `accept()`, so a refused client is closed
162
- during the handshake and never reaches an open state. The caller must
163
- stop on a False result rather than run a read loop on a socket that was
164
- never added to the pool.
165
- """
166
- if len(self._connections) >= self._max_connections:
167
- await websocket.close(code=status.WS_1013_TRY_AGAIN_LATER)
168
- return False
169
-
170
- await websocket.accept()
171
- self._connections.add(websocket)
172
- return True
173
-
174
- def disconnect(self, websocket: WebSocket) -> None:
175
- self._connections.discard(websocket)
176
-
177
- async def broadcast_json(self, payload: dict[str, Any]) -> None:
178
- stale: list[WebSocket] = []
179
- for websocket in list(self._connections):
180
- try:
181
- await websocket.send_json(payload)
182
- except RuntimeError:
183
- stale.append(websocket)
184
-
185
- for websocket in stale:
186
- self.disconnect(websocket)
187
-
188
-
189
- # Separate pools keep authenticated traffic isolated from guest traffic, so a
190
- # private broadcast can never fan out to a public (guest) connection.
191
- websocket_connections = WebSocketConnectionManager()
192
- public_websocket_connections = WebSocketConnectionManager()