Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add API docs to README and make HttpClient.write behave more like net…

….Stream.write.
  • Loading branch information...
commit d0283c3e146e656d32df99159da16d75f704a49b 1 parent f50fc81
Brian White authored
Showing with 173 additions and 41 deletions.
  1. +135 −5 README.md
  2. +12 −14 lib/grappler.js
  3. +26 −22 lib/http.client.js
140 README.md
View
@@ -1,19 +1,25 @@
Grappler
========
-Grappler is a minimalistic server for hanging ("comet") TCP/HTTP connections that exposes a single, consistent API across all transports.
-It supports the following transports:
+Grappler is a minimalistic server for "comet" and TCP connections that exposes a single, consistent API across all transports.
+Grappler currently supports the following transports (each with a list of currently supported browsers):
-- WebSockets (with Flash policy support)
+- WebSockets (with Flash policy support -- watches for policy requests on the same port as the grappler server)
+ - Firefox 4, Chrome 4, Safari 5, or any browser that supports at least Flash 9.x
- XHR Long Polling
+ - Any browser that supports XMLHttpRequest*
- XHR Multipart Streaming
+ - Firefox 3
- Server-Sent Events
+ - Chrome 6, Safari 5, Opera 9.x-10.x (DOM only)
- Plain TCP connections (Not yet implemented)
+* - Some browsers' XMLHttpRequest implementations contain unexpected quirks (i.e. the built-in web browser for Android 1.6)
+
Requirements
============
-- Tested with Node v0.1.100+
+- Node.JS v0.1.100+
- A client supporting one of the aforementioned transports.
- For HTTP (non-WebSocket) clients, cookies must be enabled for clients ONLY if they are going to send messages (i.e. via POST) to the server.
@@ -26,4 +32,128 @@ Visit the example server's test page in your browser: http://serverip:8080/test
API
===
-TODO :-)
+Grappler exports a few objects, with the main object being: `Server`.
+The others include the `LOG` and `STATE` objects, which contain constants used for when logging messages and representing the state of a client respectively.
+
+The `LOG` object is:
+ {
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3
+ }
+
+The `STATE` object is:
+ {
+ ACCEPTED: 1, // internal use only
+ TEMP: 2, // internal use only
+ PROTO_HTTP: 4, // client is HTTP-based
+ PROTO_WEBSOCKET: 8, // client is WebSocket-based
+ PROTO_TCP: 16 // client is plain TCP-based
+ }
+
+## Server
+
+### Constructor: new Server([options], [fnHandleNormalHTTP], [fnAcceptClient])
+
+Creates a new instance of a grappler server.
+
+`options` is an object with the following default values:
+ {
+ // A callback for receiving "debug" information. It is called with two arguments: message and message level.
+ // Message level is one of the values in the `LOG` object.
+ logger: function(msg, msgLevel) {},
+
+ // A string or array of strings which denote who is allowed to connect to this grappler Server instance.
+ // The format of the string is: "hostname:port", "hostname", or an asterisk substituted for either the hostname
+ // or port, or both, to act as a wildcard.
+ origins: "*:*",
+
+ // An integer value in milliseconds representing the interval in which a ping is sent to an HTTP client for those
+ // transports that need to do so.
+ pingInterval: 3000
+ }
+
+`fnHandleNormalHTTP` is a callback which is able to override grappler for an incoming HTTP connection.
+If no headers are sent, then grappler takes control of the connection. The arguments provided to this callback are the
+same for `http.Server`'s `request` event, that is the http.ServerRequest and http.ServerResponse objects. It should be
+noted that if you want grappler to automatically handle all incoming HTTP connections but want to specify a callback
+for `fnAcceptClient`, you need to specify `null` or `false` for `fnHandleNormalHTTP`.
+
+`fnAcceptClient` is a callback that is executed the moment a client connects (even before they are deemed an HTTP or plain TCP
+client). The main purpose of this callback is to have the chance to immediately deny a client further access to the grappler server.
+For example, your application may maintain a blacklist or may automatically blacklist/throttle back a certain IP after x connections in y time.
+If this callback returns false, the connection will automatically be dropped, otherwise the connection will be permitted.
+The callback receives one argument, which is the `net.Stream` object representing the connection.
+
+### Event: connection
+
+`function(client) { }`
+
+This event is emitted every time a new client has successfully connected to the system.
+`client` is an instance of `Client`.
+
+### Event: data
+
+`function(data, client) { }`
+
+This event is emitted when a client sends data to the server.
+`data` is a Buffer containing the data and `client` is the `Client` who sent it.
+
+### Event: error
+
+`function(err) { }`
+
+Emitted when an unexpected error occurs.
+
+### listen(port, [host])
+
+Starts the server listening on the specified `port` and `host`. If `host` is omitted, the server will listen on any IP address.
+
+### broadcast(data)
+
+Sends `data` to every connected client.
+
+### shutdown()
+
+Shuts down the server by no longer listening for incoming connections and severing any existing client connections.
+
+
+## Client
+
+There is one other important object that is used in grappler, and that is the `Client` object.
+`Client` represents a user connected to the server and can be used to send that user data.
+
+### Event: drain
+
+`function() { }`
+
+Emitted when the client's write buffer becomes empty.
+
+### Event: close
+
+`function() { }`
+
+Emitted when the client has disconnected.
+
+### state
+
+A bit field containing the current state of the client. See the aforementioned `STATE` object for valid bits.
+
+### remoteAddress
+
+The IP address of the client.
+
+### write(data, [encoding])
+
+Sends `data` using an optional encoding to the client.
+This function returns `true` if the entire data was flushed successfully to the kernel
+buffer. Otherwise, it will return `false` if all or part of the data was queued in user memory.
+`drain` will be emitted when the kernel buffer is free again.
+
+### broadcast(data)
+
+Sends `data` to every connected client except itself.
+
+### disconnect()
+
+Forcefully severs the client's connection to the server.
26 lib/grappler.js
View
@@ -22,7 +22,7 @@ try {
var LOG = exports.LOG = {
INFO: 1,
WARN: 2,
- ERROR: 4
+ ERROR: 3
};
var STATE = exports.STATE = {
@@ -163,7 +163,7 @@ function Server(options/*, fnHandleNormalHTTP, fnAcceptClient*/) {
// in the time determined by options.detectTimeout
if (!(socket.client.state & STATE.PROTO_HTTP)) {
socket.client.state |= STATE.PROTO_TCP;
- connections[socket.client.id] = new TcpClient(socket.client);
+ connections[socket.client._id] = new TcpClient(socket.client);
}
});
socket.setTimeout(self.options.detectTimeout);
@@ -173,10 +173,10 @@ function Server(options/*, fnHandleNormalHTTP, fnAcceptClient*/) {
var fnClose = function() {
if (socket.isMarked == undefined) {
- if (connections[socket.client.id])
- connections[socket.client.id].disconnect();
+ if (connections[socket.client._id])
+ connections[socket.client._id].disconnect();
socket.isMarked = true;
- logger('Server :: Connection closed: id == ' + socket.client.id, LOG.INFO);
+ logger('Server :: Connection closed: id == ' + socket.client._id, LOG.INFO);
}
};
socket.addListener('close', fnClose);
@@ -185,10 +185,10 @@ function Server(options/*, fnHandleNormalHTTP, fnAcceptClient*/) {
if (!cbAccept(socket)) {
// The incoming connection was denied for one reason or another
socket.destroy();
- logger('Server :: Incoming connection denied: id == ' + socket.client.id, LOG.INFO);
+ logger('Server :: Incoming connection denied: id == ' + socket.client._id, LOG.INFO);
} else {
socket.client.state |= STATE.ACCEPTED;
- logger('Server :: Incoming connection accepted: id == ' + socket.client.id, LOG.INFO);
+ logger('Server :: Incoming connection accepted: id == ' + socket.client._id, LOG.INFO);
}
});
server.addListener('request', function(req, res) {
@@ -206,7 +206,7 @@ function Server(options/*, fnHandleNormalHTTP, fnAcceptClient*/) {
if (!res._header)
new HttpClient(req, res);
else
- logger('Server :: HTTP connection handled by callback. id == ' + req.connection.client.id, LOG.INFO);
+ logger('Server :: HTTP connection handled by callback. id == ' + req.connection.client._id, LOG.INFO);
}
});
server.addListener('upgrade', function(req, socket, head) {
@@ -219,7 +219,7 @@ function Server(options/*, fnHandleNormalHTTP, fnAcceptClient*/) {
if (!cbHandleHTTP(req, socket))
new HttpClient(req, socket, head);
else
- logger('Server :: HTTP Upgrade request handled by callback. id == ' + req.connection.client.id, LOG.INFO);
+ logger('Server :: HTTP Upgrade request handled by callback. id == ' + req.connection.client._id, LOG.INFO);
}
});
@@ -227,11 +227,10 @@ function Server(options/*, fnHandleNormalHTTP, fnAcceptClient*/) {
self.emit('error', err);
});
- this.close = function() {
+ this.shutdown = function() {
server.close();
for (var i=0,keys=Object.keys(connections),len=keys.length; i<len; ++i)
connections[keys[i]].disconnect();
- self.emit('close');
};
}
sys.inherits(Server, EventEmitter);
@@ -248,16 +247,15 @@ Server.prototype.broadcast = function(data, except) {
};
function Client(srv, ip) {
- this.id = Math.floor(Math.random()*1e5).toString() + (new Date()).getTime().toString();
+ this._id = Math.floor(Math.random()*1e5).toString() + (new Date()).getTime().toString();
this.state = STATE.TEMP;
this.remoteAddress = ip;
var server = srv;
- var self = this;
this.__defineGetter__('server', function() { return server; });
this.broadcast = function(data) {
- server.broadcast(data, id);
+ server.broadcast(data, this._id);
};
}
exports.Client = Client;
48 lib/http.client.js
View
@@ -28,7 +28,7 @@ function pack(num) {
}
var HttpClient = function(req, res) {
- // Manual inheritance from the Client base class :-\
+ // Instance inheritance from the Client object :-\
grappler.mixin(this, req.connection.client);
var self = this;
@@ -45,7 +45,7 @@ var HttpClient = function(req, res) {
// Check for a permitted origin
if (req.headers.origin && !isAllowed(server.options.origins, req.headers.origin)) {
- server.options.logger('HttpClient :: Denied client ' + self.id + ' due to disallowed origin (\'' + req.headers.origin + '\')', grappler.LOG.INFO);
+ server.options.logger('HttpClient :: Denied client ' + self._id + ' due to disallowed origin (\'' + req.headers.origin + '\')', grappler.LOG.INFO);
if (res instanceof http.ServerResponse) {
res.writeHead(403);
res.end();
@@ -73,7 +73,7 @@ var HttpClient = function(req, res) {
'Connection: Upgrade'],
inBuffer = null;
- server.options.logger('HttpClient :: Using WebSocket draft ' + draft + ' for client id ' + self.id, grappler.LOG.INFO);
+ server.options.logger('HttpClient :: Using WebSocket draft ' + draft + ' for client id ' + self._id, grappler.LOG.INFO);
if (draft == 75) {
outgoingdata = outgoingdata.concat(['WebSocket-Origin: ' + req.headers.origin, 'WebSocket-Location: ws://' + req.headers.host + req.url]);
@@ -147,9 +147,9 @@ var HttpClient = function(req, res) {
isSSE = ((req.headers.accept && req.headers.accept.indexOf('text/event-stream') > -1) || isSSEDOM);
req.connection.setKeepAlive(true, server.options.pingInterval);
if (isMultipart) { // Multipart (x-mixed-replace)
- res.setSecureCookie('grappler', self.id);
+ res.setSecureCookie('grappler', self._id);
self._makePerm();
- server.options.logger('HttpClient :: Using multipart for client id ' + self.id, grappler.LOG.INFO);
+ server.options.logger('HttpClient :: Using multipart for client id ' + self._id, grappler.LOG.INFO);
req.connection.setNoDelay(true);
res.useChunkedEncodingByDefault = false;
res.writeHead(200, {
@@ -169,9 +169,9 @@ var HttpClient = function(req, res) {
});
server.emit('connection', this);
} else if (isSSE) { // Server-Side Events
- res.setSecureCookie('grappler', self.id);
+ res.setSecureCookie('grappler', self._id);
self._makePerm();
- server.options.logger('HttpClient :: Using server-side events for client id ' + self.id, grappler.LOG.INFO);
+ server.options.logger('HttpClient :: Using server-side events for client id ' + self._id, grappler.LOG.INFO);
res.writeHead(200, {
'Content-Type': (isSSEDOM ? 'application/x-dom-event-stream' : 'text/event-stream'),
'Expires': 'Fri, 01 Jan 1990 00:00:00 GMT',
@@ -194,7 +194,7 @@ var HttpClient = function(req, res) {
} catch(e) {}
if (!cookie) {
- res.setSecureCookie('grappler', self.id);
+ res.setSecureCookie('grappler', self._id);
// Reset connection to make sure the session cookie is set
self.write("");
} else {
@@ -232,7 +232,7 @@ var HttpClient = function(req, res) {
}
}, server.options.pingInterval);
});
- server.options.logger('HttpClient :: Client prefers id of ' + cookie + ' instead of ' + self.id + (isSubsequent ? ' (subsequent)' : ''), grappler.LOG.INFO);
+ server.options.logger('HttpClient :: Client prefers id of ' + cookie + ' instead of ' + self._id + (isSubsequent ? ' (subsequent)' : ''), grappler.LOG.INFO);
server.options.logger('HttpClient :: Using long polling for client id ' + cookie, grappler.LOG.INFO);
}
}
@@ -288,33 +288,36 @@ sys.inherits(HttpClient, EventEmitter);
exports.HttpClient = HttpClient;
HttpClient.prototype._makePerm = function() {
+ var self = this;
this.state = (this.state & ~grappler.STATE.TEMP);
- this.server.connections[this.id] = this; // lazy add to connections list
+ this.server.connections[this._id] = this; // lazy add to connections list
+ this._request.connection.addListener('drain', function() { self.emit('drain'); });
};
HttpClient.prototype.write = function(data, encoding) {
var isMultipart = (this._request.headers.accept && this._request.headers.accept.indexOf('multipart/x-mixed-replace') > -1),
isSSEDOM = (this._request.headers.accept && this._request.headers.accept.indexOf('application/x-dom-event-stream') > -1),
isSSE = ((this._request.headers.accept && this._request.headers.accept.indexOf('text/event-stream') > -1) || isSSEDOM),
- self = this;
+ self = this,
+ retVal = true;
try {
if (this.state & grappler.STATE.PROTO_WEBSOCKET) { // WebSocket
if (data.length > 0) {
this._request.connection.write('\x00', 'binary');
this._request.connection.write(data, 'utf8');
- this._request.connection.write('\xff', 'binary');
+ retVal = this._request.connection.write('\xff', 'binary');
}
} else if (isMultipart) { // multipart (x-mixed-replace)
this._response.write("Content-Type: " + (data instanceof Buffer && (!encoding || encoding == 'binary') ? "application/octet-stream" : "text/plain") + "\nContent-Length: " + data.length + "\n\n");
this._response.write(data, encoding);
- this._response.write("\n--grappler\n");
+ retVal = this._response.write("\n--grappler\n");
} else if (isSSE) { // Server-Sent Events -- via JS or DOM
if (isSSEDOM)
this._response.write("Event: grappler-data\n");
this._response.write("data: ");
this._response.write(data, encoding);
- this._response.write("\n\n");
+ retVal = this._response.write("\n\n");
} else { // long poll
if (!this._queue)
this._queue = [];
@@ -348,6 +351,8 @@ HttpClient.prototype.write = function(data, encoding) {
}
} catch(e) {} // silently trap "stream is not writable" errors for now
+
+ return retVal;
};
HttpClient.prototype.disconnect = function() {
@@ -371,8 +376,8 @@ HttpClient.prototype.disconnect = function() {
}
try {
- this.socket.end();
- this.socket.destroy();
+ this._request.connection.end();
+ this._request.connection.destroy();
} catch (e) {}
if ((this.state & grappler.STATE.ACCEPTED) && !(this.state & grappler.STATE.TEMP))
@@ -381,15 +386,14 @@ HttpClient.prototype.disconnect = function() {
// Setting the entry in the connections map to null/undefined supposedly performs better
// than using 'delete' (leads to a "slow case"). However, this method also means you'll have an
// increasingly large map filled with empty spots. :-\
- /*if (Object.keys(this.server.connections).indexOf(this.id) > -1) {
- this.server.connections[this.id] = undefined;
+ /*if (Object.keys(this.server.connections).indexOf(this._id) > -1) {
+ this.server.connections[this._id] = undefined;
// Hide the connection from "showing" in the connections hash
- Object.defineProperty(this.server.connections, this.id, { enumerable: false });
+ Object.defineProperty(this.server.connections, this._id, { enumerable: false });
}*/
- if (this.server.connections[this.id] && ((this.state & grappler.STATE.PROTO_WEBSOCKET) || isMultipart || isSSE)) {
- exists = true;
+ if (this.server.connections[this._id] && ((this.state & grappler.STATE.PROTO_WEBSOCKET) || isMultipart || isSSE)) {
// TODO: delete on nextTick() instead?
- delete this.server.connections[this.id];
+ delete this.server.connections[this._id];
}
};
Please sign in to comment.
Something went wrong with that request. Please try again.