How to bridge models and objects¶
This guide shows you how to convert supported Python models and JavaScript objects to the core
Value form, host them in a Session, and generate TypeScript interfaces from Python types.
Use pydantic models¶
import transports
from pydantic import BaseModel
class Device(BaseModel):
name: str
on: bool = False
model = Device(name="lamp")
value = transports.to_value(model)
restored = transports.from_value(value, Device)
assert restored == model
Pydantic models are observed automatically when hosted:
session = transports.Session()
mid = session.host(model)
model.on = True
patches = session.drain()
Use dataclasses¶
from dataclasses import dataclass
import transports
@dataclass
class Reading:
sensor: str
value: float = 0.0
reading = Reading(sensor="temp")
session = transports.Session()
session.host(reading)
reading.value = 21.5
print(session.drain())
Dataclasses are observed automatically, including nested lists, dicts, and model fields.
Use msgspec structs¶
msgspec.Struct instances use slots, so automatic mutation watching is not available. Mutate the
struct and call Session.update(id).
import msgspec
import transports
class Reading(msgspec.Struct):
sensor: str
value: float = 0.0
reading = Reading(sensor="temp")
session = transports.Session()
mid = session.host(reading)
reading.value = 21.5
print(session.drain())
# []
print(session.update(mid))
# [(1, {'rev': 1, 'ops': [...]})]
Convert values manually¶
Use to_value when a lower-level API expects the core wire value. Use from_value to materialize a
mirrored value as a Python model.
value = transports.to_value(Device(name="lamp", on=True))
# {'Map': {'name': {'Str': 'lamp'}, 'on': {'Bool': True}}}
model = transports.from_value(value, Device)
Generate TypeScript from a Python model¶
schema = transports.schema_of(Device)
print(transports.schema_to_ts(schema))
Output:
export interface Device {
name: string;
on: boolean;
}
Bridge plain JavaScript objects¶
import { fromValue, toValue } from "1kbgz/transports";
const value = toValue({ name: "lamp", on: true });
// { Map: { name: { Str: "lamp" }, on: { Bool: true } } }
const object = fromValue(value);
// { name: "lamp", on: true }
Use the JavaScript bridge before calling Client.edit, because edits compare core Value objects:
ws.send(client.edit(id, toValue({ name: "lamp", on: false })));
Transfer binary fields¶
Declare binary data as bytes on pydantic models, dataclasses, or msgspec structs:
class Sample(BaseModel):
data: bytes = b""
sample = Sample(data=bytes([0, 255, 1]))
mid = session.host(sample)
Use Client(codec="msgpack") or Client(codec="cbor") for native binary WebSocket payloads.
Comm and anywidget carry these bytes in separate message buffers automatically. JSON/SSE uses
base64. The input bridge also accepts bytearray and memoryview.
In JavaScript, pass a Uint8Array or another ArrayBufferView through toValue. fromValue
returns a Uint8Array, preserving the underlying buffer when a native view is available.
Client handles wire encoding. For a low-level JSON API, use valueToJson(toValue(data));
ordinary JSON.stringify does not encode typed arrays as the protocol’s base64 byte strings.
Let the frontend own status¶
Mark browser-derived status separately from server-controlled fields:
from pydantic import Field
class Clock(BaseModel):
is_playing: bool = Field(False, json_schema_extra={"owner": "client"})
bpm: int = Field(120, ge=1)
clock = Clock()
session = transports.Session()
mid = session.host(clock)
server = transports.Server(session)
session.command(mid, "is_playing", True) # retained until an owner connects and acknowledges
For dataclasses, use field(metadata={"owner": "client"}). To make every field client-owned, pass
session.host(model, owner="client"). Field metadata overrides that model default.
Handle commands in the browser and report the resulting status:
const client = new Client("msgpack", { updateInterval: 0.25 });
client.onCommand((command) => {
const playing = fromValue(command.value);
// Apply playing to the browser's clock here, then report its resulting state.
client.proposeOps(command.id, [{
Set: { path: [{ Key: command.field }], value: toValue(playing) },
}]);
client.ackCommand(command);
});
client.connect(url);
Use client.owns(id, "is_playing") before driving local status, and onOwnership to start or stop
that work as ownership changes. Stop local work on onDisconnect as well; disconnection revokes
all grants. Python clients expose matching on_command, on_ownership,
propose_ops, and ack_command methods; managed sends are awaited. Commands received before a
listener is registered are retained. Acknowledge only after applying a command successfully. Managed ack_command / ackCommand
flush pending owned status before sending the acknowledgement; manual command_ack / commandAck
callers must send that status first.
Assign clock.bpm normally. Assigning client-owned fields from Python raises; use session.command
instead. For unobservable models such as msgspec structs, Session.update detects and restores a
forbidden assignment before raising. Nested Python mutations are restored with a warning on flush;
idle flushes do not serialize owned values. Client-owned status preserves annotated Python types but
bypasses field constraints and model validators. Invalid type representations are rejected before
changing the hosted state. Use ownership for values whose authority belongs to the frontend; keep
security-sensitive validation on server-owned fields. Server edits still run model-level validators,
including cross-field checks. Those checks must tolerate every valid owner state, or unrelated server
edits can be rejected until the owner changes that state. Keep independent owner status in a separate
model when a cross-field constraint cannot tolerate it.
Ownership grants and client paths use serialized field names, including aliases. Python assignment
and Session.command use attribute names. Changes spanning both ownership kinds must be sent as
separate edits; propose does not split a whole-model replacement automatically.
Owned nested msgspec structs with __post_init__ need an existing instance to copy. Adding a new
such instance through an owned update is unsupported, because constructing it would run that hook.
Command delivery may repeat after handover. See the protocol reference
for delivery and memory limits. Set Client(ownership=False) in Python or
new Client("json", { ownership: false }) in JavaScript for a passive WebSocket observer.
Current bridge limits¶
Nested Python models are currently inlined as Map values. The Rust core has a Submodel value,
but the Python and JavaScript bridges do not yet emit submodel-by-id references. Inlining loses
identity: the same instance referenced from two places syncs as two independent maps, and cycles
are unsupported. Type fidelity is kept. A polymorphic nested model round-trips to its concrete
subclass whenever its dump carries the discriminator. On pydantic 2.13+ the bridge dumps
polymorphically by default, so a subclass instance in a base-annotated field keeps all its fields
on the wire. Pydantic discriminated unions and ccflow-style type_ dispatch both work with no
annotations. On older pydantic, dumps follow the field’s annotation and subclass-only fields are
dropped. Opt in per field with SerializeAsAny[Base] (ccflow’s own BaseModel applies that to
every field through its metaclass).
JavaScript has one number type. Whole-valued JavaScript numbers encode as Int; other numbers
encode as Float.