Experimenting with Ember.js & ShareJS

Wow! This takes me back! Please check the date this post was authored, as it may no longer be relevant in a modern context.

ShareJS is a nice looking chunk of code. The library uses a pattern called Operational Transforms to share changes to a document (or data structure) over a web connection. Combined with Ember’s data bindings, it isn’t difficult to brainstorm up some fun app ideas.

For a first experiment, all I want to achieve is creating a text field that has persists it’s changes to a server and updates when another user is editing. A basic, classic use of ShareJS.

This code is not ready for production, and won’t handle several niceties (such as cursor position) that you will find in other ShareJS examples.

The Server

The introductory documentation for ShareJS has an example node.js server, so I’ll use that:

// app.js
var connect = require('connect'),
    sharejs = require('share').server;

var server = connect(
      connect.logger(),
      connect.static(__dirname + '/public') // Serve static assets from public/
    );

var options = {db: {type: 'redis'}}; // See docs for options. I've enabled redis here.

// Attach the sharejs REST and Socket.io interfaces to the server
// ^^ Note, we are using browserchannel for this example, and I
// believe it is now the recommended transport for ShareJS.
sharejs.attach(server, options);

server.listen(8000);
console.log('Server running at http://127.0.0.1:8000/');

To run this server I will need the node.js libraries connect, share, and redis. This requires a package.json file:

{
  "name": "emberpad",
  "version": "0.0.1",
  "dependencies": {
    "connect": "*",
    "share": "*",
    "redis": "*"
  }
}

Now I can npm install to fetch the libraries, and node app.js to start a server.

The Browser Boilerplate

Fetching and building the required libraries for ShareJS and Ember.js was not trivial, unfortunately. I’m certain there are better methods, but here were my steps:

  1. git clone https://github.com/wycats/handlebars.js.git and checkout the v1.0.11 tag. Build it and copy the dist/handlebars.js file to public/js/.
  2. Build node-browserchannel, for which the instructions were slightly out of date. Copy dist/bcsocket-uncompressed.js to public/js/.
  3. Build ShareJS. Copy webclient/share.js to public/js/.

The other files could be found on CDNs. I ended up with a public/index.html looking like this:

<html>
  <head>
    <title>Emberpad</title>
  </head>
  <body>
    <script type="text/x-handlebars">
      {{textarea valueBinding="body"}}
    </script>
  </body>

  <script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.2/jquery.min.js"></script>
  <script src="/js/bcsocket-uncompressed.js"></script>
  <script src="/js/share.js"></script>
  <script src="/js/handlebars.js"></script>
  <script src="http://builds.emberjs.com.s3.amazonaws.com/ember-1.0.0-rc.5.js"></script>
  <script type="text/javascript">
    var App = Em.Application.create();
  </script>
</html>

The Integration

ShareJS calls transformable objects “documents”. A client opens a document, then receives updates and pushes it’s own. All of these changes are communicated with offsets. In many ways, the API feels similar to array observers in Ember.js. Each change comes with a position and text to add or remove at that position.

For the sake of brevity I will focus on the Ember code. First, I created a connection to the server that exists on ApplicationView.

App.ApplicationView = Em.View.extend({
  didInsertElement: function(){
    this._connection = new sharejs.Connection("http://localhost:8000/channel");
    this._connection.open('appBody', 'text', $.proxy(function(error, doc){
      Em.run(this, function(){
        // Ok! Connected!
      });
    }, this));
  },
  willClearRender: function(){
    this._connection.disconnect();
  }
});

The document appBody is now shared between any two browsers sharing this view, though there isn’t any interactivity.

To receive events from ShareJS, I need to handle the remoteop callback on the document. This callback only fires when an event is sent, not when the current browsers triggers it. The callback is passed the operation itself. The operation knows about what text needs to be inserted or removed and at what position. For this example, I’m going to ignore that data and just use the snapshot property on the ShareJS document. That property provides me with the server’s current version of the document.

App.ApplicationView = Em.View.extend({
  isVisible: Em.computed.bool('doc'), // Only show this view when the document is open
  didInsertElement: function(){
    this._connection = new sharejs.Connection("http://localhost:8000/channel");
    this._connection.open('appBody', 'text', $.proxy(function(error, doc){
      Em.run(this, function(){
        // Ok! Connected!
        this.set('controller.body', doc.snapshot); // Set the initial text from the server
        doc.on('remoteop', $.proxy(function(op){
          Em.run(this, this.onDocRemoteop, op, doc.snapshot);
        }, this));
        this.set('doc', doc);
      });
    }, this));
  },
  willClearRender: function(){
    this.set('doc', null);
    this._connection.disconnect();
  },
  onDocRemoteop: function(op, snapshot){
    this.set('controller.body', snapshot);
  }
});

With this code, the server could stream changes down to the client.

To send data from the textarea via Ember, I needed to lift some code from webclient/textarea.js in ShareJS’s repo. This function calls insert and del on a ShareJS document object, telling it to reflect the changes between and old text value and a new text value.

var applyChange = function(doc, oldval, newval) {
  var commonEnd, commonStart;
  if (oldval === newval) {
    return;
  }
  commonStart = 0;
  while (oldval.charAt(commonStart) === newval.charAt(commonStart)) {
    commonStart++;
  }
  commonEnd = 0;
  while (oldval.charAt(oldval.length - 1 - commonEnd) === newval.charAt(newval.length - 1 - commonEnd) && commonEnd + commonStart < oldval.length && commonEnd + commonStart < newval.length) {
    commonEnd++;
  }
  if (oldval.length !== commonStart + commonEnd) {
    doc.del(commonStart, oldval.length - commonStart - commonEnd);
  }
  if (newval.length !== commonStart + commonEnd) {
    return doc.insert(commonStart, newval.slice(commonStart, newval.length - commonEnd));
  }
};

With this function, I could easily send my changes to the textarea on keyup.

App.ApplicationView = Em.View.extend({
  didInsertElement: function(){
    this._connection = new sharejs.Connection("http://localhost:8000/channel");
    this._connection.open('appBody', 'text', $.proxy(function(error, doc){
      Em.run(this, function(){
        // Ok! Connected!
        this.set('controller.body', doc.snapshot); // Set the initial text from the server
        doc.on('remoteop', $.proxy(function(op){
          Em.run(this, this.onDocRemoteop, op, doc.snapshot);
        }, this));
        this.set('doc', doc);
      });
    }, this));
  },
  willClearRender: function(){
    this.set('doc', null);
    this._connection.disconnect();
  },
  keyUp: function(){
    var value = this.get('controller.body');
    var doc   = this.get('doc');
    if (doc && doc.snapshot != value) {
      applyChange(doc, doc.snapshot, value);
    }
  },
  onDocRemoteop: function(op, snapshot){
    this.set('controller.body', snapshot);
  }
});

And with that, I was able to propagate my changes between two browsers. Pretty simple!

Obviously this is a very minor exploration, and only scratches the surface of what ShareJS and Ember could do (ShareJS has a JSON API powered by OT. Oh yeah). Have you built something interesting with ShareJS? Let me know about your experiences with it.