Code Review: SocketStream
Code Review is a series on DailyJS where I take a look at an open source project to see how it’s built. Along the way we’ll learn patterns and techniques by JavaScript masters. If you’re looking for tips to write better apps, or just want to see how they’re structured in established projects, then this is the tutorial series for you.
About SocketStream
SocketStream (License: MIT, npm: socketstream) is a full stack framework for building single-page applications. It’s still under heavy development, but it already offers some extremely useful features:
- WebSockets
- Client-side rendering
- HTTPS support
- Built-in authentication and user model
Usage
SocketStream apps can be generated with a command-line client, so it’s a good idea to install it globally with npm:
npm install -g socketstream
socketstream new dailyjs-test
cd dailyjs-test
socketstream start
Running this skeleton app will show a welcome page with a little WebSocket chat demo.
Structure
SocketStream is built with CoffeeScript and includes a Cakefile which is the CoffeeScript version of a good old fashioned Makefile. The core project is really contributor/hacker territory rather than something users of the framework are likely to use, but that’s where I started when checking out the project. Anyway, I’m baffled by this particular Cakefile because it refers to tests that don’t seem to exist (it’s looking for a spec/ folder but there isn’t one).
The SocketStream binary script is integral to working with SocketStream projects. This concerned me because I often have multiple projects that use different versions of a framework. However, fortunately it’s possible to get around this by running npm install socketstream inside a SocketStream project’s directory, then running node_modules/socketstream/bin/socketstream start to start it up.
While looking around this level of the project I noticed the package.json file is extremely specific about dependency versions:
"dependencies": {
"coffee-script": "= 1.1.1",
"socket.io": "= 0.6.18",
"redis": "= 0.6.6",
"hiredis": "= 0.1.12",
"node-static": "= 0.5.6",
// ...
I recommend doing this in your own projects, simply because I can guarantee that people will break APIs given half a chance. Only use more relaxed versions if a library is extremely simple and is unlikely to change its public API.
The SocketStream binary script itself is only 11 lines of code. I like short binary files because it’s usually an indication that everything is broken up into modules and therefore easy to test.
Command line arguments are parsed using argsparser. I typically parse arguments with this pattern:
var args = process.argv.slice(2)
, path = '.';
while (args.length) {
var arg = args.shift();
switch (arg) {
case '-h':
case '--help':
console.log(usage);
process.exit(0);
case '-v':
case '--version':
console.log('Version 1.0');
process.exit(0);
case '--server':
useServer = true;
break;
default:
path = arg;
}
}
I copied this pattern from TJ Holowaychuk’s projects, and as you can see it’s pretty short and sweet. I’m not sure what improvements argsparser adds to this, so further investigation may be necessary. This might seem like a minor point, but so many Node projects ship with binaries these days that I think it’s worth thinking about.
Back to SocketStream. Once the arguments are parsed, CoffeeScript is loaded, and then the arguments are passed to main.coffee.
main.coffee
Right off the bat I noticed the generous amount of comments and consistent code formatting in this project. Although CoffeeScript can sometimes feel a bit like a wall of text (without those extra braces everywhere), it’s fairly easy to follow this project’s code. The command processing is done with a simple case statement, and heredocs are used to print out long sections of text (I’m a big fan of heredocs).
In exports.init the SS global, which is effectively used to communicate between client and server:
The key to using SocketStream is the ‘SS’ global variable which can be called anywhere within your server or client-side code.
This file also loads helpers.js which I presume is JavaScript rather than CoffeeScript because it’s shared between the client and server-side code. It provides a lot of useful methods, but extends native objects. Obviously this saves typing, but I think it would be better to avoid doing this unless the additions are ECMAScript shims. Also, this file mixes tabs and spaces — is it really that hard to set your editor to use spaces like everyone else? Clearly it’s just one dude here who’s adding these phantom tabs in random parts of the project. As well as phantom tabs there’s also phantom whitespace before line endings, so I assume someone is using TextMate.
The fact this file has the following comment raises a red flag:
Be very careful about introducing new helpers as they may break external third-party client-side libraries.
Elsewhere in main.coffee, I saw something that I keep finding myself doing: loading the modules used throughout the project in one global so they don’t need to be required all the time:
externalLibs: ->
[
['coffee', 'coffee-script']
['io', 'socket.io']
['static', 'node-static']
['jade', 'jade']
['stylus', 'stylus']
['uglifyjs', 'uglify-js']
['redis', 'redis']
['semver', 'semver']
].forEach (lib) ->
npm_name = lib[1]
try
version = SS.internal.package_json.dependencies[lib[1]].substring(2)
catch e
throw new Error("Unable to find #{npm_name} within the package.json dependencies")
try
SS.libs[lib[0]] = require("#{npm_name}@#{version}")
catch e
try
SS.libs[lib[0]] = require("#{npm_name}")
catch e
throw new Error("Unable to start SocketStream as we're missing #{npm_name}.\nPlease install with 'npm install #{npm_name}'. Please also check the version number if you're using a version of npm below 1.0")
I usually just do this with something like winston — anything that might be instantiated and shared. Node caches modules, so I’m not sure why something like Jade needs to be loaded this way. Elsewhere we see code like SS.libs.jade.renderFile which seems needlessly wordy for something that’s obviously a third-party module.
App Generation
At the moment apps are just generated by copying an example app. I expect this will be expanded in the future. The script checks to ensure the directory isn’t already taken, then runs something that should look familiar to anyone who’s done a lot of Node filesystem work:
exports.recursiveCopy = (source, destination) ->
files = fs.readdirSync source
# iterates over current directory
files.forEach (file) ->
unless file.match /^\./
sourcePath = path.join source, file
destinationPath = path.join destination, file
stats = fs.statSync sourcePath
if stats.isDirectory()
fs.mkdirSync destinationPath, 0755
exports.recursiveCopy sourcePath, destinationPath
else
exports.copyFile sourcePath, destinationPath
logger.coffee
I was surprised to find logging in the project, since winston seems so popular lately, but the authors have left a note saying logging is a work in progress:
NOTE: Let’s be honest. This idea sucks. It seemed like a good idea at the time, but I’m sure we can a lot better
The output of the logs looks relatively useful, and the current structure looks like it was designed to help transition to i18n in the future. There’s also a comment about an exception handling system elsewhere, so hopefully the authors will add an app-level option to turn off logging and just collect exceptions (have you ever seen the gigabytes of useless logs generated by Rails apps?)
Server
The server, session, and request files are mostly custom solutions for SocketStream. This is heavily based around Socket.IO. The server code is split between HTTP, HTTPS, and “API” code. SocketStream treats persistent HTTP connections differently to traditional stateless HTTP requests, and this is reflected by allowing applications to include optional HTTP APIs. These are mounted at a URL, the default is /api.
The server code also includes a form of keepalive, referred to as “heartbeat”. This helps maintain a count of how many users are online. I wondered if this whole user tracking code could instead be built like a plugin, rather than inside the main server code. It seems like it would be desirable to add an EventEmitter (or perhaps the pubsub system) to the server to allow external code, such as user management, bind to various events and process them accordingly.
Example Apps
The SocketStream Chat demo includes a deployment script that’s easy to follow and potentially reuse for your own projects. I think this is great, because many frameworks rarely give much guidance to this side of the project life cycle.
Benchmarks
SocketStream’s documentation has this to say:
[Features] … Crazy fast! Starts up instantly. No HTTP handshaking/headers/routing to slow down every request
That makes sense, but where are the benchmarks? I only found one benchmark in the HTTP middleware code. If you’re going to market your project as “crazy fast” I want to see real benchmarks! Look at Jade, right there at the top-level there’s a folder called benchmarks/. If you’re serious about speed be scientific about it.
Conclusion
The authors of SocketStream write great CoffeeScript, and it’s an extremely ambitious project. I’d love to spend some more time looking over the code to fully appreciate it, but sadly I’m out of time for this Code Review. So far they’ve put a lot of effort into the project, and judging by their code comments the project is heading in a positive direction.
In summary:
- Ship tests (where are the SocketStream tests)?
- Could we get more benchmarks, perhaps something that can be run from the command line?
- Selecting a house style for code formatting might help collaborators work together better. It’s consistent but there’s definitely a rogue tabber out there
- Is it really a good idea to load every module and pass it around the project?
- Is extending native objects in the helpers module the best approach?
Something else I thought about while writing and researching this article was this: is building something as fundamental as a framework in CoffeeScript a good idea? Please don’t beat me up about this point, I mention it for the prospect of an interesting discussion!