Skip to content

Commit

Permalink
initial
Browse files Browse the repository at this point in the history
  • Loading branch information
janmonschke committed Apr 17, 2015
1 parent b78a59e commit 6084c41
Show file tree
Hide file tree
Showing 12 changed files with 1,212 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .eslintrc
@@ -0,0 +1,15 @@
{
"env": {
"browser": true,
"node": true,
"mocha": true
},
"rules": {
"no-mixed-requires": false,
"no-multi-spaces": false,
"no-new": false,
"no-underscore-dangle": false,
"quotes": [2, "single", "avoid-escape"],
"strict": [2, "never"]
}
}
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
.DS_Store
node_modules/
3 changes: 3 additions & 0 deletions README.md
@@ -0,0 +1,3 @@
# diffsync.js

Real time collaborative editing for JSON objects
6 changes: 6 additions & 0 deletions index.js
@@ -0,0 +1,6 @@
module.exports = {
Client: require('./src/client'),
Server: require('./src/server'),
COMMANDS: require('./src/commands'),
InMemoryDataAdapter: require('./src/adapter')
};
27 changes: 27 additions & 0 deletions package.json
@@ -0,0 +1,27 @@
{
"name": "diffsync.js",
"version": "0.0.1",
"description": "Real time collaborative editing for JSON objects",
"main": "index.js",
"scripts": {
"test": "mocha test/**/*.js"
},
"dependencies": {
"jsondiffpatch": "^0.1.31",
"underscore": "^1.8.3"
},
"devDependencies": {
"mocha": "^2.2.4",
"sinon": "^1.14.1"
},
"repository": {
"type": "git",
"url": "https://github.com/janmonschke/diffsync.js.git"
},
"author": "Jan Monschke",
"license": "MIT",
"bugs": {
"url": "https://github.com/janmonschke/diffsync.js/issues"
},
"homepage": "https://github.com/janmonschke/diffsync.js"
}
32 changes: 32 additions & 0 deletions src/adapter.js
@@ -0,0 +1,32 @@
/**
* A dumb in-memory data store. Do not use in production.
* Only for demo purposes.
* @param {Object} cache
*/
var InMemoryDataAdapter = function(cache){
// `stores` all data
this.cache = cache || {};
};

/**
* Get the data specified by the id
* @param {String/Number} id ID for the requested data
* @param {Function} cb
*/
InMemoryDataAdapter.prototype.getData = function(id, cb){
var data = this.cache[id];
if(!data){
this.cache[id] = {};
}
cb(null, this.cache[id]);
};

/**
* Stores `data` at `id`
*/
InMemoryDataAdapter.prototype.storeData = function(id, data, cb){
this.cache[id] = data;
if(cb){ cb(null); }
};

module.exports = InMemoryDataAdapter;
235 changes: 235 additions & 0 deletions src/client.js
@@ -0,0 +1,235 @@
var _ = require('underscore'),
jsondiffpatch = require('jsondiffpatch').create({
objectHash: function(obj) { return obj.id || obj._id || JSON.stringify(obj); }
}),

COMMANDS = require('./commands'),
utils = require('./utils'),
Client;

Client = function(socket, room){
if(!socket){ throw new Error('No socket specified'); }
if(!room){ room = ''; }

this.socket = socket;
this.room = room;
this.syncing = false;
this.initialized = false;
this.scheduled = false;
this.doc = {
localVersion: 0,
serverVersion: 0,
shadow: {},
localCopy: {},
diffs: []
};

_.bindAll(this, '_onConnected', 'syncWithServer', 'applyServerEdit', 'applyServerEdits');
};

/**
* Get the data
* @return {Object} [description]
*/
Client.prototype.getData = function(){
return this.doc.localCopy;
};

/**
* Initializes the sync session
* @return {[type]} [description]
*/
Client.prototype.initialize = function(){
// connect, join room and initialize
this.syncing = true;
this.socket.emit(COMMANDS.join, this.room, this._onConnected);
};

/**
* Sets up the local version and listens to server updates
* Will notify the `onConnected` callback.
* @param {Object} initialVersion The initial version from the server
*/
Client.prototype._onConnected = function(initialVersion){
// client is not syncing anymore and is initialized
this.syncing = false;
this.initialized = true;

// set up shadow doc, local doc and initial server version
// IMPORTANT: the shadow needs to be a deep copy of the initial version
// because otherwise changes to the local object will also result in changes
// to the shadow object because they are pointing to the same doc
this.doc.shadow = utils.deepCopy(initialVersion);
this.doc.localCopy = initialVersion;
this.doc.serverVersion = 0;

// listen to incoming updates from the server
this.socket.on(COMMANDS.remoteUpdateIncoming, this.schedule);

// notify about established connection
this.onConnected();
};

