Skip to content

Yjs - 01 - The good days of collaboration system

CRDT is the most beautiful tool I've ever seen on building a collaborative application.

Let's go collaborate

A Doc instance is what we should get started with.

ts
import { Doc } from 'yjs'

const doc = new Doc()

// send local update to peers and persist it in db
doc.on('update', (update: Uint8Array) => {
  peer.push(update)
  local.save(update)
  server.push(update)
})

The update event on Doc instance is where we broadcast the updates to peers, push them to centerized server or local DB to have our data persisted. It's quite straightforward.

Updates received in update event are well encoded as binary format(Uint8Array), which can be parsed by decodeUpdate or applied to other doc by applyUpdate.

We could create and get a text type from the doc, and manipulate it. Alongside any changes happened on the text, there will be following update event triggered internally.

ts
const ytext = doc.getText('content')

ytext.insert(0, 'Hello world!') // trigger `update`
ytext.insert(5, ',')            // trigger `update`

ytext.toString() // "Hello, world!"

With the basic understanding on how Yjs works, we can now bind the content with an editor like Monaco or ProseMirror.

The follwing demonstrations will be using the Monaco editor with y-monaco binding. y-monaco is a simple wrapper layer against Monaco and Yjs, provides out-of-box connecting ability between Monaco editor model with Yjs. It's not a full-featured wrapper, but basically meets our requirement on demonstrating.

ts
import { MonacoBinding } from "y-monaco"
import { Doc } from 'yjs'

class CollaborativeDoc {
  #doc = new Doc()

  bind(el: HTMLElement) {
    const ytext = this.#doc.getText('content')
    const editor = renderMonacoEditor(el)

    const binding = new MonacoBinding(
      ytext,
      editor.getModel(),
      new Set([editor]),
    )

    return { editor, binding }
  }
}

We now have a minimal usable editor. Any changes in the editor, will reflect as an update to bound Doc instance.

Try typing some words in below:

Once we have Monaco connected with Yjs successfully, it's time to make dual or multiples editors collaborative. I'm gonna use BroadcastChannel to act like peer-to-peer network broadcasting so we don't rely on a server to do the proxy.

ts
import { Doc } from 'yjs'
import { Doc, applyUpdate } from 'yjs'

class CollaborativeDoc {
  #doc = new Doc()
  #channel = new BroadcastChannel('yjs')                           
 
  constructor() {
    this.#channel.addEventListener('message', this.#onPeerMessage) 
    this.#doc.on('update', this.#onLocalUpdate)                    
  }

  #onPeerMessage(event) {                                          
    if (event.data.type === 'update')                              
      applyUpdate(this.#doc, event.data.data, 'peer')              
  }                                                                
  #onLocalUpdate(update, origin) {                                 
    if (origin === 'peer') return
    this.#channel.postMessage({ type: 'update', data: update })    
  }                                                                
}

Two things was done with above updates:

  • Receiving peers' doc update by message event of BroadcastChannel.
  • Sending local doc to peers by postMessage method, in update event of doc.

But there is yet another super important thing: remote cursor. A remote cursor or remote selection indicator could give us quick notice that somebody is on some line about to typing or deleting some words, otherwise it will look like some "ghost" is hebind you and touching your code. Yjs also provide it built in, with a protocol name Awareness.

Let's make it happen.

ts
import {                         
  Awareness,                     
  applyAwarenessUpdate,          
  encodeAwarenessUpdate,         
} from "y-protocols/awareness"

class CollaborativeDoc {
  #doc = new Doc()
  #awareness = new Awareness(this.#doc)                                  
  #channel = new BroadcastChannel('yjs')
 
  constructor() {
    this.#channel.addEventListener('message', this.#onPeerMessage)
    this.#doc.on('update', this.#onLocalUpdate)
    this.#awareness.on('update', this.#onLocalAwarenessUpdate)          
  }

  #onPeerMessage(event) {
    if (event.data.type === 'update') {
      applyUpdate(this.#doc, event.data.data, 'peer')
    if (event.data.type === 'awareness')                                
      applyAwarenessUpdate(this.#awareness, event.data.data, 'peer')    
  }

  #onLocalAwarenessUpdate(changes, origin) {                            
    if (origin === 'peer') return
    const changedClients = added.concat(updated).concat(removed)        
    this.#channel.postMessage({                                         
      type: 'awareness',                                                
      data: encodeAwarenessUpdate(this.#awareness, changedClients)      
    })                                                                  
  }                                                                     

  bind(el: HTMLElement) {
    // ...
    const binding = new MonacoBinding(
      this.#doc.getText('content'),
      editor.getModel(),
      new Set([editor]),
      this.#awareness                                                   
    )
  }
}

Likewise, the Awareness protocol of Yjs shares state in the form of binary as well. All we need to do is encoding and send updates to peers by encodeAwarenessUpdate, and applying updates from peers to local awareness instance by applyAwarenessUpdate.

So here we go. We now have a fully collaborative editor. Clicking on the [Add Peer] button to add more peers, and don't forget opening a new browser tab to join the game too!

Overview

By walking through this episode, we achieved the following:

  • We learnt a little how to use Yjs text type
  • We created a Monaco editor bound with Yjs text type
  • We created a mocked peer-to-peer network to practice broadcasting doc updates

Using Yjs is extremely easy. It takes care all complicated details for us so that we could do all these within 100 lines of code.

What's next?

There is always “but”.

Till now, I only showed you exactly what you will see in all other CRDT or Yjs tutorials. Although things work as expected, there are already some problems invisible.

In next episode, I will lead you go deeper to catch those problems and discuss on how can we avoid them.

INFO

Code runs in this post is available at GitHub

This work is licensed under CC BY-NC-SA 4.0