Adding collaborative editing to the Ace web code editor with web sockets

Using Ace‘s excellent API, it is relatively easy to enhance it to allow for live collaborative editing.

The gist of what we’re doing here is to use Ace’s API for extracting and applying delta when changes occur in the editor. Then we simply transmit them over a websocket that all clients are connected to. This example is functional but in no way comprehensive to what a full code editing collaboration could be. It’s meant to be simple thus understandable. It’s a great starting point for whatever other pieces of functionality you want to send across web sockets.

Loading Ace in a webpage with some custom Javascript

This is what your web page looks like, load Ace as instructed and add Javascript to handle interaction with the websocket server.

<!DOCTYPE html>
<html lang="en">
    <head>

        <title>Collaborative Ace Coding!</title>

        <style type="text/css" media="screen">
            #editor {
                position: absolute;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;
            }
        </style>

        <script src="https://<?=$_SERVER['HTTP_HOST']?>:1337/socket.io/socket.io.js"></script>
        <script src="ace-builds/src/ace.js" type="text/javascript" charset="utf-8"></script>
        <script src="ace-builds/src/ext-language_tools.js"></script>
        <script>
            var session_id = null ;
            var editor = null ;
            var collaborator = null ;
            var buffer_dumped = false ;
            var last_applied_change = null ;
            var just_cleared_buffer = null ;

            function Collaborator( session_id ) {
                this.collaboration_socket = io.connect( "https://code.thayer.dartmouth.edu:1337", {query:'session_id=' + session_id} ) ;

                this.collaboration_socket.on( "change", function(delta) {
                    delta = JSON.parse( delta ) ;
                    last_applied_change = delta ;
                    editor.getSession().getDocument().applyDeltas( [delta] ) ;
                }.bind() ) ;

                this.collaboration_socket.on( "clear_buffer", function() {
                    just_cleared_buffer = true ;
                    console.log( "setting editor empty" ) ;
                    editor.setValue( "" ) ;
                }.bind() ) ;
            }

            Collaborator.prototype.change = function( delta ) {
                this.collaboration_socket.emit( "change", delta ) ;
            }

            Collaborator.prototype.clear_buffer = function() {
                this.collaboration_socket.emit( "clear_buffer" ) ;
            }

            Collaborator.prototype.dump_buffer = function() {
                this.collaboration_socket.emit( "dump_buffer" ) ;
            }

            function body_loaded() {

                session_id = "meow" ;

                editor = ace.edit( "editor" ) ;
                collaborator = new Collaborator( session_id ) ;
                

                // registering change callback
                editor.on( "change", function( e ) {
                    // TODO, we could make things more efficient and not likely to conflict by keeping track of change IDs
                    if( last_applied_change!=e && !just_cleared_buffer ) {
                        collaborator.change( JSON.stringify(e) ) ;
                    }
                    just_cleared_buffer = false ;
                }, false );

                editor.setTheme( "ace/theme/monokai") ;
                editor.$blockScrolling = Infinity ;

                collaborator.dump_buffer() ;

                document.getElementsByTagName('textarea')[0].focus() ;
                last_applied_change = null ;
                just_cleared_buffer = false ;
            }
        </script>
    </head>

    <body onLoad="body_loaded()">
        <div id="editor"></div>
    </body>
</html>

Parallel to this, run the following Node.js server script

Following is the Node.js websocket server which must be instantiated on the same server serving the web page above. It needs to be up for the page above to work.

  1. Make sure to have port 1337 open in the same capacity as ports 80 & 443, this is what this listens on.
  2. Make sure to update the paths to SSL certs, we use SSL on the websocket server. We do SSL here so browsers can run the websocket Javascript regardless of whether their original context it SSL or not.
  3. You need to have Socket.IO installed
// config variables
verbose = false ;
session_directory = "/tmp" ; // it has to exist

/* https specific */
var https = require('https'),
    fs =    require('fs');

var options = {
    key:    fs.readFileSync('/path/to/your/ssl.key'),
    cert:   fs.readFileSync('/path/to/your/ssl.crt'),
    ca:     fs.readFileSync('/path/to/your/CA.crt')
};
var app = https.createServer(options);
io = require('socket.io').listen(app);     //socket.io server listens to https connections
app.listen(1337, "0.0.0.0");

// will use the following for file IO
var fs = require( "fs" ) ;

//io = require('socket.io').listen(2015) ;
if( verbose ) { console.log( "> server launched" ) ; }

collaborations = [] ;
socket_id_to_session_id = [] ;

