Jampot.dev

A live editor in half a day

March 28, 2021

I’ve been interested in building a live, multi-user editor, similar to google doc or something, and thought it’d be interesting to see how much I could do in half a day.

So let’s get on with it.

Choosing the protocol to send the messages was a bit of a rabbit hole, and I had three options that all seemed sensible:

  • gRPC
  • XMPP
  • websockets

I went with websockets, which appeared to be the least scalable, but also the simplest. I can always change down the line, so let’s keep moving.

Building out the websocket server was mostly verbatim of the example given in the websockets package, but I’ll give a brief walkthrough - why not.

First I defined the state of the server, clients and the initial state.

type Client = (Int, WS.Connection)
type ServerState = [Client]

newServerState :: ServerState
newServerState = []

Then creating the main method to run the application, with some state in an MVar, to keep track of the mutable server state (in this case the clients along with their ids and connections).

main :: IO ()
main = do
    print "Server started."
    state <- newMVar newServerState
    WS.runServer "127.0.0.1" 5000 $ application state

The application then accepts the request and creates a ping thread to ensure the websocket connection stays alive.

application :: MVar ServerState -> WS.ServerApp
application state pending = do
    conn <- WS.acceptRequest pending
    WS.withPingThread conn 30 (return ()) $ do

We then add the client to the server state, which we can do using modifyMVar. Since addClient generates the users id, we also return that so we can use it later.

        clientId <- modifyMVar state $ \s -> do
            s' <- addClient conn s
            return (s', fst $ head s')

Finally, we create the loop that takes websocket data sent to us, and broadcasts it to everyone except the sender.

        print $ "Connected client " <> show clientId
        flip finally (disconnect clientId) $ forever $ do
            msg <- WS.receiveData conn
            readMVar state >>= broadcastToOthers clientId msg

That’s pretty much it for the backend code, as we should be able to send live edit updates with this simple mechanism.

Moving onto the frontend, I quickly found a few options for editors to use:

  • Ace
  • CodeMirror
  • Monaco

Monaco seemed interesting, as I’d never seen a live editor that used it (perhaps because of licencing, I haven’t checked).

Lucky for me I found a webpack plugin for it, so with a webpack svelte template I added monaco with the plugin and it was as easy as importing it - nice.

With a simple monaco window, now I needed edits to be send and retrieved.

Lucky for us, this was easier than I thought it’d be, as there is an event onDidChangeModalContent that gives you the edit information in this useful form.

{
  "changes": [
    {
      "range": {
        "startLineNumber": 1,
        "startColumn": 1,
        "endLineNumber": 1,
        "endColumn": 1
      },
      "rangeLength": 0,
      "text": "a",
      "rangeOffset": 0,
      "forceMoveMarkers": false
    }
  ]
}

So on change of the editors content, I send the event.

monacoEditor.onDidChangeModelContent(e => {
  websocketConnection.send(JSON.stringify(e, null, '\t'));
});

And when a message is received from the websocket, I execute the retrieved edits.

websocketConnection.onmessage = message => {
  const data = getJsonFromStr(message.data);
  if (data?.changes) {
    ed.executeEdits("", data.changes);
  }
});

That’s really all there was to it. Nothing fancy, no rooms or logins or heavy load. But it works, and I thought it was neat to be able to hack something that realtime allows people to collaborate in a few hours.

This was almost made better by hosting the frontend on netlify (which is a handy click or two), and the backend on azure with a container instance. Unfortunately I ran into an issue where the container instance wouldn’t expose secure websockets properly, and netlify enforces HTTPS which made chrome refuse the ws:// connection, sad times.

Thanks for reading, perhaps next time I’ll extend it with rooms, users and more.