Fumadocs Editor

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; wsTransport is 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.

typeSent byPurpose
hellobothopen the connection
subscribeclientfollow a resource
unsubscribeclientstop following it
updateboththe state of a resource, or a change to it
errorserverrefuse 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. An error in reply closes it.
  • A message with an id gets exactly one reply with the same id: an update, or an error. A message without an id never gets a reply.
  • A change is sent to every other subscriber as an update without an id.
  • There is no resume. After a reconnect, the client says hello and subscribes again.

Resources

A message targets one resource. Paths are relative to the server root.

ResourceHolds
{ 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",
});
  • version is opaque; "" means the file does not exist.
  • A save sends base, the version it was edited from. The server only writes while base is current.

Custom transports

Pass any SyncTransport as the client's transport. Each connect call is one connection:

worker-transport.ts
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 close once per connection, including after close().
  • Drop messages sent while the connection is not open.

Last updated on

On this page