DEV Community

Cover image for Synchronizing Collaborative Text Editing with Yjs and WebSockets
priolo
priolo

Posted on

Synchronizing Collaborative Text Editing with Yjs and WebSockets

In this article, I almost do a copy-paste of what's found on the internet.
It's a "reminder" for my personal project
where I analyzed the use of Yjs.

SYNCHRONIZING DOCUMENTS VIA SOCKET

I foolishly use the y-websocket library.

NODEJS SERVER

Install:

npm install ws yjs y-websocket
Enter fullscreen mode Exit fullscreen mode

create:
start.ts

import WebSocket from 'ws';
const utils = require("y-websocket/bin/utils")

/**
 * create a simple websocket server
 */
const wss = new WebSocket.Server({ port: 1234 })

wss.on('connection', (ws, req) => {

    // CONNECT the CLIENT to YJS documents
    // use the "room" passed in `req.url` (for example "/yjs-ws-demo")
    utils.setupWSConnection(ws, req)

    // and that's it.... these following are just logs
    console.log('CLIENT::CONNECTED')
    ws.on('message', message => console.log('CLIENT::MESSAGE', message))
    ws.on('close', () => console.log('CLIENT::DISCONNECTED'))
})
Enter fullscreen mode Exit fullscreen mode

run:

npx ts-node start.ts
Enter fullscreen mode Exit fullscreen mode

So the y-websocket library takes care of everything,
upon client connection, you just need to pass the websocket to the setupWSConnection method.

STORAGE

But if we shut down the server, all Yjs documents are lost.
To store them, we need to use y-leveldb.

Install:

npm install y-leveldb
Enter fullscreen mode Exit fullscreen mode

add to
start.ts

import { LeveldbPersistence } from 'y-leveldb';
import * as Y from 'yjs';

const persistence = new LeveldbPersistence('./storage')

// apply DB updates to the Yjs document
persistence.getYDoc('yjs-ws-demo').then((persistedYdoc) => {
    Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc))
    ydoc.on('update', (update: Uint8Array) => {
        persistence.storeUpdate('yjs-ws-demo', update)
    })
})
Enter fullscreen mode Exit fullscreen mode

With each update of the Yjs document, I store it in the DB.
It can be optimized by updating after a certain interval of time.
However, with the "update" event, we will always have a binary file "not in clear text"!
This interesting discussion: https://discuss.yjs.dev/t/decoding-yjs-messages/264/2
Given that Yjs is a CRDT system:
It allows distributing replicas of a document
these can be updated autonomously without a central server.
I realized that
implementing a custom storage and managing the clear text data of the "update"
could be a problem.

CLIENT SLATE

I use SLATE because these reflections are due to my current project.
Of course, SLATE has nothing to do with Yjs, it's just to give an example.
Later, I'll give an example with a textarea.

Create a ViteJs project for React and TypeScript

npm create vite@latest my-project --template react-ts
Enter fullscreen mode Exit fullscreen mode

Install:

npm install yjs y-websocket @slate-yjs/core slate slate-react 
Enter fullscreen mode Exit fullscreen mode

Replace
App.ts

import { withYjs, withYHistory, YjsEditor } from '@slate-yjs/core';
import { useEffect, useMemo, useState } from 'react';
import { createEditor, Editor, Transforms } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';

