WebSockets
pp.socket(name, args, handlers) opens a named socket: a long-lived, bidirectional JSON channel to a server-side function. It is part of the core runtime — no plugin, no extra script.
Choosing the right wire
Sockets are for genuinely bidirectional channels. Ordinary reads and writes stay on RPC, and one-way server push stays on RPC streaming:
| API | Shape | Use it for |
|---|---|---|
pp.rpc(name, data) |
Request / response | Reads, writes, form submits — one call, one JSON answer. |
pp.rpc(name, data, { onStream }) |
One-way server push | LLM output, progress feeds — the server streams chunks over SSE. |
pp.socket(name, args, handlers) |
Bidirectional, long-lived | Chat, presence, live dashboards, collaborative editing — both sides send frames at any time. |
The component pattern
Open the socket in a mount effect, keep the handle in a ref, close it in the effect's cleanup. The socket's lifetime is the component's lifetime — disposal runs the cleanup, so nothing leaks on either end of the wire:
<div pp-component="chat_room">
<ul>
<template pp-for="msg in messages">
<li key="{msg.id}">{msg.author}: {msg.text}</li>
</template>
</ul>
<form onsubmit="send(event)">
<input name="text" autocomplete="off" placeholder="Say something…" />
<button hidden="{!connected}">Send</button>
</form>
<script>
const [messages, setMessages] = pp.state([]);
const [connected, setConnected] = pp.state(false);
// The handle lives in a ref: reconnecting-state is not render-state.
const socketRef = pp.ref(null);
// Open in a mount effect, close in its cleanup — the socket's lifetime
// is the component's lifetime.
pp.effect(() => {
socketRef.current = pp.socket("chatRoom", { room: "general" }, {
onOpen: () => setConnected(true),
onMessage: (msg) => setMessages((prev) => [...prev, msg]),
onError: (err) => console.error("chat:", err.message),
onClose: () => setConnected(false),
});
return () => socketRef.current?.close();
}, []);
const send = (event) => {
event.preventDefault();
const form = event.currentTarget;
const text = String(new FormData(form).get("text") || "").trim();
if (!text) return;
socketRef.current.send({ text });
form.reset();
};
</script>
</div>
Handlers
| Handler / option | Fires when |
|---|---|
onOpen() |
The connection is open and the argument frame has been sent. |
onMessage(value) |
One incoming frame, JSON-parsed. Non-JSON text is handed through as a string rather than dropped. |
onError(error) |
Handshake refusals and server error frames — a frame shaped {"error": "…"} (that key alone) is reserved for failure and routed here, never to onMessage. |
onClose({ code, reason, wasClean }) |
The connection closed, cleanly or not. |
url |
Optional endpoint override; defaults to the shared /__pulsepoint/ws path. |
The returned handle
| Member | Behavior |
|---|---|
send(value) |
Queues one JSON value. Frames sent before the connection opens are buffered and flushed after the argument frame. Returns false once the connection is closing or closed. |
close(code?, reason?) |
Closes the connection (defaults to a clean 1000 close). |
readyState |
Mirrors the underlying WebSocket.readyState. |
The wire protocol
Every named socket shares one endpoint, /__pulsepoint/ws, with the function's name in the name query parameter. The arguments travel as the connection's first frame rather than in the URL — a URL is logged by every proxy on the way, and an argument is data:
CLIENT SERVER
| WS CONNECT /__pulsepoint/ws?name=chatRoom |
|----------------------------------------------->| check Origin, look up "chatRoom"
| frame 1: {"room": "general"} | the arguments — one JSON object,
|----------------------------------------------->| filtered against the handler signature
| {"text": "hi"} |
|----------------------------------------------->|
| {"id": 7, "author": "ana", "text": "hi"}|
|<-----------------------------------------------|
| {"error": "room closed"} | reserved failure frame
|<-----------------------------------------------| → onError, then the server closes
The server half
Any backend can serve the endpoint — resolve the name against an explicit registry (exactly like RPC), read the argument frame, then exchange JSON frames:
# The server half, FastAPI flavor — one endpoint serves every named socket.
@app.websocket("/__pulsepoint/ws")
async def pulsepoint_socket(ws: WebSocket):
if ws.headers.get("origin") not in ALLOWED_ORIGINS:
await ws.close(code=4403)
return
handler = SOCKETS.get(ws.query_params.get("name")) # explicit registry
if handler is None:
await ws.close(code=4404)
return
await ws.accept()
args = json.loads(await ws.receive_text()) # first frame = arguments
try:
await handler(ws, **filter_to_signature(handler, args))
except Exception as exc:
await ws.send_text(json.dumps({"error": str(exc)}))
await ws.close()
Production servers must check the Origin header against an allow-list, cap concurrent connections, bound message size and rate, and idle-time out dead connections. The full server contract lives in Backend Integration and in llms.md.
Rules
- Open sockets in
pp.effect(..., []), keep the handle inpp.ref(...), close in the cleanup — never at module top level. - Frames sent before the connection opens are buffered, so
pp.socket(...).send(...)on one line just works. - Treat a
send()that returnsfalseas a closed channel — reflect it in UI state instead of retrying blindly. - Reach for a raw
new WebSocket(...)only for wires the JSON-frame contract cannot carry, such as binary protocols — and then implement the origin check and auth yourself.