Skip to main content

Collaboration with Yjs

@vaultys/peer-sdk/yjs syncs a Yjs document between peers. Yjs gives you shared types (text, maps, arrays) that merge concurrent edits automatically, and editor bindings for Quill, ProseMirror, CodeMirror, Monaco and others.

npm install yjs y-protocols lib0

A shared document​

import * as Y from "yjs";
import { YjsProvider } from "@vaultys/peer-sdk/yjs";

const doc = new Y.Doc();
const provider = new YjsProvider(peer, {
doc,
room: "design-review",
peers: [aliceDid, bobDid],
});

const notes = doc.getText("notes");
notes.observe(() => render(notes.toString()));
notes.insert(0, "Agenda: ");

Everyone in the room who lists the others syncs the same document. When a member connects, the two sides exchange what the other is missing, so it doesn't matter who edited what while offline.

The provider keeps trying to reach members who are offline (every 5 seconds by default, reconnectMs), so the order in which people open the room doesn't matter.

Presence: cursors, names, selections​

Each provider has a Yjs awareness instance for ephemeral state:

provider.awareness?.setLocalStateField("user", { name: "Alice", color: "#4f386e" });

provider.awareness?.on("change", () => {
for (const [clientId, state] of provider.awareness!.getStates()) {
showUser(clientId, state.user);
}
});

When a member disconnects, their presence is removed automatically. Pass awareness: false if you don't need presence, or your own Awareness instance to share one with an editor binding.

import { QuillBinding } from "y-quill";
new QuillBinding(doc.getText("body"), quill, provider.awareness!);

Who may join​

By default, the provider only syncs with the DIDs in peers and those you add with addPeer(). Frames from anyone else are ignored.

To let people join without being listed, for example everyone who has the room's link, use admit. They're added on their first message:

new YjsProvider(peer, {
doc,
room: "public-board",
admit: (did) => true, // or check a list, a signature, a payment…
});

admit controls the room. The peer's acceptIncoming policy still decides whether a session can be opened at all, so set both when you host a room for strangers.

Owner-authoritative rooms​

In an open room, any member can change anything. For rooms with a host, ownedBy sets up a star topology:

import { YjsProvider, ownedBy } from "@vaultys/peer-sdk/yjs";

const isOwner = peer.did === ownerDid;

new YjsProvider(peer, {
doc,
room: `board@${ownerDid}`,
peers: isOwner ? [] : [ownerDid], // participants only talk to the owner
admit: () => isOwner, // the owner admits joiners
acceptUpdate: ownedBy(ownerDid, peer.did),
});
  • The owner accepts updates from everyone and relays them to the other members.
  • Participants only accept updates from the owner. A participant can't change the room for others without the owner merging it.

The owner has to be online for participants to see each other's changes. Participants' edits made while the owner is away sync when it returns.

Persisting the document​

The provider syncs; it doesn't store. To keep a document across reloads, save its state yourself, for example in the same store as the peer:

const key = `${peer.did}/docs/design-review`;
const saved = await store.get(key);
if (saved) Y.applyUpdate(doc, saved);

doc.on("update", debounce(() => store.set(key, Y.encodeStateAsUpdate(doc)), 1000));

For larger documents, y-indexeddb works alongside the provider.

Cleaning up​

provider.destroy(); // stops syncing, removes your presence; the peer stays open

Protocol details​

A provider talks on the protocol yjs/<room>, using the standard Yjs sync protocol (state vector, then missing updates) plus awareness updates. You can run several providers (rooms) on the same peer.