halley
Version:
A bayeux client for modern browsers and node. Forked from Faye
526 lines (441 loc) • 14.3 kB
JavaScript
'use strict';
var Extensions = require('./extensions');
var BayeuxError = require('../util/errors').BayeuxError;
var TransportError = require('../util/errors').TransportError;
var Channel = require('./channel');
var ChannelSet = require('./channel-set');
var Dispatcher = require('./dispatcher');
var Promise = require('bluebird');
var debug = require('debug')('halley:client');
var StateMachineMixin = require('../mixins/statemachine-mixin');
var extend = require('../util/externals').extend;
var Events = require('../util/externals').Events;
var globalEvents = require('../util/global-events');
var Advice = require('./advice');
var Subscription = require('./subscription');
var SubscribeThenable = require('./subscribe-thenable');
var MANDATORY_CONNECTION_TYPES = ['long-polling'];
var DEFAULT_ENDPOINT = '/bayeux';
/**
* TODO: make the states/transitions look more like the official client states
* http://docs.cometd.org/reference/bayeux_operation.html#d0e9971
*/
var FSM = {
name: 'client',
initial: 'UNCONNECTED',
globalTransitions: {
disable: 'DISABLED',
disconnect: 'UNCONNECTED'
},
transitions: {
// The client is not yet connected
UNCONNECTED: {
connect: 'HANDSHAKING',
reset: 'HANDSHAKING'
},
// The client is undergoing the handshake process
HANDSHAKING: {
handshakeSuccess: 'CONNECTED',
rehandshake: 'HANDSHAKE_WAIT', // TODO:remove
error: 'HANDSHAKE_WAIT'
},
// Handshake failed, try again after interval
HANDSHAKE_WAIT: {
timeout: 'HANDSHAKING'
},
// The client is connected
CONNECTED: {
disconnect: 'DISCONNECTING',
rehandshake: 'HANDSHAKE_WAIT',
reset: 'RESETTING'
},
// The client is undergoing reset
// RESETTING is handled by the same handler as disconnect, so must
// support the same transitions (with different states)
RESETTING: {
disconnectSuccess: 'HANDSHAKING',
reset: 'HANDSHAKING',
connect: 'HANDSHAKING',
error: 'HANDSHAKING'
},
// The client is disconnecting
DISCONNECTING: {
disconnectSuccess: 'UNCONNECTED',
reset: 'HANDSHAKING',
connect: 'HANDSHAKING',
error: 'UNCONNECTED'
},
// The client has been disabled an will not reconnect
// after being sent { advice: none }
DISABLED: {
}
}
};
function validateBayeuxResponse(response) {
if (!response) {
throw new TransportError('No response received');
}
if (!response.successful) {
throw new BayeuxError(response.error);
}
return response;
}
/**
* The Halley Client
*/
function Client(endpoint, options) {
debug('New client created for %s', endpoint);
if (!options) options = {};
var advice = this._advice = new Advice(options);
debug('Initial advice: %j', this._advice);
this._extensions = new Extensions();
this._endpoint = endpoint || DEFAULT_ENDPOINT;
this._channels = new ChannelSet(this._onSubscribe.bind(this), this._onUnsubscribe.bind(this));
this._dispatcher = options.dispatcher || new Dispatcher(this._endpoint, advice, options);
this._initialConnectionTypes = options.connectionTypes || MANDATORY_CONNECTION_TYPES;
this._messageId = 0;
this._connected = false;
/**
* How many times have we failed handshaking
*/
this.initStateMachine(FSM);
this.listenTo(this._dispatcher, 'message', this._receiveMessage);
this.listenTo(advice, 'advice:handshake', function() {
return this.transitionState('rehandshake', { optional: true, dedup: true });
});
this.listenTo(advice, 'advice:none', function() {
return this.resetTransition('disable', new Error('Client disabled'));
});
this.listenTo(this._dispatcher, 'transport:up transport:down', function() {
this._updateConnectionState();
});
}
Client.prototype = {
addExtension: function(extension) {
this._extensions.add(extension);
},
removeExtension: function(extension) {
this._extensions.remove(extension);
},
handshake: function() {
debug('handshake');
return this.transitionState('connect', { optional: true });
},
/**
* Wait for the client to connect
*/
connect: Promise.method(function() {
if (this.stateIs('DISABLED')) throw new Error('Client disabled');
if (this.stateIs('CONNECTED')) return;
return this.transitionState('connect', { optional: true });
}),
disconnect: Promise.method(function() {
if (this.stateIs('DISABLED')) return;
if (this.stateIs('UNCONNECTED')) return;
return this.resetTransition('disconnect', new Error('Client disconnected'), { dedup: true });
}),
/**
* Returns a thenable of a subscription
*/
subscribe: function(channelName, onMessage, context, onSubscribe) {
var subscription = new Subscription(this._channels, channelName, onMessage, context, onSubscribe);
var subscribePromise = this._channels.subscribe(channelName, subscription);
return new SubscribeThenable(subscribePromise);
},
/**
* Publish a message
* @return {Promise} A promise of the response
*/
publish: function(channel, data, options) {
debug('publish: channel=%s, data=%j', channel, data);
return this.connect()
.bind(this)
.then(function() {
return this._sendMessage({
channel: channel,
data: data
}, options);
})
.then(validateBayeuxResponse);
},
/**
* Resets the client and resubscribes to existing channels
* This can be used when the client is in an inconsistent state
*/
reset: function() {
debug('reset');
return this.transitionState('reset', { optional: true });
},
/**
* Returns the clientId or null
*/
getClientId: function() {
return this._dispatcher.clientId;
},
listChannels: function() {
return this._channels.getKeys();
},
_onSubscribe: function(channel) {
return this.connect()
.bind(this)
.then(function() {
return this._sendMessage({
channel: Channel.SUBSCRIBE,
subscription: channel
});
})
.then(validateBayeuxResponse);
},
_onUnsubscribe: function(channel) {
return this.connect()
.bind(this)
.then(function() {
return this._sendMessage({
channel: Channel.UNSUBSCRIBE,
subscription: channel
});
})
.then(validateBayeuxResponse);
},
_updateConnectionState: function() {
// The client is connected when the state
// of the client is CONNECTED and the
// transport is up
var isConnected = this.stateIs('CONNECTED') && this._dispatcher.isTransportUp();
if (this._connected === isConnected) return;
this._connected = isConnected;
debug('Connection state changed to %s', isConnected ? 'up' : 'down');
this.trigger(isConnected ? 'connection:up' : 'connection:down');
},
onStateChange: function() {
this._updateConnectionState();
},
/**
* The client must issue a handshake with the server
*/
_onEnterHANDSHAKING: function() {
this._dispatcher.clientId = null;
return this._dispatcher.selectTransport(this._initialConnectionTypes)
.bind(this)
.then(function() {
return this._sendMessage({
channel: Channel.HANDSHAKE
}, {
attempts: 1 // Note: only try once
});
})
.then(function(response) {
validateBayeuxResponse(response);
this._dispatcher.clientId = response.clientId;
var supportedConnectionTypes = this._supportedTypes = response.supportedConnectionTypes;
debug('Handshake successful: %s', this._dispatcher.clientId);
return this._dispatcher.selectTransport(supportedConnectionTypes, true);
})
.return('handshakeSuccess');
},
/**
* Handshake has failed. Waits `interval` ms then
* attempts another handshake
*/
_onEnterHANDSHAKE_WAIT: function() {
this._advice.handshakeFailed();
var delay = this._advice.getHandshakeInterval();
debug('Waiting %sms before rehandshaking', delay);
return Promise.delay(delay)
.return('timeout');
},
/**
* The client has connected. It needs to send out regular connect
* messages.
*/
_onEnterCONNECTED: function() {
this.trigger('connected');
// Fire a disconnect when the user navigates away
this.listenTo(globalEvents, 'beforeunload', this.disconnect);
/* Handshake success, reset count */
this._advice.handshakeSuccess();
this._sendConnect();
this._resubscribeAll() // Not chained
.catch(function(err) {
debug('resubscribe all failed on connect: %s', err);
})
.done();
return Promise.resolve();
},
/**
* Stop sending connect messages
*/
_onLeaveCONNECTED: function() {
if (this._connect) {
debug('Cancelling pending connect request');
this._connect.cancel();
this._connect = null;
}
// Fire a disconnect when the user navigates away
this.stopListening(globalEvents);
},
/**
* The client will attempt a disconnect and will
* transition back to the HANDSHAKING state
*/
_onEnterRESETTING: function() {
debug('Resetting %s', this._dispatcher.clientId);
return this._onEnterDISCONNECTING();
},
/**
* The client is disconnecting, or resetting.
*/
_onEnterDISCONNECTING: function() {
debug('Disconnecting %s', this._dispatcher.clientId);
return this._sendMessage({
channel: Channel.DISCONNECT
}, {
attempts: 1,
timeout: this._advice.getDisconnectTimeout()
})
.bind(this)
.then(validateBayeuxResponse)
.return('disconnectSuccess')
.finally(function() {
this._dispatcher.close();
this.trigger('disconnect');
});
},
/**
* The client is no longer connected.
*/
_onEnterUNCONNECTED: function() {
this._dispatcher.clientId = null;
this._dispatcher.close();
debug('Clearing channel listeners for %s', this._dispatcher.clientId);
this._channels.reset();
},
/**
* The server has told the client to go away and
* don't come back
*/
_onEnterDISABLED: function() {
this._onEnterUNCONNECTED();
this.trigger('disabled');
},
/**
* Use to resubscribe all previously subscribed channels
* after re-handshaking
*/
_resubscribeAll: Promise.method(function() {
var channels = this._channels;
var channelNames = channels.getKeys();
return Promise.map(channelNames, function(channelName) {
debug('Client attempting to resubscribe to %s', channelName);
var channel = channels.get(channelName);
return this._sendMessage({
channel: Channel.SUBSCRIBE,
subscription: channelName
})
.then(validateBayeuxResponse)
.tap(function(response) {
channel.subscribeSuccess(response);
});
}.bind(this));
}),
/**
* Send a request message to the server, to which a reply should
* be received.
*
* @return Promise of response
*/
_sendMessage: function(message, options) {
message.id = this._generateMessageId();
return this._extensions.pipe('outgoing', message)
.bind(this)
.then(function(message) {
if (!message) return;
return this._dispatcher.sendMessage(message, options)
.bind(this)
.then(function(response) {
return this._extensions.pipe('incoming', response);
});
});
},
/**
* Event handler for when a message has been received through a channel
* as opposed to as the result of a request.
*/
_receiveMessage: function(message) {
this._extensions.pipe('incoming', message)
.bind(this)
.then(function(message) {
if (!message) return;
if (!message || !message.channel || message.data === undefined) return;
this._channels.distributeMessage(message);
return null;
})
.done();
},
/**
* Generate a unique messageid
*/
_generateMessageId: function() {
this._messageId += 1;
if (this._messageId >= Math.pow(2, 32)) this._messageId = 0;
return this._messageId.toString(36);
},
/**
* Periodically fire a connect message with `interval` ms between sends
* Ensures that multiple connect messages are not fired simultaneously.
*
* From the docs:
* The client MUST maintain only a single outstanding connect message.
* If the server does not have a current outstanding connect and a connect
* is not received within a configured timeout, then the server
* SHOULD act as if a disconnect message has been received.
*/
_sendConnect: function() {
if (this._connect) {
debug('Cancelling pending connect request');
this._connect.cancel();
this._connect = null;
}
this._connect = this._sendMessage({
channel: Channel.CONNECT
}, {
timeout: this._advice.getConnectResponseTimeout()
})
.bind(this)
.then(validateBayeuxResponse)
.catch(function(err) {
debug('Connect failed: %s', err);
})
.finally(function() {
this._connect = null;
// If we're no longer connected so don't re-issue the
// connect again
if (!this.stateIs('CONNECTED')) {
return null;
}
var interval = this._advice.interval;
debug('Will connect after interval: %sms', interval);
this._connect = Promise.delay(interval)
.bind(this)
.then(function() {
this._connect = null;
// No longer connected after the interval, don't re-issue
if (!this.stateIs('CONNECTED')) {
return;
}
/* Do not chain this */
this._sendConnect();
// Return an empty promise to stop
// bluebird from raising warnings
return Promise.resolve();
});
// Return an empty promise to stop
// bluebird from raising warnings
return Promise.resolve();
});
}
};
/* Mixins */
extend(Client.prototype, Events);
extend(Client.prototype, StateMachineMixin);
module.exports = Client;