Sync a UI to a server over transports¶
This guide shows you how to keep a served spaday UI in sync with a server-side state model using transports — so a two-way control’s change is applied on the server and fanned to every connected browser. For delivering the page itself (the integration ladder), see Serve and embed; for the zero-server version, the notebook guide.
The split to keep in mind: spaday owns the UI (the tree and its reactive Store); transports
owns the wire (a Client that mirrors a model and sends edits); a single adapter, connectStore, is the
only place they meet — and serve(wire="transports", …) generates that adapter for you, so there is
no hand-written browser glue.
pip install "spaday[examples]" # spaday + transports + starlette + uvicorn
Host a model and wire the page¶
Host a model in a transports Session, author a tree whose controls two-way bind to its fields, and
serve it with wire="transports" — supply the websocket route and run autosync as a background task:
import transports, uvicorn
from pydantic import BaseModel
from starlette.routing import WebSocketRoute
from spaday import element
from spaday.backends.starlette import serve
from spaday_webawesome import WaInput, WaSwitch
class Controls(BaseModel):
label: str = "hello"
on: bool = True
session = transports.Session()
session.host(Controls())
server = transports.Server(session)
def page():
return (
element("div")
.child(WaInput().bind("value", "label", mode="two-way"))
.child(WaSwitch().bind("checked", "on", mode="two-way"))
)
app = serve(
page,
packages=["webawesome"],
wire="transports",
routes=[WebSocketRoute("/ws", transports.ws_endpoint(server))],
background=[transports.autosync(server)], # fan host-side changes to every client
)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
There are no event handlers in the tree — the two-way bindings carry every control→model edit.
Inbound model patches flow model → store → bound props; a two-way control’s change becomes a
server-authoritative proposal. The control keeps its latest local value while proposals are pending, so
an older server echo cannot replace newer input. An accepted proposal applies the server’s canonical
value, and a rejection restores the last authoritative value. Other server patches continue to update
the store while an edit is pending. Generated Wire connections use the managed transports client for
sending and disconnect abandonment. The single-model reconnect=True form also retries the connection.
A complete, runnable version is spaday/examples/reactive.py.
Generic controls with a two-way value binding also bind their error presentation to
$errors.<field>. A rejected proposal writes the server’s validation message there; a new edit or an
accepted proposal clears it. The runtime also dispatches spaday:reject on document. Its detail has
the model namespace, bound store field, model id, revision, proposal id, and error string.
connectStore can also send through a callback for a socket owned by application code. Return false
when that socket is not open; WebSocket.send() can silently discard data while closing:
const link = connectStore(store, client, (frame) => {
if (ws.readyState !== WebSocket.OPEN) return false;
ws.send(frame);
return true;
}, codec);
ws.addEventListener("close", () => link.disconnect());
disconnect() abandons proposals the server may not have received and restores later local edits from
the authoritative mirror until another frame arrives. Generated Wire connections do this through the
managed transports client.
Bind collaborative CRDT fields¶
connectStore also accepts transports CRDT snapshots. It reads the model’s CrdtSpec and maps a
two-way field update to its declared policy:
register fields use
register_set;string and list sequence fields use positional
sequence_splicemutations, which transports turns into stable-ID operations before sending them.
The update is optimistic because the transports client applies and queues CRDT operations locally. Its managed connection resends queued operations after a reconnect and clears them when the server echoes their causal IDs.
Editors can include granular ranges in their change event:
new CustomEvent("editor-change", {
detail: { changes: [{ from: 4, to: 7, insert: "new" }] },
});
Bind that event with bind("doc", "doc", mode="two-way", event="editor-change"). Spaday preserves
the ranges instead of diffing the whole string. String offsets follow browser UTF-16 indexing;
transports converts them to Unicode-scalar sequence positions. Controls that report only their final
value still work through a minimal prefix/suffix splice.
Whole map and set replacement is not inferred from a control event. Bind their register leaves or send explicit CRDT mutations when an application needs map membership or keyed-set operations.
Go multi-tenant¶
Swap the Session for a Hub, which routes each connection to its
own tenant session (and can share models across tenants). The UI code is unchanged — connectStore and
the bindings don’t know whether the model is private or shared.
Several models on one page¶
Pass one Wire, or a list of them, to configure transports connections. Several models share one
store, so give each one a namespace when their fields could collide:
from spaday import Wire, field
from spaday.backends.starlette import serve
app = serve(
page,
wire=[
Wire("/ws", namespace="global"), # a shared model
Wire("/ws/session", namespace="session", session=True), # a fresh per-tab tenant (a Hub)
Wire("/ws/cfg", namespace="cfg", flatten=False), # an opaque map/dict field, mirrored whole
],
routes=[...], # one WebSocketRoute per wire
background=[...], # one autosync per Server/Hub
)
The tree then binds against namespaced fields — bind("value", "global.type"),
compute("data", field("global.data")). A Wire:
namespace— mirror the model’s fields under<namespace>.(omit for bare fields, e.g. a form).session— append?session=<uuid>, making the model a fresh per-page-load tenant.flatten— recurse nested sub-models into dottedparent.childfields (the default, what a form binds); setFalsefor an opaque map/dict field (a chart’s time-keyeddata, a Perspective layout) so it’s mirrored whole instead of one store field per key.codec— select JSON, MessagePack, CBOR, or a registered transports codec.batch— request batched server messages for this connection.reconnect— use the managed reconnect loop. It is off by default for eachWire.retryandauthority— set the reconnect delay in milliseconds and choose"server"or"client"state as authoritative after reconnect.connected— publish a connection-ready boolean to an exact store field. A namespaced wire already publishes<namespace>.connected; set this option for a bare wire or to choose another field. It becomes true when the WebSocket opens, including a reconnect where the client is already current and the server sends no model frame.
For example, this single connection uses MessagePack, requests server batching, retries after 250 ms,
and exposes its state as editor_connected:
Wire(
"/ws/editor",
codec="msgpack",
batch=True,
reconnect=True,
retry=250,
connected="editor_connected",
)
A raw {"url": …, "namespace": …} dict works anywhere a Wire does. The omnibus
(python -m spaday.examples) wires four models this way.
Component adapters that need a transports API outside model bindings can listen for
spaday:wire-client. Spaday dispatches it after connectStore is configured and before the client
connects:
document.addEventListener("spaday:wire-client", ({ detail }) => {
const { client, link, store, namespace, url } = detail;
link.onModel((modelId) => {
// Use client APIs scoped to this wire's model.
});
});
The event exposes the existing managed client and its StoreLink. Use them for transport-owned
features such as ephemeral awareness instead of opening a second WebSocket. link.modelId is
undefined until the initial snapshot; link.onModel() runs after that snapshot has synchronized the
store and calls late subscribers immediately. Match namespace or url when a page has several wires.
The client, link, and store are live objects for browser adapters, not serializable page state.
Perspective (Mode B)¶
A live Perspective table streams its data over Perspective’s own websocket; spaday/transports sync
only a small config model (server, tables, layout). Mirror the config with flatten=False and feed it
to the panel with a computed config prop, so a server push re-restores the workspace for every tab:
from spaday import field, obj
from spaday_perspective import PerspectivePanel
PerspectivePanel().compute("config", obj({
"ws_url": field("cfg.ws_url"), "tables": field("cfg.tables"), "layout": field("cfg.layout"),
}))
Install spaday-perspective and select its assets with packages=["perspective"] when serving the page.
See spaday/examples/gateway.py (no transports — REST + Perspective’s own ws) and the omnibus for the
full pattern.