How to connect live clients

This guide shows you how to serve a Session or Hub over the connection adapters transports ships today: WebSocket, WebRTC data channels, Server-Sent Events, Jupyter comm, and anywidget custom messages.

Serve a session over WebSocket

Install the WebSocket dependencies:

pip install "transports[connections]" uvicorn

Create a Starlette app:

import asyncio

import transports
from pydantic import BaseModel
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute

class Counter(BaseModel):
    tick: int = 0

session = transports.Session()
counter = Counter()
session.host(counter)
server = transports.Server(session)

async def ticker():
    while True:
        await asyncio.sleep(1)
        counter.tick += 1

async def startup():
    asyncio.create_task(transports.autosync(server))
    asyncio.create_task(ticker())

app = Starlette(
    routes=[WebSocketRoute("/ws", transports.ws_endpoint(server))],
    on_startup=[startup],
)

Run it:

uvicorn app:app --reload

Run one autosync task per Server or Hub. It drains host-side mutations and broadcasts the resulting patches to all open connections.

Mirror the server in a browser

Initialize the wasm package, connect, and render whenever a message arrives.

import init, { Client, fromValue } from "1kbgz/transports";

await init();

const client = new Client();
const ws = client.connect("ws://127.0.0.1:8000/ws");

ws.addEventListener("message", () => {
  const [id] = client.ids();
  if (id === undefined) return;
  render(fromValue(client.value(id)));
});

To send an edit, propose it over the active connection. connect() and run() own the socket, so you do not handle it yourself:

import { toValue } from "1kbgz/transports";

const [id] = client.ids();
client.propose(id, toValue({ tick: 10 }), "tick-1");

The local mirror updates when the server echoes the authoritative patch, or onReject fires with the reason the edit was refused. edit assigns a proposal identifier; pass one explicitly when an adapter needs to match pending state. onAck receives the authoritative patch carrying that identifier, and rejects include it in onReject. Use proposeOps on a managed connection, or editOps with a hand-rolled socket, to send explicit operations when a whole-value diff would be empty against the current mirror. Generated identifiers use the reserved auto-N form; explicit identifiers matching that form are rejected so the two sources cannot collide.

onConnect fires whenever a managed duplex connection opens, including each run() reconnect and custom channels attached with attach(). It fires after client.connected becomes true and does not depend on the server sending a snapshot or patch. Use it with onDisconnect when an adapter needs connection status. Python names the hooks on_connect and on_disconnect. These hooks do not apply to receive-only connectSSE / connect_sse streams.

Proposal correlation lasts for one live connection. If it drops, onAbandon receives the unsettled proposal identifiers before onDisconnect fires. Python exposes the same hook as on_abandon. pendingProposals() / pending_proposals() returns the current set. Resume replays authoritative state without old proposal identifiers; it does not resend those proposals.

client.send(frame) sends any pre-built frame the same way. It is what an adapter hands its send callback, for example spaday’s connectStore(store, client, (f) => client.send(f), codec). Both return false and drop the frame when no managed connection is open. This matches a browser WebSocket’s send on a closed socket, so they are safe as fire-and-forget callbacks even across run() reconnect gaps. Check client.connected (or the return value) when delivery matters. With a hand-rolled socket, send client.edit(id, value) yourself as before.

An adapter can also make its channel the client’s managed connection. Attach its sender, feed received frames to recv, and detach the same sender object when the channel closes:

channel.binaryType = "arraybuffer";
const sender = (frame: string | Uint8Array) => channel.send(frame);
client.attach(sender);
channel.onmessage = ({ data }) =>
  client.recv(typeof data === "string" ? data : new Uint8Array(data));
channel.onclose = () => client.detach(sender);
sender = channel.send
await client.attach(sender)
try:
    async for frame in channel:
        client.recv(frame)
finally:
    client.detach(sender)

attach flushes queued CRDT operations. Attaching a replacement first disconnects the old channel, which abandons its unsettled ordinary proposals but retains CRDT operations for the replacement. detach checks sender identity, so a late close from the old channel cannot clear the replacement. If the initial CRDT flush fails, attach detaches the failed channel and reports the send error.

If a hand-rolled sender fails after edit or editOps creates a proposal, call client.abandonProposal(proposal) to remove it from the JavaScript client’s pending set. Python provides client.abandon_proposal(proposal). The methods return whether the proposal was pending; they do not fire the disconnect-only onAbandon or on_abandon callbacks.