/**
* Schedule a sync cycle. This method should be used from the outside to
* trigger syncs.
*/
Client.prototype.schedule = function(){
// do nothing if already scheduled
if(this.scheduled){ return; }
this.scheduled = true;

// try to sync now
this.syncWithServer();
};

/**
* Starts a sync cycle. Should not be called from third parties
*/
Client.prototype.syncWithServer = function(){
if(this.syncing || !this.initialized){ return false; }
if(this.scheduled){ this.scheduled = false; }

// initiate syncing cycle
this.syncing = true;

// 1) create a diff of local copy and shadow
var diff = this.createDiff(this.doc.shadow, this.doc.localCopy);
var basedOnLocalVersion = this.doc.localVersion;

// 2) add the difference to the local edits stack if the diff is not empty
if(!_.isEmpty(diff)){
this.doc.diffs.push(this.createDiffMessage(diff, basedOnLocalVersion));
this.doc.localVersion++;
}

// 3) create an edit message with all relevant version numbers
var editMessage = this.createEditMessage(basedOnLocalVersion);

// 4) apply the patch to the local shadow
this.applyPatchTo(this.doc.shadow, utils.deepCopy(diff));

// 5) send the edits to the server
this.sendEdits(editMessage);

// yes, we're syncing
return true;
};

/**
* Returns a diff of the passed documents
* @param {Object} docA
* @param {Object} docB
* @return {Diff} The diff of both documents
*/
Client.prototype.createDiff = function(docA, docB){
return jsondiffpatch.diff(docA, docB);
};

/**
* Applies the path to the specified object
* WARNING: The patch is applied in place!
* @param {Object} obj
* @param {Diff} patch
*/
Client.prototype.applyPatchTo = function(obj, patch){
jsondiffpatch.patch(obj, patch);
};

/**
* Creates a message for the specified diff
* @param {Diff} diff the diff that will be sent
* @param {Number} baseVersion the version of which the diff is based
* @return {Object} a diff message
*/
Client.prototype.createDiffMessage = function(diff, baseVersion){
return {
serverVersion: this.doc.serverVersion,
localVersion: baseVersion,
diff: diff
};
};

/**
* Creates a message representing a set of edits
* An edit message contains all edits since the last sync has happened.
* @param {Number} baseVersion The version that these edits are based on
* @return {Object} An edit message
*/
Client.prototype.createEditMessage = function(baseVersion){
return {
room: this.room,
edits: this.doc.edits,
localVersion: baseVersion,
serverVersion: this.doc.serverVersion
};
};

/**
* Send the the edits to the server and applies potential updates from the server
*/
Client.prototype.sendEdits = function(editMessage){
this.socket.emit(COMMANDS.syncWithServer, editMessage, this.applyServerEdits);
};

/**
* Applies all edits from the server and notfies about changes
* @param {Object} serverEdits The edits message
*/
Client.prototype.applyServerEdits = function(serverEdits){
if(serverEdits && serverEdits.localVersion === this.doc.localVersion){
// 0) delete all previous edits
this.doc.edits = [];
// 1) iterate over all edits
serverEdits.edits.forEach(this.applyServerEdit);
}else{
// Rejected patch because localVersions don't match
this.onError('REJECTED_PATCH');
}

// we are not syncing any more
this.syncing = false;

// notify about sync
this.onSynced();

// if a sync has been scheduled, sync again
if(this.scheduled) {
this.syncWithServer();
}
};

/**
* Applies a single edit message to the local copy and the shadow
* @param {[type]} editMessage [description]
* @return {[type]} [description]
*/
Client.prototype.applyServerEdit = function(editMessage){
// 2) check the version numbers
if(editMessage.localVersion === this.doc.localVersion &&
editMessage.serverVersion === this.doc.serverVersion){

if(!_.isEmpty(editMessage.diff)){
// versions match
// 3) patch the shadow
this.applyPatchTo(this.doc.shadow, editMessage.diff);

// 4) increase the version number for the shadow if diff not empty
this.doc.serverVersion++;
// apply the patch to the local document
// IMPORTANT: Use a copy of the diff, or newly created objects will be copied by reference!
this.applyPatchTo(this.doc.localCopy, utils.deepCopy(editMessage.diff));
}

return true;
}else{
// TODO: check in the algo paper what should happen in the case of not matching version numbers
return false;
}
};

Client.prototype.onConnected = _.noop;
Client.prototype.onSynced = _.noop;
Client.prototype.onError = _.noop;

module.exports = Client;
6 changes: 6 additions & 0 deletions src/commands.js
@@ -0,0 +1,6 @@
module.exports = {
join: 'join',
syncWithServer: 'send-edit',
remoteUpdateIncoming: 'updated-doc',
error: 'error'
};

0 comments on commit 6084c41

Please sign in to comment.