Experimenting with Ember.js & ShareJS, part II

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

The content of this post was presented to Ember.js NYC (video). Come visit us some time!

In part one of this exploration, I demonstrated a simple integration of Ember.js and ShareJS. That demonstration ended with code that was tightly coupled to the view, and only synchronized the one property of controller.body.

ShareJS provides a JSON API that can perform operational transforms on data structures, instead of just on text documents. Given a structure:

{
  name: 'Nick',
  dogs: ['muffy', 'fido', 'terry'],
  catCount: 0, // Nick does not name his cats
  bio: { age: 37, car: 'Dodge Dart' }
}

You can use the JSON API to read the name: doc.at('name').get(). You can set the name: doc.at('name').set(newName), but you can also send transforms to the name:

var subdoc = doc.at('name');
doc.del(0, 1, function(){ doc.insert(0, 'R'); });
doc.get(); //=> 'Rick'

There are transforms for arrays, numbers, and strings. Document objects can be nested, so a transform can be sent for bio the same way transforms are sent for the top level properties.

For this experiment, I’ll wire up an Ember object that proxies to a ShareJS document. The internals will use set to share changes, so the OT messages will not be as ideal as if we use del, insert, and other OT helpers. Despite this, our object will synchronize all it’s properties across all instances in realtime.

Building an Ember Proxy for ShareJS

First, let’s get a connection to ShareJS.

App.SharedObject = Ember.Object.extend();

App.SharedObject.reopenClass({

  serverUrl: "http://localhost:5000/channel",

  open: function(doc){
    return new Em.RSVP.Promise(function(resolve, reject){
      var connection = new sharejs.Connection(App.SharedObject.serverUrl);
      connection.open(doc, 'json', function(error, doc){
        Em.run(function(){ // Because this is async, we need a new runloop.
          resolve(new App.SharedObject(doc)); // Resolve the promise with a new SharedObject.
        });
      });
    });
  }

});

App.SharedObject.open('myDoc') returns a promise, which wil let us easily load up a document in the Ember router. For instance:

App.ApplicationRoute = Em.Route.extend({
  model: function(){ return App.SharedObject.open('applicationModel') }
});

The Ember router will put our app in a loading state until the connection to our server is opened and the App.SharedObject has been created. This is a great feature of the Ember router, and the same way that Ember-Data queries work.

The open function expects that App.SharedObject can be instantiated with a document. Let’s add that functionality.

App.SharedObject = Ember.Object.extend({
  init: function(doc){
    this._doc = doc;
    this.startObservingDoc();
  },
  startObservingDoc: function(){
    var sharedObject = this;
    this._doc.on('remoteop', function(ops){
      Em.run(function(){
        ops.mapProperty('p').forEach(function(prop){
          sharedObject.propertyDidChange(prop);
        }
      });
    });
  }
});

On each remoteop (which is any event being sent from the server), propertyDidChange is called for each key it modifies. ops itself is a data structure describing the event, which could contain multiple operations and transforms. It could look like this:

[
  { // One operation
    od: "ick",
    oi: "Rick",
    p: [
      'name' // Which subdoc (or property) has changed.
    ]
  }
]

propertyDidChange is a function of the Ember internals. You use propertyDidChange to inform an object that a given property has been modified. That object can then notify anything watching that property that the property has changed. So, for instance, if a property is being displayed via a template, that template will be notified to redraw the property.

We’re close. The final step is to make the App.SharedObject proxy it’s get and set calls to the ShareJS document. To do this, I use unknownProperty and setUnknownProperty.

App.SharedObject = Ember.Object.extend({
  /* ... the init from above ... */

  unknownProperty: function(key) {
    // ShareJS documents are undefined by default. Return null if
    // the document is undefined and the requested property could
    // not possibly have a value.
    if (!this._doc.at().get()) { return null; }

    var subdoc = this._doc.at(key);
    return subdoc ? subdoc.get() : null
  },

  setUnknownProperty: function(key, value) {
    // ShareJS documents are undefined by default. Set an empty
    // object to the document value if it has not been set before.
    if (!this._doc.at().get()) { this._doc.set({}); }

    var subdoc = this._doc.at(key);
    subdoc.set(value);
    this.propertyDidChange(key)
    return value;
  }

});

If a property is not found on an Ember object, that object will pass the request to unknownProperty. Because none of the properties we share over ShareJS are defined on this object, every get request will hit unknownProperty. This is basically a proxy: The Ember.Object API hiding ShareJS behind it. Ember provides powerful enough internals for us to do this. Our proxy object can now be used anywhere a normal Ember object can be used.

If you App.SharedObject.open('docName') with the same docName in two browsers, then all properties on that object will synchronize in realtime. New properties will propagate in realtime. Even more amazing, ShareJS will even buffer these updates if the network connection goes down between the browsers. You can kill the server, make changes in browser A (browser B cannot receive updates because the server is down), then reboot the server and see all your changes appear in browser B.

It’s easier to play with this behavior over a real network, so let’s put this demo on Heroku.

Getting ShareJS live on Heroku

ShareJS can use any of several backends for persistance. I’m going to use Redis with OpenRedis. Be aware that the syntax for configuring ShareJS’s Redis connection is obscure at best. It took some code spelunking to figure out what was expected where.

I ended up with a server (app.js) that looks like this:

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

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

var options = {db: {type: 'redis'}};

// Production options
var url = require("url").parse(process.env.OPENREDIS_URL);
options['db']['hostname'] = url.hostname;
options['db']['port'] = url.port;
options['db']['auth'] = url.auth.split(":")[1];

sharejs.attach(server, options);

var port = process.env.PORT || 5000;
server.listen(port);
console.log('Server running at port '+port);

For Heroku to boot the app, a package.json is required to load dependencies:

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

And Procfile is required to tell Heroku how to start the server:

web: node app.js

There is a demo server running at whowatchesthewatchmen.herokuapp.com. I suggest opening up the console and using App.SharedObject.open to open your own shared object, and playing with how things propagate.

All the code for that page is uncompressed and inline if you view the source.

Adding real OT messages to the shared object layer is a natural next step, tough it will be a non-trivial undertaking. From this work, you can already see that shared textarea editing only scratches the surface of what ShareJS can do.