What You Will Learn

In this tutorial we will create a very simple realtime collaboration tool (like google docs).
We will be using WebSockets for this, as WebSockets provides us with two-way communication over a one connection, meaning we won’t have to make additional HTTP requests to send and receive messages. A WebSocket connection stays open, greatly reducing latency (and complexity).

Dependencies

First we create a Maven project with our dependencies (→ Tutorial).
We will be using Javalin for our web-server and WebSockets, and slf4j for logging:

<dependencies>
    <dependency>
        <groupId>io.javalin</groupId>
        <artifactId>javalin</artifactId>
        <version>2.4.0</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-simple</artifactId>
        <version>1.7.25</version>
    </dependency>
</dependencies>

The Java application

The Java application is pretty straightforward. We need:

  • a data class (Collab) containing the document and the collaborators
  • a map to keep track of document-ids and Collabs
  • websocket handlers for connect/message/close

We can get the entire server done in about 40 lines:

import io.javalin.Javalin;
import io.javalin.websocket.WsSession;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class Main {

    private static Map<String, Collab> collabs = new ConcurrentHashMap<>();

    public static void main(String[] args) {

        Javalin.create()
            .enableStaticFiles("/public")
            .ws("/docs/:doc-id", ws -> {
                ws.onConnect(session -> {
                    if (getCollab(session) == null) {
                        createCollab(session);
                    }
                    getCollab(session).sessions.add(session);
                    session.send(getCollab(session).doc);
                });
                ws.onMessage((session, message) -> {
                    getCollab(session).doc = message;
                    getCollab(session).sessions.stream().filter(WsSession::isOpen).forEach(s -> {
                        s.send(getCollab(session).doc);
                    });
                });
                ws.onClose((session, status, message) -> {
                    getCollab(session).sessions.remove(session);
                });
            })
            .start(7070);

    }

    private static Collab getCollab(WsSession session) {
        return collabs.get(session.param("doc-id"));
    }

    private static void createCollab(WsSession session) {
        collabs.put(session.param("doc-id"), new Collab());
    }

}

We also need to create a data object for holding our document and the people working on it:

import io.javalin.websocket.WsSession;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class Collab {
    public String doc;
    public Set<WsSession> sessions;

    public Collab() {
        this.doc = "";
        this.sessions = ConcurrentHashMap.newKeySet();
    }
}

Building a JavaScript Client

In order to demonstrate that our application works, we can build a JavaScript client. We’ll keep the HTML very simple, we just need a heading and a text area:

<body>
    <h1>Open the URL in another tab to start collaborating</h1>
    <textarea placeholder="Type something ..."></textarea>
</body>

The JavaScript part could also be very simple, but we want some slightly advanced features:

  • When you open the page, the app should either connect to an existing document or generate a new document with a random id
  • When a WebSocket connection is closed, it should immediately be reestablished
  • When new text is received, the user caret (“text-cursor”) should remain in the same location (easily the most complicated part of the tutorial).
window.onload = setupWebSocket;
window.onhashchange = setupWebSocket;

if (!window.location.hash) { // document-id not present in url
    const newDocumentId = Date.now().toString(36); // this should be more random
    window.history.pushState(null, null, "#" + newDocumentId);
}

function setupWebSocket() {
    const textArea = document.querySelector("textarea");
    const ws = new WebSocket(`ws://localhost:7070/docs/${window.location.hash.substr(1)}`);
    textArea.onkeyup = () => ws.send(textArea.value);
    ws.onmessage = msg => { // place the caret in the correct position
        const offset = msg.data.length - textArea.value.length;
        const selection = {start: textArea.selectionStart, end: textArea.selectionEnd};
        const startsSame = msg.data.startsWith(textArea.value.substring(0, selection.end));
        const endsSame = msg.data.endsWith(textArea.value.substring(selection.start));
        textArea.value = msg.data;
        if (startsSame && !endsSame) {
            textArea.setSelectionRange(selection.start, selection.end);
        } else if (!startsSame && endsSame) {
            textArea.setSelectionRange(selection.start + offset, selection.end + offset);
        } else { // this is what google docs does...
            textArea.setSelectionRange(selection.start, selection.end + offset);
        }
    };
    ws.onclose = setupWebSocket; // should reconnect if connection is closed
}

And that’s it! Now try opening localhost:7070 in a couple of different browser windows (that you can see simultaneously) and collaborate with yourself.

Conclusion

We have a working realtime collaboration app written in less than 100 lines of Java and JavaScript. It’s very basic though, some things to add could include:

  • Show who is currently editing the document
  • Persist the data in a database at periodic intervals
  • Replace the textarea with a rich text editor, such as quill
  • Replace the textarea with a code editor such as ace for collaborative programming
  • Improving the collaborative aspects with operational transformation

The use cases are not limited to text and documents though, you should use WebSockets for any project which requires a lot of interactions with low latency. Have fun!