How to share Backbone.js models with node.js
I’ve been building a node.js app for a project for a while now, and I just redesigned the architecture of the client (a single-page HTML5 app) to use Backbone.js. The problem that I needed to solve was the ability to reuse common data models and business logic both on the client and the server, so that I wouldn’t have to have two different representations of the same model. I like to have all my parsing and serialization logic “nearby” so if the over-the-wire format changes, there’s no hunting involved.
One of the real beauties of server-side JavaScript is that you’re able to reuse business logic on the client. Mundane tasks like input validation, need to at least happen on the server. Traditional Web 1.0 apps did these entirely on the server, but modern web apps often need to do it both on the server and client since a web-based UI might not be the only consumer. If you’re offering an API for third-party developers, then it’s handy to offer single logic for validation to avoid inconsistencies.
Backbone.js requires Underscore.js, so both need to be made available to modules in the node.js environment. Luckily, both are CommonJS-aware, so adding the directory containing underscore.js to the require path as part of your bootstrapping process is a good idea:
require.paths.unshift('../vendor/underscore');
The following code generates the client-side source from a model object that could contain both server and client-side logic. It looks for an __exports__
property in the constructor. ClientModelFactory will only serialize properties defined by __exports__
either through the exact name or a regular expression matching the constructor’s own properties or those of its prototype.
modelfactory.js
// Models should live in ./models
// ClientModelFactory.makeAll() will generate the client-side source for models, with
// only properties defined in the constructor's __exports__ array property.
var util = require('util'),
fs = require('fs');
var EXTENDS_REGEX = /@extends (.+)/,
JS_REGEX = /^[a-zA-Z0-9_\-]+\.js$/;
function log(s) {
util.puts('[ClientModelFactory] ' + s);
}
function serialize(object, name, property) {
if(property in object) {
return name + '.' + property + ' = ' + object[property] + ';\n';
} else if(property in object.prototype) {
return name + '.prototype.' + property + ' = ' + object.prototype[property] + ';\n';
} else {
return '';
}
}
function ClientModelFactory(modelpath) {
this.models = {};
var files = fs.readdirSync(modelpath);
for(var i = 0; i < files.length; i++) {
if(!files[i].match(JS_REGEX))
continue;
var name = files[i].replace('.js', '');
log('loading model file: ' + files[i]);
var module;
try {
module = require(modelpath + files[i]);
} catch(e) {
log(' skipping due to exception: ' + e);
continue;
}
this.models[name] = '';
for(var export in module) {
if(typeof module[export] != 'function')
continue;
if('__exports__' in module[export]) {
// constructor
this.models[name] += module[export] + "\n";
// handle inheritance using ghetto @extends comments
var match = (''+module[export]).match(EXTENDS_REGEX);
if(match) {
this.models[name] += this.extend(export, match[1]);
}
var export_list = module[export]['__exports__'];
for(var j = 0; j < export_list.length; j++) {
var prop = export_list[j];
if(typeof prop == 'function') { // regular expression
for(var p in module[export]) {
if(module[export].hasOwnProperty(p) && p.match(prop))
this.models[name] += serialize(module[export], export, p);
}
} else if(typeof prop == 'string') { // exact string
this.models[name] += serialize(module[export], export, prop);
}
}
}
}
}
}
ClientModelFactory.prototype.extend = function extend(child, parent) {
// this code is inserted immediately after a constructor
return child + '.prototype = ' + parent + '.prototype;\n';
};
ClientModelFactory.prototype.make = function make(model) {
return this.models[model];
};
ClientModelFactory.prototype.makeAll = function makeAll() {
var sources = [];
for(var model in this.models) {
sources.push(this.make(model));
}
return sources.join('');
};
exports.ClientModelFactory = global['ClientModelFactory'] || (global['ClientModelFactory'] = new ClientModelFactory(__dirname + '/models/'));
user.js
var Backbone = require('backbone');
function User() { // @extends Backbone.Model
return Backbone.Model.apply(this, arguments);
}
User.prototype = Backbone.Model.prototype;
// by default export all uppercase properties and validate
User.__exports__ = [/^[A-Z]+[A-Z_]+/, 'validate'];
User.EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
User.prototype.validate = function validate() {
var email = this.get('email');
return !!email.match(User.EMAIL_REGEX);
};
User.prototype.updateDB = function updateDB() {
// server-only logic here...
};
exports.User = User;
To use this technique, you should include both Underscore.js and Backbone.js in your HTML page as usual. You’ll also want to include the generated client-side model that is generated by calling ClientModelFactory.makeAll(). For development, you could have a URL handler at /js/models.js that calls this and returns the generated content. Obviously, for production, doing the generation offline and integrating it with part of your build process makes much more sense.
This approach blurs the line between client and server. In the war against cruft, this is a plus for me.