Protocol
The messages between the sync client and a backend
You only need this page to write your own backend or transport. The built-in sync server already speaks the protocol.
Sync has three layers:
- a transport carries messages;
wsTransportis built in - the client (
createSyncClient) connects, reconnects and keeps the state - the server holds the files, checks permissions and answers
Messages
Every message is an object with a type. Yjs data is a Uint8Array, everything else is JSON.
type | Sent by | Purpose |
|---|---|---|
hello | both | open the connection |
subscribe | client | follow a resource |
unsubscribe | client | stop following it |
update | both | the state of a resource, or a change to it |
error | server | refuse a message |
Both directions are typed as ClientMessage and ServerMessage in
@fumadocs-editor/core/sync.
client({ type: "hello", id: 0, protocol: 1, auth: "token" });
server({ type: "hello", id: 0, user: { name: "Ada" } });Rules
- A connection starts with
hello. Anerrorin reply closes it. - A message with an
idgets exactly one reply with the sameid: anupdate, or anerror. A message without anidnever gets a reply. - A change is sent to every other subscriber as an
updatewithout anid. - There is no resume. After a reconnect, the client says
helloand subscribes again.
Resources
A message targets one resource. Paths are relative to the server root.
| Resource | Holds |
|---|---|
{ resource: "file", path } | a file's text |
{ resource: "tree" } | the file tree |
{ resource: "doc", path } | a collab Yjs document |
client({ type: "subscribe", id: 1, resource: "file", path: "index.mdx" });
server({
type: "update",
id: 1,
resource: "file",
path: "index.mdx",
text: "# Hello",
version: "v1",
writable: true,
});
client({ type: "update", id: 2, resource: "file", path: "index.mdx", text: "# Hi", base: "v1" });
// saved
server({ type: "update", id: 2, resource: "file", path: "index.mdx", version: "v2" });
// or, when `base` is stale: the file as it is now
server({
type: "update",
id: 2,
resource: "file",
path: "index.mdx",
text: "# Hey",
version: "v3",
});versionis opaque;""means the file does not exist.- A save sends
base, the version it was edited from. The server only writes whilebaseis current.
Custom transports
Pass any SyncTransport as the client's transport. Each connect call is
one connection:
import type { SyncTransport } from "@fumadocs-editor/core/sync";
// a backend running in a worker, speaking the protocol over postMessage
export function workerTransport(url: URL): SyncTransport {
return {
connect(listener) {
const worker = new Worker(url, { type: "module" });
let open = true;
const close = () => {
if (!open) return;
open = false;
worker.terminate();
listener.close();
};
worker.onmessage = (event) => listener.message(event.data);
worker.onerror = close;
queueMicrotask(() => listener.open());
return {
send(message) {
if (open) worker.postMessage(message);
},
close,
};
},
};
}
- Never call the listener synchronously inside
connect. - Call
closeonce per connection, including afterclose(). - Drop messages sent while the connection is not open.
- The Vite plugin mounts it at
SYNC_ENDPOINT, wherewsTransport()connects by default. - One message per frame. Messages without bytes are JSON text frames.
- A message with bytes is a binary frame: a big-endian
u32header length, the JSON header listing the byte fields as$bytes: [[field, length]], then the bytes in that order. - An empty frame is a heartbeat: the client sends one every 20 s and the server echoes it. Either side drops a silent connection.
Uploads and assets are plain HTTP, called by your media provider:
| Endpoint | Request | Response |
|---|---|---|
POST UPLOAD_ENDPOINT | the file as body, its name in x-filename | { src } |
GET ASSET_ENDPOINT/<path> | the file |
Both read the auth payload from the AUTH_HEADER header, JSON-encoded.
Last updated on