Wire protocol¶
OpStream's three transports (SignalR, WebSockets, gRPC) all serialize the same logical messages. Engines determine the shape of the op payload; the envelope around it is uniform.
Protocol versioning¶
A single integer:
Clients send their ProtocolVersion with JoinDocument. A mismatch
rejects the join with "UnsupportedProtocol".
Logical messages¶
JoinDocument (client → server)¶
Returns SessionJoinResult:
{
"revision": 14,
"snapshot": "<base64 of JSON-serialized TDoc>",
"pendingOps": [],
"currentAwareness": [
{ "peerId": "p-3", "data": { ... }, "lastUpdated": "..." }
]
}
SendOp (client → server)¶
Where payload is JsonSerializer.SerializeToUtf8Bytes(TOp,
OpStreamJsonOptions.Default). Returns OpApplyResult:
{ "success": true, "newRevision": 15 }
// or on rejection:
{ "success": false, "newRevision": 14, "errorMessage": "Forbidden: ..." }
UpdateAwareness (client → server)¶
Returns the freshly-stored AwarenessState.
ReceiveOp (server → client)¶
The payload is the server-transformed op — already rebased
through OT / CRDT against any concurrent ops the peer hadn't seen.
ReceiveAwareness (server → client)¶
Sent on join with the full live snapshot; later deltas come via
ReceiveAwarenessUpdate (a single AwarenessState).
PeerDisconnected (server → client)¶
Op payloads per engine¶
The bytes inside payload are engine-specific. Use the discriminator
"type" field on each polymorphic op variant. Examples:
TextOp¶
RichTextOp¶
{ "components": [
{ "type": "retain", "count": 5 },
{ "type": "retain", "count": 5, "attributes": { "bold": true } }
] }
JsonOpBatch¶
{ "operations": [
{ "type": "set", "path": "user.name", "value": "Alice", "timestamp": 100, "peerId": "p-1" }
] }
TreeOpBatch¶
{ "operations": [
{ "type": "move", "nodeId": "A", "newParentId": "__root__",
"newPosition": "m", "newPayload": null, "timestamp": 100, "peerId": "p-1" }
] }
TableOpBatch¶
{ "operations": [
{ "type": "set_cell", "rowId": "R1", "columnId": "C1",
"value": "hello", "timestamp": 100, "peerId": "p-1" }
] }
FormOpBatch¶
{ "operations": [
{ "type": "set", "fieldName": "email", "value": "a@b.c",
"timestamp": 100, "peerId": "p-1" }
] }
JSON conventions¶
All serialization uses OpStreamJsonOptions.Default:
- camelCase property names.
- Polymorphic types via
[JsonDerivedType(..., "discriminator")]. - Enums as strings (lowercase).
JsonElementpreserved verbatim for opaque payloads.
A non-.NET client should match these conventions exactly. The simplest way is to round-trip a known-good payload through your client and compare bytes to the server's expected shape.
Backplane envelope (cross-node only)¶
If you're integrating directly with the backplane (e.g. writing a custom transport that forwards into OpStream), the message types are:
| Type | Direction | Payload |
|---|---|---|
OpStreamConstants.BackplaneMessages.OpApplied |
Pub/sub fan-out | OpAppliedBackplanePayload(opBytes, revision) |
OpStreamConstants.BackplaneMessages.ReceiveAwarenessUpdate |
Pub/sub fan-out | Serialized AwarenessState |
OpStreamConstants.BackplaneCommands.JoinDocument |
RPC | JoinRequestData |
OpStreamConstants.BackplaneCommands.ApplyOp |
RPC | ApplyOpRequestData |
OpStreamConstants.BackplaneCommands.UpdateAwareness |
RPC | UpdateAwarenessRequestData |
These are internal for normal use. Build your own client transports against the three logical messages above, not the backplane envelope.