Live, bidirectional, self-healing
Named Sockets
Caspian's WebSocket layer exposes named Python
@socket() handlers
to PulsePoint through pp.socket(...). Every
live channel shares one secure framework endpoint, and every connection
heartbeats and restores itself after a drop — the guarantees of Socket.IO,
with no extra library.
"websocket": true in
caspian.config.json, then run the Caspian project
update workflow so src/lib/websocket/sockets.py
and the application bootstrap are generated together.
Bidirectional
Browser and server send JSON values for the life of the page.
Self-healing
Heartbeats detect dead links; dropped connections reconnect with backoff.
Policy-aware
Auth, roles, origin checks, capacity, rate, size, and idle limits are centralized.
Broadcast-ready
Sender handles and pools support rooms, presence, feeds, and collaboration.
Define a named socket
from src.lib.websocket.sockets import Socket, socket
@socket(require_auth=True)
async def project_feed(project_id: str, socket: Socket):
while (message := await socket.recv()) is not None:
sent = await socket.send({
"projectId": project_id,
"message": message,
})
if not sent:
break
-
The handler is asynchronous and declares one parameter named
socket. -
Client arguments are filtered against the declared signature;
socketcan never be supplied from the wire. -
recv()returns the next JSON value orNoneafter disconnect. -
send()returns false when the browser has gone away — a signal to stop, not an error. -
Returning from the handler ends the conversation (close code 1000). A raised
ValueErrorreaches the browser as a readable error frame.
Connect from PulsePoint
<section>
<p>Status: {status}</p>
<button onclick="sendUpdate()">Send update</button>
<script>
const feed = pp.ref(null);
const [events, setEvents] = pp.state([]);
const [status, setStatus] = pp.state("connecting");
pp.effect(() => {
feed.current = pp.socket("project_feed", { project_id: "alpha" }, {
onOpen: ({ reconnected }) => {
setStatus("live");
if (reconnected) reloadEvents(); // frames sent while offline are not replayed
},
onReconnecting: ({ attempt, delay }) =>
setStatus(`reconnecting (attempt ${attempt}, ${Math.round(delay / 1000)}s)`),
onClose: ({ willReconnect }) => { if (!willReconnect) setStatus("closed"); },
onMessage: (value) => setEvents((current) => [...current, value]),
onError: (error) => setStatus(error.message),
});
return () => feed.current.close(); // final: cancels any pending reconnect
}, []);
async function reloadEvents() {
setEvents(await pp.rpc("load_project_events", { project_id: "alpha" }));
}
function sendUpdate() {
feed.current.send({ type: "refresh" }); // buffered while reconnecting
}
</script>
</section>
Keep the socket handle in pp.ref(...), state only
for values that render, and close the channel from the owning effect's cleanup.
PulsePoint already knows the shared /__pulsepoint/ws endpoint,
so a route only names its function.
| Handler | Receives | When |
|---|---|---|
| onOpen | { reconnected } | Connected — reconnected is true after an automatic restore. |
| onMessage | value | Every application frame. Heartbeats and error frames never arrive here. |
| onError | error | A server {"error": ...} frame: refusal or failed handler. |
| onClose | { code, reason, wasClean, willReconnect } | Each close; willReconnect says whether a restore follows. |
| onReconnecting | { attempt, delay } | Before each retry, with the backoff delay in milliseconds. |
Stays connected: heartbeat and auto-reconnect
A socket is expected to stay open for as long as the page is — including a notification feed that sits silent for an hour and then delivers one message. Quiet is not dead: Caspian keeps the connection alive and restores it on its own.
Heartbeat
pp.socket sends {"__pp": "ping"} every 25 s.
A reader task the server runs for every connection answers, whether or not
the handler is calling recv(). No frame within
20 s of a ping — a laptop that slept, a proxy that silently dropped the link —
and the client replaces the connection.
Idle timeout
WEBSOCKET_IDLE_TIMEOUT_SECONDS (default 120, floor 60)
only closes a peer that stopped answering: live heartbeats keep resetting it,
and traffic in either direction counts. The idle close uses code
4000, never 1000, so the client knows to reconnect.
Reconnect
Any non-final close reopens with exponential backoff and jitter (1 s doubling
to 30 s). The browser's online event and a hidden
tab becoming visible skip the wait. send() calls
made meanwhile are buffered and flushed after the argument frame.
pp.socket("project_feed", { project_id: "alpha" }, {
heartbeatInterval: 25000, // ping every 25 s
heartbeatTimeout: 20000, // no frame for 20 s after a ping = dead link
reconnectDelay: 1000, // first retry, doubled each attempt (with jitter)
reconnectDelayMax: 30000, // backoff ceiling
maxReconnectAttempts: Infinity,
reconnect: true, // false = one connection, no auto-restore
});
A reconnect runs your handler again
The argument frame is resent and the @socket()
function runs from the top for the new connection. Keep handlers safe to
re-enter: a room announces the rejoin, a feed re-adds its sender to the pool.
Reconnect is not replay
A message broadcast while the link was down is lost, as with Socket.IO without
connection-state recovery. When missing one matters, reload the current state
in onOpen when reconnected
is true — an @rpc() call is the natural fit.
Close codes
A connection is final — no reconnect — when the page called
close(), the server sent an error frame (a refusal
would only fail again), the handler returned, or a policy rule closed it.
| Code | Meaning | Client reconnects |
|---|---|---|
| 1000 | The handler returned; the conversation ended. | No |
| 1008 | Policy: refused origin, auth, or payload (after an error frame). | No |
| 1009 | A frame exceeded MAX_WEBSOCKET_MESSAGE_BYTES. |
No |
| 1003 / 1007 / 1010 | Other policy closes. | No |
| 1013 | Connection ceiling reached, or maintenance mode. | Yes, with backoff |
| 4000 | Idle timeout: the peer stopped answering. | Yes |
| 1001 / 1006 / 1011 / 1012 | Server restart, network loss, crash. | Yes, with backoff |
Send-only feeds
A notification feed never reads from the browser — it parks its sender in a pool
and waits. await socket.wait_closed() keeps the
handler alive and returns once the browser is gone, so the finally
block can clean up. Frames a browser sends to a handler that never reads are held
briefly and then refused with an error frame, never buffered without bound.
from src.lib.websocket.sockets import Socket, SocketPool, socket
subscribers = SocketPool()
@socket(require_auth=True)
async def notifications(socket: Socket):
sender = socket.sender()
subscribers.add(sender)
try:
await socket.wait_closed() # heartbeats keep it alive, even in silence
finally:
subscribers.discard(sender)
# Anywhere else — an @rpc(), a background job:
# await subscribers.broadcast({"title": "Build finished"})
Broadcast with pools
socket.sender() is the sending half of a connection,
safe to hold in shared state or another task. SocketPool.broadcast(...)
prunes browsers that are gone as it walks. Keep authenticated and guest traffic in separate pools.
from src.lib.websocket.sockets import Socket, SocketPool, socket
room = SocketPool()
@socket(allowed_roles=["member", "admin"])
async def team_room(name: str, socket: Socket):
sender = socket.sender()
room.add(sender) # runs again on every reconnect: keep it re-entrant
try:
await room.broadcast({"from": "room", "text": f"{name} joined"})
while (value := await socket.recv()) is not None:
await room.broadcast({"from": name, "value": value})
finally:
room.discard(sender)
The wire
Arguments travel as the first frame, not in the URL a proxy would log. Two
single-key shapes are reserved: error for failures and
__pp for control frames. The server refuses to send
either as an ordinary message, control frames do not spend the per-connection
message budget, and unknown __pp verbs are ignored so
a newer client never breaks an older server.
→ {"project_id": "alpha"} first frame: the arguments → {"type": "refresh"} every later frame: one JSON value ← {"projectId": "alpha", ...} → {"__pp": "ping"} control frame, never reaches recv() ← {"__pp": "pong"} control frame, never reaches onMessage ← {"error": "Not allowed"} failure frame, routed to onError, then close
Choose the transport
| Application need | Caspian transport |
|---|---|
| Forms, CRUD, uploads, button actions | @rpc() + pp.rpc() |
| One request with progressive server output | RPC streaming / SSE |
| Chat, presence, collaboration, live feeds | @socket() + pp.socket() |
| Binary or specialized non-JSON protocol | App-owned raw WebSocket endpoint |
The named-socket endpoint validates origins before upgrade and enforces connection, message-size, rate, and idle limits. Authentication and roles delegate to Caspian Auth, and WebSocket session access remains read-only — session changes are never written back to the cookie over a socket.