nitrogen
Version:
Nitrogen is a platform for building connected devices. Nitrogen provides the authentication, authorization, and real time message passing framework so that you can focus on your device and application. All with a consistent development platform that lev
495 lines (384 loc) • 15.6 kB
JavaScript
var io = require('socket.io-client')
, Message = require('./message')
, Principal = require('./principal')
, request = require('request');
/**
* A Session represents an authenticated session and subscription connection between a principal and a service.
*
* @class Session
* @namespace nitrogen
* @param {Object} service The service this session is associated with.
* @param {Object} principal The principal this session is associated with.
* @param {Object} accessToken The accessToken to use for authenticating requests with this session.
* @param {Object} socket The subscription socket to use for realtime updates.
*/
function Session(service, principal, accessToken) {
var self = this;
this.service = service;
this.principal = principal;
this.accessToken = accessToken;
this.subscriptionCount = 0;
this.sessionId = Math.floor(Math.random()*100000000);
this.heartbeatTimeout = false;
this.heartbeatReceived = true;
this.consecutiveHeartbeatFailures = 0;
this.subscriptions = {};
this.failureCallback = function() {};
Session.prototype.log = {};
Session.prototype.log.debug = function(message) { Session.log(self, "debug", message); };
Session.prototype.log.info = function(message) { Session.log(self, "info", message); };
Session.prototype.log.warn = function(message) { Session.log(self, "warn", message); };
Session.prototype.log.error = function(message) { Session.log(self, "error", message); };
this.log.debug('session: created.');
}
Session.HEARTBEAT_DEFAULT_INTERVAL = 5 * 60 * 1000; // ms
Session.MAX_SUBSCRIPTION_RESTARTS = 3;
Session.HEARTBEAT_TIMEOUT = 15 * 1000; // ms
Session.queryStringFromObject = function(obj) {
var str = [];
for(var p in obj)
str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
return str.join("&");
};
Session.startSession = function(service, principal, accessToken) {
if (!Session.sessions)
Session.sessions = {};
if (!Session.sessions[principal.id]) {
Session.sessions[principal.id] = new Session(service, principal, accessToken);
}
return Session.sessions[principal.id];
};
Session.stopSession = function(session) {
delete Session.sessions[session.principal.id];
};
/**
* Stop the session with the service.
*
* @method stop
* @private
**/
Session.prototype.stop = function() {
Session.stopSession(this);
this.failureCallback = null;
var self = this;
for (var key in this.subscriptions) {
this.disconnectSubscription(this.subscriptions[key].id);
}
if (this.socket)
this.socket.disconnect();
this.socket = null;
if (this.heartbeat)
this.heartbeat.stop();
this.heartbeat = null;
};
Session.prototype.beforeRequest = function(options) {
if (!options.headers) options.headers = {};
options.headers.Authorization = "Bearer " + this.accessToken.token;
var prefix = "?";
if (options.query) {
var queryString = JSON.stringify(options.query);
options.url += prefix + "q=" + encodeURIComponent(queryString);
delete options.query;
prefix = "&";
}
if (options.queryOptions) {
var optionsString = JSON.stringify(options.queryOptions);
options.url += prefix + "options=" + encodeURIComponent(optionsString);
delete options.queryOptions;
}
};
Session.prototype.afterRequest = function(err, resp, body, callback) {
if (resp && resp.statusCode !== 200) {
if (body && body.error)
err = body.error;
else
err = resp.statusCode;
}
if (resp && resp.statusCode === 401) this.signalFailure();
// If we get an access token update, replace the current access token and continue.
if (resp && resp.headers && resp.headers['x-n2-set-access-token']) {
this.accessToken = JSON.parse(resp.headers['x-n2-set-access-token']);
}
callback(err, resp, body);
};
Session.prototype.makeRequest = function(options, requestFunc, callback) {
var self = this;
this.beforeRequest(options);
return requestFunc(options, function(err, resp, body) {
self.afterRequest(err, resp, body, callback);
});
};
Session.prototype.get = function(options, callback) {
return this.makeRequest(options, request.get, callback);
};
Session.prototype.post = function(options, callback) {
return this.makeRequest(options, request.post, callback);
};
Session.prototype.put = function(options, callback) {
return this.makeRequest(options, request.put, callback);
};
Session.prototype.remove = function(options, callback) {
return this.makeRequest(options, request.del, callback);
};
/**
* Clear all of the credentials for a particular principal.
*
* @method clearCredentials
* @private
* @param {Object} principal The principal to clear credentials for.
**/
Session.prototype.clearCredentials = function() {
this.service.clearCredentials(this.principal);
};
/**
* Connect subscription socket for principal with this accessToken to the service.
*
* @method connectSocket
* @private
**/
Session.prototype.connectSocket = function() {
var self = this;
if (!this.principal || !this.principal.id || !this.accessToken) throw new Error('need both principal and accessToken to connectSocket');
// we can only share a socket on a per principal basis because otherwise principals can listen in
// on other principals' events.
if (!this.socket) {
this.socket = io.connect(this.service.config.endpoints.subscriptions, {
query: Session.queryStringFromObject({ auth: this.accessToken.token }),
'force new connection': true
});
this.setupSocketEvents();
self.startHeartbeat();
}
return this.socket;
};
Session.prototype.setupSocketEvents = function() {
var self = this;
//this.socket.on('connecting', function() { self.log.debug('session: socket.io connecting'); });
this.socket.on('connect', function() {
self.log.debug('session: socket.io connected');
});
this.socket.on('disconnect', function() {
self.log.debug('session: socket.io connection disconnected');
});
// this.socket.on('reconnecting', function() { self.log.info('session: socket.io connection reconnecting'); });
this.socket.on('reconnect', function() {
if (self.socket) {
self.log.warn('session: socket.io connection reconnected. restarting subscriptions: ' + JSON.stringify(self.subscriptions));
self.restartSubscriptions();
}
});
};
/**
* Disconnect the current subscription connection with the service.
*
* @method disconnectSubscription
* @private
**/
Session.prototype.disconnectSubscription = function(subscriptionId) {
if (!this.subscriptions[subscriptionId]) return;
this.socket.emit('stop', this.subscriptions[subscriptionId]);
delete this.subscriptions[subscriptionId];
};
/**
* Connect a subscription with the service.
*
* @method disconnectSubscription
* @private
**/
Session.prototype.connectSubscription = function(options) {
this.subscriptions[options.id] = options;
this.socket.emit('start', options);
};
/**
* Restart all subscriptions with the service. Used after a connection disruption.
*
* @method restartSubscriptions
* @private
**/
Session.prototype.restartSubscriptions = function() {
for (var key in this.subscriptions) {
var subscription = this.subscriptions[key];
this.log.info('restarting subscription: ' + key + ': ' + JSON.stringify(subscription));
if (this.socket) this.socket.emit('stop', this.subscriptions[subscription.id]);
this.connectSubscription(this.subscriptions[key]);
}
};
/**
* Impersonate the principal with the authorization context of this session. Used by the Nitrogen service to impersonate principals for agent setup.
*
* @method impersonate
* @private
* @param {Object} principal The principal to impersonate with this session.
* @param {Object} callback Callback for the impersonation.
* @param {Object} callback.err If the impersonation failed, this will contain the error.
* @param {Object} callback.session The session for the impersonated principal with this service.
* @param {Object} callback.principal The impersonated principal.
**/
Session.prototype.impersonate = function(principal, callback) {
this.service.impersonate(this, principal, callback);
};
// DEPRECIATED
Session.prototype.onAuthFailure = function(callback) {
this.failureCallback = callback;
};
/**
* Passed callback function will be called on session failure.
*
* @method onFailure
* @param {Object} principal The principal to impersonate with this session.
* @param {Function} callback Callback function with signature f(err).
**/
Session.prototype.onFailure = function(callback) {
this.failureCallback = callback;
};
Session.prototype.signalFailure = function() {
var failureCallback = this.failureCallback;
this.stop();
if (failureCallback) failureCallback();
};
/**
* Core subscription event method. Primarily used to subscribe for changes to principals and new messages.
*
* @method on
* @param {Object} options Options for the filter of the messages this subscription should receive: 'type': The type this subscription should receive. Only 'message' is currently supported. 'filter': The filter to apply to the objects returned from this subscription. For example { from: '51f2735fda5fcca439000001' } will restrict messages received to only those from this particular principal id.
* @param {Function} callback Callback function with signature f(objectReceived).
**/
Session.prototype.on = function(options, callback) {
if (!options) return callback("Options hash required for subscription");
if (['message', 'principal'].indexOf(options.type) === -1) return callback("Unknown subscription 'type'");
// if there is an existing socket connection already, this will be a NOP.
this.connectSocket();
this.subscriptionCount += 1;
options.id = this.principal.id + "_" + this.sessionId + "_" + this.subscriptionCount;
this.socket.on(options.id, function(obj) {
if (options.type === 'message')
return callback(new Message(obj));
if (options.type === 'principal')
return callback(new Principal(obj));
});
this.connectSubscription(options);
return options.id;
};
/**
* Syntax sugar to setup a message subscription.
*
* @method onMessage
* @param {Object} filter The filter to apply to the objects returned from this subscription. For example { from: '51f2735fda5fcca439000001' } will restrict messages received to only those from this particular principal id.
* @param {Function} callback Callback function with signature f(messageReceived).
**/
Session.prototype.onMessage = function(filter, callback) {
if (typeof filter === 'function') {
callback = filter;
}
var options = {};
options.filter = filter || {};
options.type = 'message';
return this.on(options, callback);
};
/**
* Syntax sugar to setup a principal subscription.
*
* @method onMessage
* @param {Object} filter The filter to apply to the objects returned from this subscription. For example { from: '51f2735fda5fcca439000001' } will restrict messages received to only those from this particular principal id.
* @param {Function} callback Callback function with signature f(messageReceived).
**/
Session.prototype.onPrincipal = function(filter, callback) {
if (typeof filter === 'function') {
callback = filter;
}
var options = {};
options.filter = filter || {};
options.type = 'principal';
return this.on(options, callback);
};
Session.log = function(session, severity, message) {
if (session.service.config.log_levels && session.service.config.log_levels.indexOf(severity) === -1) return;
var logLifetime = session.service.config.log_lifetime || 24 * 60 * 60000;
var logMessage = new Message({
type: 'log',
ts: new Date(),
from: session.principal.id,
body: {
severity: severity,
message: message
},
index_until: new Date(new Date().getTime() + logLifetime)
});
var principalNameOrId = (session.principal.name || session.principal.id);
var dateFormat = [logMessage.ts.getMonth()+1, logMessage.ts.getDate(), logMessage.ts.getFullYear()];
var date = dateFormat.join('/');
console.log(date + " " + logMessage.ts.toLocaleTimeString() + ": " + principalNameOrId + ': ' + severity + ": " + message);
logMessage.send(session);
};
Session.prototype.heartbeatReceivedCheck = function() {
if (!this.heartbeatReceived) {
this.consecutiveHeartbeatFailures += 1;
this.log.error('heartbeat: not received within timeout: restarting subscriptions (' + this.consecutiveHeartbeatFailures + ' consecutive).');
if (this.session && this.consecutiveHeartbeatFailures < Heartbeat.MAX_SUBSCRIPTION_RESTARTS) {
this.stopHeartbeat();
this.restartSubscriptions();
this.startHeartbeatInterval();
} else {
// too many consecutive heartbeat failures
this.log.error('heartbeat: too many consecutive failures. signalling session failure.');
this.signalFailure();
}
}
};
Session.prototype.startHeartbeat = function() {
if (this.heartbeatTimeout) return;
var self = this;
this.onMessage({ type: 'heartbeat' }, function(message) {
// the goal of this is to test realtime connectivity so we don't care whose heartbeat this is.
self.log.debug('received heartbeat');
self.heartbeatReceived = true;
self.consecutiveHeartbeatFailures = 0;
});
this.startHeartbeatInterval();
};
Session.prototype.startHeartbeatInterval = function() {
var self = this;
this.stopHeartbeat();
this.log.debug('starting heartbeat interval');
this.heartbeatTimeout = setInterval(function() {
self.sendHeartbeat();
}, Session.HEARTBEAT_DEFAULT_INTERVAL);
};
Session.prototype.sendHeartbeat = function(callback) {
var self = this;
this.principal.status(function(err, status) {
if (err) {
status = status || {};
status.error = err;
}
var heartbeatIndexUntil = self.service.config.heartbeat_lifetime || 30 * 60 * 1000;
var message = new Message({
type: 'heartbeat',
public: false,
body: {
error: !!err,
status: status
},
index_until: new Date(new Date().getTime() + heartbeatIndexUntil)
});
setTimeout(function() { self.heartbeatReceivedCheck() }, Session.HEARTBEAT_TIMEOUT);
self.heartbeatReceived = false;
self.log.debug('sending heartbeat');
message.send(self, function(err, message) {
if (err) {
self.log.error("failed to send heartbeat: " + err);
// something is far more wrong than the subscription.
self.heartbeatReceived = true;
if (self) self.signalFailure();
}
if (callback) return callback(err);
});
});
};
Session.prototype.stopHeartbeat = function() {
if (!this.heartbeatTimeout) return;
this.log.debug('clearing heartbeat interval');
clearInterval(this.heartbeatTimeout);
this.heartbeatTimeout = false;
};
module.exports = Session;