Chapter Three: Creating a Google Docs clone with WebSockets using Javalin
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
Create a maven project with dependencies
We will be using Javalin for our web-server and WebSockets, and slf4j for logging:
1 <dependencies>
2 <dependency>
3 <groupId>io.javalin</groupId>
4 <artifactId>javalin</artifactId>
5 <version>1.7.0</version>
6 </dependency>
7 <dependency>
8 <groupId>org.slf4j</groupId>
9 <artifactId>slf4j-simple</artifactId>
10 <version>1.7.25</version>
11 </dependency>
12 </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:
1 import io.javalin.Javalin;
2 import io.javalin.embeddedserver.jetty.websocket.WsSessio\
3 n;
4 import java.util.Map;
5 import java.util.concurrent.ConcurrentHashMap;
6
7 public class Main {
8
9 private static Map<String, Collab> collabs = new Conc\
10 urrentHashMap<>();
11
12 public static void main(String[] args) {
13
14 Javalin.create()
15 .port(7070)
16 .enableStaticFiles("/public")
17 .ws("/docs/:doc-id", ws -> {
18 ws.onConnect(session -> {
19 if (getCollab(session) == null) {
20 createCollab(session);
21 }
22 getCollab(session).sessions.add(sessi\
23 on);
24 session.send(getCollab(session).doc);
25 });
26 ws.onMessage((session, message) -> {
27 getCollab(session).doc = message;
28 getCollab(session).sessions.stream().\
29 filter(WsSession::isOpen).forEach(s -> {
30 s.send(getCollab(session).doc);
31 });
32 });
33 ws.onClose((session, status, message) -> {
34 getCollab(session).sessions.remove(se\
35 ssion);
36 });
37 })
38 .start();
39
40 }
41
42 private static Collab getCollab(WsSession session) {
43 return collabs.get(session.param("doc-id"));
44 }
45
46 private static void createCollab(WsSession session) {
47 collabs.put(session.param("doc-id"), new Collab()\
48 );
49 }
50
51 }
We also need to create a data object for holding our document and the people working on it:
1 import io.javalin.embeddedserver.jetty.websocket.WsSessio\
2 n;
3 import java.util.Set;
4 import java.util.concurrent.ConcurrentHashMap;
5
6 public class Collab {
7 public String doc;
8 public Set<WsSession> sessions;
9
10 public Collab() {
11 this.doc = "";
12 this.sessions = ConcurrentHashMap.newKeySet();
13 }
14 }
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:
1 <body>
2 <h1>Open the URL in another tab to start collaboratin\
3 g</h1>
4 <textarea placeholder="Type something ..."></textarea>
5 </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).
1 window.onload = setupWebSocket;
2 window.onhashchange = setupWebSocket;
3
4 if (!window.location.hash) { // document-id not present i\
5 n url
6 const newDocumentId = Date.now().toString(36); // thi\
7 s should be more random
8 window.history.pushState(null, null, "#" + newDocumen\
9 tId);
10 }
11
12 function setupWebSocket() {
13 const textArea = document.querySelector("textarea");
14 const ws = new WebSocket(`ws://localhost:7070/docs/${\
15 window.location.hash.substr(1)}`);
16 textArea.onkeyup = () => ws.send(textArea.value);
17 ws.onmessage = msg => {
18 // place the caret in the correct position
19 const offset = msg.data.length - textArea.value.l\
20 ength;
21 const selection = {start: textArea.selectionStart\
22 , end: textArea.selectionEnd};
23 const startsSame = msg.data.startsWith(textArea.v\
24 alue.substring(0, selection.end));
25 const endsSame = msg.data.endsWith(textArea.value\
26 .substring(selection.start));
27 textArea.value = msg.data;
28 if (startsSame && !endsSame) {
29 textArea.setSelectionRange(selection.start, s\
30 election.end);
31 } else if (!startsSame && endsSame) {
32 textArea.setSelectionRange(selection.start + \
33 offset, selection.end + offset);
34 } else { // this is what google docs does...
35 textArea.setSelectionRange(selection.start, s\
36 election.end + offset);
37 }
38 };
39 ws.onclose = setupWebSocket; // should reconnect if c\
40 onnection is closed
41 }
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!