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:

<dependencies>
    <dependency>
        <groupId>io.javalin</groupId>
        <artifactId>javalin-bundle</artifactId>
        <version>6.4.0</version>
    </dependency>
</dependencies>

The Javalin application

The Javalin 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 around 50 lines:

  • Java
  • Kotlin
import io.javalin.Javalin;
import io.javalin.http.staticfiles.Location;
import io.javalin.websocket.WsContext;

import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class JavalinRealtimeCollaborationExampleApp {

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

    public static void main(String[] args) {

        Javalin.create(config -> {
            config.staticFiles.add("/public", Location.CLASSPATH);
            config.router.mount(router -> {
                router.ws("/docs/{doc-id}", ws -> {
                    ws.onConnect(ctx -> {
                        if (getCollab(ctx) == null) {
                            createCollab(ctx);
                        }
                        getCollab(ctx).clients.add(ctx);
                        ctx.send(getCollab(ctx).doc);
                    });
                    ws.onMessage(ctx -> {
                        getCollab(ctx).doc = ctx.message();
                        getCollab(ctx).clients.stream().filter(c -> c.session.isOpen()).forEach(s -> {
                            s.send(getCollab(ctx).doc);
                        });
                    });
                    ws.onClose(ctx -> {
                        getCollab(ctx).clients.remove(ctx);
                    });
                });
            });
        }).start(7070);

    }

    private static Collab getCollab(WsContext ctx) {
        return collabs.get(ctx.pathParam("doc-id"));
    }

    private static void createCollab(WsContext ctx) {
        collabs.put(ctx.pathParam("doc-id"), new Collab());
    }

    static public class Collab {
        public String doc;
        public Set<WsContext> clients;

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

}
import io.javalin.Javalin
import io.javalin.http.staticfiles.Location
import io.javalin.websocket.WsContext
import java.util.concurrent.ConcurrentHashMap

data class Collaboration(var doc: String = "", val clients: MutableSet<WsContext> = ConcurrentHashMap.newKeySet())

fun main() {

    val collaborations = ConcurrentHashMap<String, Collaboration>()

    Javalin.create {
        it.staticFiles.add("/public", Location.CLASSPATH)
        it.router.mount {
            it.ws("/docs/{doc-id}") { ws ->
                ws.onConnect { ctx ->
                    if (collaborations[ctx.docId] == null) {
                        collaborations[ctx.docId] = Collaboration()
                    }
                    collaborations[ctx.docId]!!.clients.add(ctx)
                    ctx.send(collaborations[ctx.docId]!!.doc)
                }
                ws.onMessage { ctx ->
                    collaborations[ctx.docId]!!.doc = ctx.message()
                    collaborations[ctx.docId]!!.clients.filter { it.session.isOpen }.forEach {
                        it.send(collaborations[ctx.docId]!!.doc)
                    }
                }
                ws.onClose { ctx ->
                    collaborations[ctx.docId]!!.clients.remove(ctx)
                }
            }
        }
    }.start(7070)

}

val WsContext.docId: String get() = this.pathParam("doc-id")

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!