CRDT-backed shared models use proposeCrdt(id, mutations) or editCrdt(id, mutations). These edits are optimistic: the client applies them locally, retains their causally identified operations while offline, and resends them after reconnect. An authoritative echo clears the matching operations. Unlike ordinary patch proposals, CRDT operations survive a connection drop because resending one causal dot is safe. Use pendingCrdtOps(id) to inspect the outbox.

Publish ephemeral awareness

Awareness carries transient state for a shared Hub model. It is suitable for cursor positions, selections, typing state, and other hints that should disappear when a connection closes. The Hub does not store awareness in the model, replay log, CRDT state, or persistence callbacks.

client.onAwareness(({ id, peer, state }) => {
  if (id !== documentId) return;
  if (state === null) removePeerCursor(peer);
  else updatePeerCursor(peer, state);
});

client.setAwareness(documentId, {
  selection: { anchor: 12, head: 18 },
});
client.on_awareness(handle_awareness)
await client.set_awareness(document_id, {"selection": {"anchor": 12, "head": 18}})

awareness(id) returns the latest remote state keyed by the Hub-assigned peer id. A client retains its latest local state and republishes it whenever a managed connection opens, including a resume that produces no model frame. Pass null in JavaScript or None in Python to clear that state. The Hub also sends a removal when the connection closes. Read-only subscribers may publish awareness; it does not grant model write access. Payload meaning, user identity, names, colors, and rendering remain application concerns.

Independent awareness scopes

Use a named scope when multiple components publish state on one model and connection:

await client.set_awareness(document_id, {"anchor": 12}, scope="editor")
await client.set_awareness(document_id, {"row": "order-42"}, scope="table")
client.on_awareness(handle_editor_selection, scope="editor")
client.awareness(document_id, scope="table")
await client.set_awareness(document_id, None, scope="editor")

JavaScript accepts {scope: "editor"} as the final argument to setAwareness, onAwareness, and awareness. Each call replaces only that scope’s state; null withdraws only that scope. Repeated writes to the same scope use the last value. Scope names are application-defined strings. Subscriptions observe future updates; read awareness for current peers when subscribing late. Unsubscribing removes the callback without withdrawing state.

Calls without a scope retain their existing full-payload behavior and receive only unscoped updates. Closing a connection removes all its scopes; reconnect republishes each retained scope’s latest value. Scoped messages use scoped_awareness, which older clients ignore. Publishing scopes requires a Hub with scoped-awareness support. Neither scoped nor unscoped awareness currently crosses relay workers or expires independently of connection cleanup.

Use an existing WebRTC data channel

transports can manage an RTCDataChannel after your application negotiates it. Signaling and RTCPeerConnection ownership remain with the application:

const client = new Client();
client.connectDataChannel(peerConnection.createDataChannel("transports"));

The adapter receives frames, sends proposals, flushes queued CRDT operations and awareness when the channel opens, and reports connection lifecycle through the same hooks as connect(). The remote endpoint must speak the transports protocol with the same codec.

Pyodide uses the same browser channel through the js FFI:

client = transports.Client()
await client.connect_data_channel(channel)

connect_data_channel runs until the channel closes. Native Python intentionally does not install aiortc; browser peer-to-peer sessions are the supported WebRTC path. transports does not yet provide signaling or turn two clients into a peer-hosted Hub.

Mirror the server in Python

Client.connect() runs a receive loop until the WebSocket closes.

client = transports.Client()
await client.connect("ws://127.0.0.1:8000/ws")

Edits work the same as in JS. await client.propose(mid, value, "edit-1") and await client.propose_ops(mid, ops, "edit-2") ride the active connect() or run() connection, native or Pyodide. They return False and drop the frame when there is none, so check client.connected first when delivery matters. With a hand-rolled socket, send client.edit(mid, value) or client.edit_ops(mid, ops) yourself.

Python exposes the same correlation API as edit(..., proposal=...), edit_ops, on_ack, and on_reject.

For a CRDT-backed shared model, use await client.propose_crdt(mid, mutations) on a managed connection or send client.edit_crdt(mid, mutations) over a hand-rolled socket. The local mirror updates immediately, including while disconnected. pending_crdt_ops(mid) reports operations still waiting for the server echo.

Use MessagePack on a connection