function App() {

    const [isOnline, setIsOnline] = useState(false)
    // get the Yjs document
    const ydoc = useMemo(() => new Y.Doc(), [])
    // get the shared data type
    const sharedType = useMemo(() => ydoc.get('content', Y.XmlText), [ydoc])
    // create the Slate editor connected to Yjs
    const editor = useMemo(() => {
        const e = withReact(withYHistory(withYjs(createEditor(), sharedType)))
        // Extend normalizeNode to avoid the document being empty
        // (at least one node must be there otherwise SLATE gets very angry)
        const { normalizeNode } = e
        e.normalizeNode = (entry) => {
            const [node] = entry
            if (!Editor.isEditor(node) || node.children.length > 0) {
                return normalizeNode(entry)
            }
            Transforms.insertNodes(editor, [{ children: [{ text: '' }] }], { at: [0] })
        }
        return e
    }, [sharedType])

    // when I have all the nice things I connect to the server with the Y-websocket provider
    useEffect(() => {
        const provider = new WebsocketProvider('ws://localhost:1234', 'yjs-ws-demo', ydoc);
        provider.on('status', ({ status }: { status: string }) => 
            setIsOnline(status == 'connected')
        )

        YjsEditor.connect(editor);

        return () => {
            YjsEditor.disconnect(editor);
            provider.destroy();
        };
    }, [editor, ydoc]);

    // and here is a bit of UI
    return <div>

        <div style={{ color: isOnline ? 'green' : 'red' }}>
            {isOnline ? 'Connected' : 'Disconnected'}
        </div>

        <Slate
            editor={editor}
            initialValue={[{ children: [{ text: '' }] }]}>
            <Editable
                renderElement={({ attributes, children, element }) => <p {...attributes}>{children}</p>}
                renderLeaf={({ attributes, children, leaf }) => <span {...attributes}>{children}</span>}
                placeholder="Enter some text..."
            />
        </Slate>
    </div>
}

export default App
Enter fullscreen mode Exit fullscreen mode

WebsocketProvider connects the local Yjs doc to the websocket server with the room "yjs-ws-demo":

new WebsocketProvider('ws://localhost:1234', 'yjs-ws-demo', ydoc)
Enter fullscreen mode Exit fullscreen mode

So from that moment on, the Yjs document is synchronized with the WS server.
Finally, with withYjs and withYHistory, I connect the editor to Yjs.

CLIENT TEXTAREA

And if I wanted a simple textarea?
I found the solution thanks to raine. Great professional.

Install

npm install fast-diff
Enter fullscreen mode Exit fullscreen mode

Replace

App.ts

import diff from 'fast-diff';
import { useEffect, useMemo, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';

function App() {

    // get the Yjs document
    const ydoc = useMemo(() => new Y.Doc(), [])
    // get the shared data type
    const sharedType = useMemo(() => ydoc.get('content', Y.Text), [ydoc])
    // connect the shared data to a "reactive" string
    const [text, setText] = useText(sharedType)

    // on component creation, connect via websocket
    useEffect(() => {
        const provider = new WebsocketProvider('ws://localhost:1234', 'yjs-ws-demo', ydoc);
        return () => {
            provider.destroy();
        };
    }, [sharedType]);

    // and here is a bit of UI
    return <div>
        <textarea style={{ width: '100%' }} rows={5}
            value={text}
            onChange={(e) => setText(e.target.value)}
        />
    </div>
}

export default App
Enter fullscreen mode Exit fullscreen mode

Next, create the custom hook useText:

/** A hook to read and set a YText value. */
function useText(ytext: Y.Text) : [string, (text: string) => void] {
    // the "reactive" string
    const [text, setText] = useState(ytext.toString())
    // every time the shared data changes, update the "reactive" string
    ytext.observe(() => setText(ytext.toString()))
    // when I change the "reactive" string, update the Yjs shared data (only the differences)
    const setYText = (textNew: string) => {
        const delta = diffToDelta(diff(text, textNew));
        ytext.applyDelta(delta);
    }
    return [text, setYText]
}
/** Convert a fast-diff result to a YJS delta. */
function diffToDelta(diffResult: [number, string][]) {
    return diffResult.map(([op, value]) => ({
        [diff.INSERT]: { insert: value },
        [diff.EQUAL]: { retain: value.length },
        [diff.DELETE]: { delete: value.length },
    }[op])).filter(Boolean);
}
Enter fullscreen mode Exit fullscreen mode

We could send all the text with each change, but that would be inefficient.
So we use fast-diff to calculate the differences between the current text and the previous one.
And then we transform the differences into a delta object that Yjs understands.
The sending to the server is done by y-websocket without us having to worry about it.

CONCLUSIONS

My impression is that Yjs is fantastic!
But for my personal project, I will implement a server-coordinated system for document management.
In this way, I can manage permissions and data persistence more easily.

Also, I have the feeling that the implementation for SLATE is a bit neglected.
I plan to publish some other reminders of my project!

Bye!

ivano

Top comments (0)