io.sockets.on('connection', function(socket) {
    var session_id = socket.manager.handshaken[socket.id].query['session_id'] ;

    socket_id_to_session_id[socket.id] = session_id ;

    if( verbose ) { console.log( session_id + " connected on socket " + socket.id ) ; }


    if( !(session_id in collaborations) ) {
        // not in memory but is is on the filesystem?
        if( file_exists(session_directory + "/" + session_id) ) {
            if( verbose ) { console.log( "   session terminated previously, pulling back from filesystem" ) ; }
            var data = read_file( session_directory + "/" + session_id ) ;
            if( data!==false ) {
                collaborations[session_id] = {'cached_instructions':JSON.parse(data), 'participants':[]} ;
            } else {
                // something went wrong, we start from scratch
                collaborations[session_id] = {'cached_instructions':[], 'participants':[]} ;
            }
        } else {
            if( verbose ) { console.log( "   creating new session" ) ; }
            collaborations[session_id] = {'cached_instructions':[], 'participants':[]} ;
        }
    }
    collaborations[session_id]['participants'].push( socket.id ) ;


    socket.on('change', function( delta ) {
        if( verbose ) { console.log( "change " + socket_id_to_session_id[socket.id] + " " + delta ) ; }
        if( socket_id_to_session_id[socket.id] in collaborations ) {
            collaborations[socket_id_to_session_id[socket.id]]['cached_instructions'].push( ["change", delta, Date.now()] ) ;
            for( var i=0 ; i<collaborations[session_id]['participants'].length ; i++ ) {
                if( socket.id!=collaborations[session_id]['participants'][i] ) {
                    io.sockets.socket(collaborations[session_id]['participants'][i]).emit( "change", delta ) ;
                }
            }
        } else {
            if( verbose ) { console.log( "WARNING: could not tie socket_id to any collaboration" ) ; }
        }
    });


    socket.on('change_selection', function( selections ) {
        if( verbose ) { console.log( "change_selection " + socket_id_to_session_id[socket.id] + " " + selections ) ; }
        if( socket_id_to_session_id[socket.id] in collaborations ) {
            for( var i=0 ; i<collaborations[session_id]['participants'].length ; i++ ) {
                if( socket.id!=collaborations[session_id]['participants'][i] ) {
                    io.sockets.socket(collaborations[session_id]['participants'][i]).emit( "change_selection", selections ) ;
                }
            }
        } else {
            if( verbose ) { console.log( "WARNING: could not tie socket_id to any collaboration" ) ; }
        }
    });


    socket.on('clear_buffer', function() {
        if( verbose ) { console.log( "clear_buffer " + socket_id_to_session_id[socket.id] ) ; }
        if( socket_id_to_session_id[socket.id] in collaborations ) {
            collaborations[socket_id_to_session_id[socket.id]]['cached_instructions'] = [] ;
            for( var i=0 ; i<collaborations[session_id]['participants'].length ; i++ ) {
                if( socket.id!=collaborations[session_id]['participants'][i] ) {
                    io.sockets.socket(collaborations[session_id]['participants'][i]).emit( "clear_buffer" ) ;
                }
            }
        } else {
            if( verbose ) { console.log( "WARNING: could not tie socket_id to any collaboration" ) ; }
        }
    });


    socket.on('dump_buffer', function() {
        if( verbose ) { console.log( "dump_buffer " + socket_id_to_session_id[socket.id] ) ; }
        if( socket_id_to_session_id[socket.id] in collaborations ) {
            for( var i=0 ; i<collaborations[socket_id_to_session_id[socket.id]]['cached_instructions'].length ; i++ ) {
                socket.emit( collaborations[socket_id_to_session_id[socket.id]]['cached_instructions'][i][0], collaborations[socket_id_to_session_id[socket.id]]['cached_instructions'][i][1] ) ;
            }
        } else {
            if( verbose ) { console.log( "WARNING: could not tie socket_id to any collaboration" ) ; }
        }
        socket.emit( "buffer_dumped" ) ;
    });


    socket.on('disconnect', function () {
        console.log( socket_id_to_session_id[socket.id] + " disconnected" ) ;
        var found_and_removed = false ;
        if( socket_id_to_session_id[socket.id] in collaborations ) {
            //var index = collaborations[socket_id_to_session_id[socket.id]].participants.indexOf( socket.id ) ;
            var index = collaborations[socket_id_to_session_id[socket.id]]['participants'].indexOf( socket.id ) ;
            if( index>-1 ) {
                //collaborations[socket_id_to_session_id[socket.id]].participants.splice( index, 1 ) ;
                collaborations[socket_id_to_session_id[socket.id]]['participants'].splice( index, 1 ) ;
                found_and_removed = true ;
                //if( collaborations[socket_id_to_session_id[socket.id]].participants.length==0 ) {
                if( collaborations[socket_id_to_session_id[socket.id]]['participants'].length==0 ) {
                    if( verbose ) { console.log( "last participant in collaboration, committing to disk & removing from memory" ) ; }
                    // no one is left in this session, we commit it to disk & remove it from memory
                    write_file( session_directory + "/" + socket_id_to_session_id[socket.id], JSON.stringify(collaborations[socket_id_to_session_id[socket.id]]['cached_instructions']) ) ;
                    delete collaborations[socket_id_to_session_id[socket.id]] ;
                }
            }
        }
        if( !found_and_removed ) {
            console.log( "WARNING: could not tie socket_id to any collaboration" ) ;
        }
        console.log( collaborations ) ;
    });

});


function write_file( path, data ) {
    try {
        fs.writeFileSync( path, data ) ;
        return true ;
    } catch( e ) {
        return false ;
    }
}


function read_file( path ) {
    try {
        var data = fs.readFileSync( path ) ;
        return data ;
    } catch( e ) {
        return false
    }
}


function file_exists( path ) {
    try {
        stats = fs.lstatSync( path ) ;
        if (stats.isFile()) {
            return true ;
        }
    } catch( e ) {
        return false ;
    }
    // we should not reach that point
    return false ;
}

One Reply to “Adding collaborative editing to the Ace web code editor with web sockets”

  1. Useful example. I guess doing this comprehensively is way more work than it seems, so hopefully someone already made it. But it’s useful to know how it works by means of a minimal version.

Leave a Reply

Your email address will not be published. Required fields are marked *