create-caspian-app 1.2.0 → 1.3.1

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,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