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.