Pass codec="msgpack" on the client. The client appends ?codec=msgpack; the server sends binary frames to that connection and can still serve JSON clients at the same time.

client = transports.Client(codec="msgpack")
await client.connect("ws://127.0.0.1:8000/ws")
const client = new Client("msgpack");
const ws = client.connect("ws://127.0.0.1:8000/ws");

Stream receive-only updates over SSE

Use SSE for dashboards and other receive-only clients.

pip install "transports[sse]"
import asyncio
from starlette.applications import Starlette
from starlette.routing import Route

async def startup():
    asyncio.create_task(transports.autosync(server))

app = Starlette(
    routes=[Route("/sse", transports.sse_endpoint(server))],
    on_startup=[startup],
)

Python client:

client = transports.Client()
await client.connect_sse("http://127.0.0.1:8000/sse")

Browser client:

const client = new Client();
const events = client.connectSSE("http://127.0.0.1:8000/sse");

SSE is JSON/text and server-to-client only. Use WebSocket when clients need to send edits.

Use a Jupyter comm

Install the comm dependency:

pip install "transports[jupyter]"

Wire a kernel comm to a Server or Hub:

from comm import create_comm

comm = create_comm(target_name="transports")
transports.serve_comm(server, comm)

# after mutating hosted models
transports.sync(server)

The comm carries JSON control data and separate binary buffers. serve_comm supports JSON, MessagePack, and CBOR; receivers reattach buffers before decoding the protocol message.

Use anywidget custom messages

For the common case, transports.widget(server) builds an anywidget.AnyWidget whose frontend ships inside the wheel. Display it and every hosted model mirrors live. The frontend loads the same WASM client state as the browser package, emits transports-change / transports-reject DOM events, and exposes el.transports.edit for proposals. See Pyodide for details.

w = transports.widget(server)   # pip install anywidget
w                               # display; then mutate models + transports.sync(server)

For a custom frontend, serve_anywidget wires any anywidget-style send / on_msg object. You supply the _esm. The frontend sends {"ready": true} before snapshots are delivered.

conn = transports.serve_anywidget(server, widget)

# after mutating hosted models
transports.sync(server)

The bundled frontend gives each browser model a distinct view id and sends heartbeats. A view that stops responding expires after 180 seconds, releasing ownership and redelivering pending commands to the next eligible view. serve_anywidget(..., view_timeout=300) changes that timeout. Lease timers need a running asyncio loop, as provided by Jupyter. In synchronous hosts without a running loop, the view still opens but has no automatic expiry; the frontend must close it explicitly. Explicit disposal closes the view immediately.

Custom frontends can opt into this lifecycle with {"ready": true, "view": "unique-id", "ownership": true, "heartbeat": true}. The response {"view": "unique-id", "heartbeat": seconds} gives the heartbeat interval. Send {"view": "unique-id", "heartbeat": true} at that interval, even if a reply is lost. On {"view": "unique-id", "expired": true}, detach the client, reattach, and send a new ready message to obtain a snapshot and ownership grant. Send {"view": "unique-id", "closed": true} on disposal. Legacy frontends without heartbeats must close their connections explicitly.

Frontend messages use the same client protocol:

const client = new Client();

model.on("msg:custom", (content, buffers) => {
  if (typeof content.wire === "string") client.recv(content.wire);
  else if (content.wire) client.recvBuffers(content.wire, buffers ?? []);
});

model.send({ ready: true });

For text-only custom frontends, use model.send({ wire: client.edit(id, value) }). The bundled widget manages binary attachments, distinct frontend ids, managed sending, and ownership cleanup. Custom binary/ownership adapters must implement the attachment and view protocol.

Serve a Hub

A Hub satisfies the same connection contract as Server, so the same adapters serve it: transports.ws_endpoint(hub) for WebSocket, transports.sse_endpoint(hub) for SSE, and the same serve_comm / serve_anywidget helpers for Jupyter (with autosync(hub) or sync(hub)).

hub = transports.Hub(key=lambda ws: ws.path_params["tenant"])
app = Starlette(routes=[WebSocketRoute("/ws/{tenant}", transports.ws_endpoint(hub))])

Comm frontends opt into owning fields with serve_comm(server, comm, ownership=True); the default leaves them as observers. Custom manual adapters can pass ownership=False to Server.open or Hub.open. A closed adapter must call Client.detach and the server’s close method so ownership can transfer; send every message returned by close to its destination.