This document distills the repository's specs and APIs into a compact guide
for language-model agents. It covers the binary protocol, the %ELO
end-to-end encrypted extension, and the full surface of the
LoroWebsocketClient.
- Transport agnostic: Works over WebSocket or any ordered, reliable link.
- Multiplexed rooms: Each message carries a CRDT magic prefix plus a room identifier; rooms of different CRDT types are distinct even if they share an ID string.
- CRDT magic bytes (first four bytes):
%LOR– Loro document updates (default).%EPH– Loro ephemeral store.%YJS– Yjs document updates.%YAW– Yjs awareness.%ELO– Encrypted Loro document updates (see §3).
- Envelope fields (after magic bytes):
varBytes roomId(max 128 bytes).u8 messageType.- Type-specific payload.
- Message size ceiling: 256 KiB. Larger payloads must be fragmented with
DocUpdateFragmentHeader+DocUpdateFragment. - Keepalive: Plain-text
"ping"/"pong"WebSocket frames bypass the envelope; they are connection-scoped and never forwarded to rooms.
| Type ID | Name | Payload Summary |
|---|---|---|
0x00 |
JoinRequest |
varBytes joinPayload (app-defined metadata, e.g., auth/session info), varBytes version. |
0x01 |
JoinResponseOk |
varString permission ("read"/"write"), varBytes version, varBytes extraMetadata. |
0x02 |
JoinError |
u8 code, varString message, optional varBytes receiverVersion when code=version_unknown. |
0x03 |
DocUpdate |
varUint N updates followed by N varBytes chunks, then 8-byte batchId. |
0x04 |
DocUpdateFragmentHeader |
8-byte batchId, varUint fragmentCount, varUint totalSizeBytes. |
0x05 |
DocUpdateFragment |
8-byte batchId, varUint index, varBytes fragment. |
0x06 |
RoomError |
u8 code, varString message; receipt means the peer is evicted from the room until rejoin. |
0x07 |
Leave |
No additional payload. |
0x08 |
Ack |
8-byte refId (batch or fragment ID) + u8 status (see §1.4). |
- Client (
Req) sendsJoinRequestwith a join payload (auth or other metadata) and local version. - Server (
Recv) responds:JoinResponseOkwith current version and permission, then streams backfills viaDocUpdate/DocUpdateFragment.- or
JoinErrorwhen the join payload is rejected (e.g., auth failure) or for unknown version. Onversion_unknown, the server includes its version for reseeding.
- Clients broadcast local edits using
DocUpdate(with an 8-bytebatchId). Oversize payloads are sliced into fragments: send header first, then numbered fragments. Recipients reassemble bybatchId. - Server replies with
Ackfor everyDocUpdate/fragment batch:status=0on success, non-zero mirrors legacyUpdateErrorcodes. - Clients send
Leavewhen unsubscribing from a room. - Servers send
RoomErrorto evict a peer from a room; the peer must rejoin to resume traffic.
MAX_MESSAGE_SIZE = 256*1024bytes.- Clients split oversize updates into fragments smaller than the limit minus overhead (~240 KiB per fragment in the current implementation).
- Receivers store fragments per
batchId, expect allfragmentCountentries, and enforce a default 10-second reassembly timeout. - On timeout, receivers send
Ackwithstatus=fragment_timeout; senders SHOULD resend the full batch (header + fragments).
JoinErrorcodes:0x00 unknown0x01 version_unknown(receiverVersionincluded)0x02 auth_failed0x7F app_error(varString app_code)
Ack.statuscodes (mirrors legacyUpdateError):0x00 ok0x01 unknown0x03 permission_denied0x04 invalid_update0x05 payload_too_large0x06 rate_limited0x07 fragment_timeout0x7F app_error
RoomErrorcodes:0x01 unknown(eviction; peer must rejoin)
- Protocol violations MAY be raised via host callbacks; implementations often close the connection on unrecoverable errors.
- Exactly the ASCII text
"ping"/"pong"(WebSocket text frames). - Either side MAY send
"ping"; the peer MUST reply with"pong"promptly. - Keepalive frames are never associated with rooms, are not broadcast, and must not be parsed as protocol messages.
%ELO introduces encrypted DocUpdate payloads while keeping the base envelope,
handshake, and keepalive unchanged. Only the body of DocUpdate (and the
reassembled bytes from fragments) differs.
DocUpdatePayload :=
varUint recordCount
record[0] … record[recordCount-1]
record := varBytes recordBytes
Fragmentation happens after container encoding if serialized bytes exceed the 256 KiB limit.
Each recordBytes contains a plaintext header followed by AES-GCM ciphertext
(ct || 16-byte tag). Two kind values exist:
0x00 DeltaSpanvarBytes peerId(≤64 bytes recommended).varUint start(inclusive) andvarUint end(exclusive,end > start).varString keyId(≤64 UTF-8 bytes).varBytes iv(12-byte nonce, explicit per record).varBytes ct(ciphertext with tag).
0x01 SnapshotvarUint vvCount.- Repeat
vvCounttimes (sorted by peerId):varBytes peerId,varUint counter. varString keyId.varBytes iv(12-byte).varBytes ct.
The server parses headers for routing/deduplication but never decrypts ct.
- Cipher: AES-GCM-256 (AES-GCM-128 permitted where necessary).
- IV: 96-bit (12 bytes). MUST be unique per symmetric key.
- Tag: 128-bit (16 bytes).
- AAD: Exact encoded plaintext header (kind + all encoded fields including
iv). Encoders MUST feed the encoded header bytes into AEAD; decoders MUST treat authentication failure as fatal. - Keys: Managed at the application layer. Implementations expose a hook
(e.g.,
getPrivateKey(keyId)returning{ keyId, key }) to fetch the correct key material. Key rotation is handled by publishing updates under a newkeyId. - Decryption failures: Clients report locally (e.g.,
onDecryptError); servers cannot detect them. - Normative test vector: See
protocol-e2ee.mdfor a canonical DeltaSpan example (key, IV, header encoding, ciphertext).
- Message size remains bounded by the base protocol (use fragments as needed).
- Receivers SHOULD deduplicate spans via
peerId/start/endmetadata. - Unknown
keyIdSHOULD trigger key resolution and a retry; persistent failure is surfaced locally; no Ack is emitted because encryption/auth is end-to-end.
High-level client that speaks the protocol, handles reconnection, latency
tracking, and room management. Designed to pair with CRDT adaptors from
loro-adaptors.
import { LoroWebsocketClient } from "loro-websocket";
import { LoroAdaptor } from "loro-adaptors";
// In Node, provide a WebSocket implementation:
import { WebSocket } from "ws";
(globalThis as any).WebSocket = WebSocket as typeof globalThis.WebSocket;
const client = new LoroWebsocketClient({ url: "ws://localhost:8787" });
await client.waitConnected();Adaptors bridge the client to actual CRDT state:
LoroAdaptor– Loro document (const adaptor = new LoroAdaptor()).LoroEphemeralAdaptor– transient presence (%EPH).EloAdaptor–%ELOencrypted Loro withgetPrivateKey().
new LoroWebsocketClient({
url: string, // Required ws:// or wss:// endpoint.
pingIntervalMs?: number, // Default 20_000 ms.
disablePing?: boolean, // Skip periodic ping/pong entirely.
onWsClose?: () => void, // Invoked on low-level close before status change.
});Instantiation triggers an immediate connect() with exponential backoff on
failure.
waitConnected(): Promise<void>– Resolves once the socket reaches OPEN.connect(): Promise<void>– Manually initiate/resume connection. Also re-enables auto-reconnect if the client was previouslyclose()d.close(): void– Gracefully close, flush pending frames, transition toDisconnected, and disable auto-reconnect.destroy(): void– Full teardown: remove listeners, stop timers, reject pending operations, close the socket, and disable reconnects permanently.
getStatus(): ClientStatusValue–"connecting" | "connected" | "disconnected".onStatusChange(cb): () => void– Subscribe to status changes; invokes the callback immediately with the current status. Returns an unsubscribe fn.ping(timeoutMs?: number): Promise<void>– Send an app-level"ping"and resolve on the matching"pong"; rejects on timeout (default 5 s). Ensures the connection is open before sending.getLatency(): number | undefined– Last measured RTT (ms) from ping/pong.onLatency(cb): () => void– Subscribe to latency updates; emits the last known RTT immediately if available.
- Retries begin ~500 ms after an unexpected close, doubling every attempt up to 15 s (500 ms → 1 s → 2 s → 4 s … 15 s cap). Success resets the backoff.
- Network offline events pause retries; they resume immediately on
online. - Calling
close()ordestroy()stops auto-retries; laterconnect()restarts them from the base delay.
const adaptor = new LoroAdaptor();
adaptor.getDoc().setPeerId(1);
const room = await client.join({
roomId: "doc-123",
crdtAdaptor: adaptor,
auth?: Uint8Array, // Optional join metadata forwarded to the server.
});- Room identities are keyed by
<crdtType><roomId>. Joining the same pair twice reuses the existing promise/room. - Upon
JoinResponseOk:- The adaptor receives
setCtx({ send, onJoinFailed, onImportError }). - The client registers the room so reconnects automatically re-send
JoinRequestand replay buffered updates (special handling for%ELOto cover backfills that race the join).
- The adaptor receives
joinrejects on non-recoverableJoinError. Whencode=version_unknown, adaptors can supplygetAlternativeVersion()to retry before falling back to an empty version.
The resolved room implements:
leave(): Promise<void>– SendLeaveover the current socket. No effect if already destroyed.waitForReachingServerVersion(): Promise<void>– Resolves when the adaptor reports that the local document version is ≥ the server’s version.destroy(): Promise<void>– Callsleave(), destroys the adaptor, removes the room from the client, and clears listeners. Idempotent.
- Adaptors call
send(updates: Uint8Array[])supplied insetCtx. The client encodes each update asDocUpdateor fragments as needed (header then numbered fragments). Fragment payloads reserve ~4 KiB belowMAX_MESSAGE_SIZEto stay within limits. - On receiving
DocUpdate/fragments, the client reassembles updates and passes them tocrdtAdaptor.applyUpdate. Ackmessages with non‑zero status triggercrdtAdaptor.onUpdateError(updates, status)using the original sent batch; missing batches are still logged.
- Periodic ping (default 30 s) sends
"ping"only when no RTT probe is in flight. Disable withdisablePingor adjust interval. Receipt of"pong"clears waiters, updates latency, and resets the RTT timer. - Manual
ping(timeoutMs)registers a waiter with its own timeout and reuses the same shared"pong"handling.
close()flushes frames, closes the socket with code1000, cancels timers, rejects in-flight promises, and leaves ping waiters withDisconnected.destroy()additionally detaches global online/offline listeners and should be called before discarding the client instance.
The SimpleServer (Node, ws-based) provides a minimal server matching the
protocol for local development and tests.
import { SimpleServer } from "loro-websocket/server";
const server = new SimpleServer({
port: 8787,
host?: string,
saveInterval?: number, // Default 60_000 ms.
authenticate?: async (roomId, crdtType, auth) => "read" | "write" | null, // auth is join metadata
onLoadDocument?: async (roomId, crdtType) => Uint8Array | null,
onSaveDocument?: async (roomId, crdtType, data) => void,
});
await server.start();
// ...
await server.stop();Key behaviors:
- Tracks per-room CRDT documents via adaptor-compatible helpers.
- Handles
"ping"/"pong"keepalive frames at the text layer. - Saves dirty documents periodically when
onSaveDocumentis provided. - Enforces message size limits and reassembles fragments mirroring the client.
- Always respect the 256 KiB message ceiling; rely on the client’s automatic fragmentation rather than hand-rolling your own.
- When operating on
%ELO, ensure your adaptor can fetch keys bykeyIdand pass 12-byte IVs to encryption helpers. - Subscribe to
onStatusChangeto gate CRDT mutations behindConnected. - Use
waitForReachingServerVersion()before assuming local state matches the server (important after reconnects). - For join metadata (auth tokens, roles, etc.), encode bytes as
Uint8Arrayand pass viajoin({ auth }). - Remember that keepalive frames are raw text messages: handle them before attempting to decode binary protocol messages.
This reference should equip an LLM agent to reason about the binary protocol,
the %ELO extension, and the WebSocket client/server APIs without re-reading
the full repository.