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.