--- /dev/null
+/************************************************************************
+ * Copyright 2010-2015 Brian McKelvey.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ ***********************************************************************/
+
+var utils = require('./utils');
+var extend = utils.extend;
+var util = require('util');
+var EventEmitter = require('events').EventEmitter;
+var http = require('http');
+var https = require('https');
+var url = require('url');
+var crypto = require('crypto');
+var WebSocketConnection = require('./WebSocketConnection');
+var bufferAllocUnsafe = utils.bufferAllocUnsafe;
+
+var protocolSeparators = [
+ '(', ')', '<', '>', '@',
+ ',', ';', ':', '\\', '\"',
+ '/', '[', ']', '?', '=',
+ '{', '}', ' ', String.fromCharCode(9)
+];
+
+var excludedTlsOptions = ['hostname','port','method','path','headers'];
+
+function WebSocketClient(config) {
+ // Superclass Constructor
+ EventEmitter.call(this);
+
+ // TODO: Implement extensions
+
+ this.config = {
+ // 1MiB max frame size.
+ maxReceivedFrameSize: 0x100000,
+
+ // 8MiB max message size, only applicable if
+ // assembleFragments is true
+ maxReceivedMessageSize: 0x800000,
+
+ // Outgoing messages larger than fragmentationThreshold will be
+ // split into multiple fragments.
+ fragmentOutgoingMessages: true,
+
+ // Outgoing frames are fragmented if they exceed this threshold.
+ // Default is 16KiB
+ fragmentationThreshold: 0x4000,
+
+ // Which version of the protocol to use for this session. This
+ // option will be removed once the protocol is finalized by the IETF
+ // It is only available to ease the transition through the
+ // intermediate draft protocol versions.
+ // At present, it only affects the name of the Origin header.
+ webSocketVersion: 13,
+
+ // If true, fragmented messages will be automatically assembled
+ // and the full message will be emitted via a 'message' event.
+ // If false, each frame will be emitted via a 'frame' event and
+ // the application will be responsible for aggregating multiple
+ // fragmented frames. Single-frame messages will emit a 'message'
+ // event in addition to the 'frame' event.
+ // Most users will want to leave this set to 'true'
+ assembleFragments: true,
+
+ // The Nagle Algorithm makes more efficient use of network resources
+ // by introducing a small delay before sending small packets so that
+ // multiple messages can be batched together before going onto the
+ // wire. This however comes at the cost of latency, so the default
+ // is to disable it. If you don't need low latency and are streaming
+ // lots of small messages, you can change this to 'false'
+ disableNagleAlgorithm: true,
+
+ // The number of milliseconds to wait after sending a close frame
+ // for an acknowledgement to come back before giving up and just
+ // closing the socket.
+ closeTimeout: 5000,
+
+ // Options to pass to https.connect if connecting via TLS
+ tlsOptions: {}
+ };
+
+ if (config) {
+ var tlsOptions;
+ if (config.tlsOptions) {
+ tlsOptions = config.tlsOptions;
+ delete config.tlsOptions;
+ }
+ else {
+ tlsOptions = {};
+ }
+ extend(this.config, config);
+ extend(this.config.tlsOptions, tlsOptions);
+ }
+
+ this._req = null;
+
+ switch (this.config.webSocketVersion) {
+ case 8:
+ case 13:
+ break;
+ default:
+ throw new Error('Requested webSocketVersion is not supported. Allowed values are 8 and 13.');
+ }
+}
+
+util.inherits(WebSocketClient, EventEmitter);
+
+WebSocketClient.prototype.connect = function(requestUrl, protocols, origin, headers, extraRequestOptions) {
+ var self = this;
+
+ if (typeof(protocols) === 'string') {
+ if (protocols.length > 0) {
+ protocols = [protocols];
+ }
+ else {
+ protocols = [];
+ }
+ }
+ if (!(protocols instanceof Array)) {
+ protocols = [];
+ }
+ this.protocols = protocols;
+ this.origin = origin;
+
+ if (typeof(requestUrl) === 'string') {
+ this.url = url.parse(requestUrl);
+ }
+ else {
+ this.url = requestUrl; // in case an already parsed url is passed in.
+ }
+ if (!this.url.protocol) {
+ throw new Error('You must specify a full WebSocket URL, including protocol.');
+ }
+ if (!this.url.host) {
+ throw new Error('You must specify a full WebSocket URL, including hostname. Relative URLs are not supported.');
+ }
+
+ this.secure = (this.url.protocol === 'wss:');
+
+ // validate protocol characters:
+ this.protocols.forEach(function(protocol) {
+ for (var i=0; i < protocol.length; i ++) {
+ var charCode = protocol.charCodeAt(i);
+ var character = protocol.charAt(i);
+ if (charCode < 0x0021 || charCode > 0x007E || protocolSeparators.indexOf(character) !== -1) {
+ throw new Error('Protocol list contains invalid character "' + String.fromCharCode(charCode) + '"');
+ }
+ }
+ });
+
+ var defaultPorts = {
+ 'ws:': '80',
+ 'wss:': '443'
+ };
+
+ if (!this.url.port) {
+ this.url.port = defaultPorts[this.url.protocol];
+ }
+
+ var nonce = bufferAllocUnsafe(16);
+ for (var i=0; i < 16; i++) {
+ nonce[i] = Math.round(Math.random()*0xFF);
+ }
+ this.base64nonce = nonce.toString('base64');
+
+ var hostHeaderValue = this.url.hostname;
+ if ((this.url.protocol === 'ws:' && this.url.port !== '80') ||
+ (this.url.protocol === 'wss:' && this.url.port !== '443')) {
+ hostHeaderValue += (':' + this.url.port);
+ }
+
+ var reqHeaders = {};
+ if (this.secure && this.config.tlsOptions.hasOwnProperty('headers')) {
+ // Allow for additional headers to be provided when connecting via HTTPS
+ extend(reqHeaders, this.config.tlsOptions.headers);
+ }
+ if (headers) {
+ // Explicitly provided headers take priority over any from tlsOptions
+ extend(reqHeaders, headers);
+ }
+ extend(reqHeaders, {
+ 'Upgrade': 'websocket',
+ 'Connection': 'Upgrade',
+ 'Sec-WebSocket-Version': this.config.webSocketVersion.toString(10),
+ 'Sec-WebSocket-Key': this.base64nonce,
+ 'Host': reqHeaders.Host || hostHeaderValue
+ });
+
+ if (this.protocols.length > 0) {
+ reqHeaders['Sec-WebSocket-Protocol'] = this.protocols.join(', ');
+ }
+ if (this.origin) {
+ if (this.config.webSocketVersion === 13) {
+ reqHeaders['Origin'] = this.origin;
+ }
+ else if (this.config.webSocketVersion === 8) {
+ reqHeaders['Sec-WebSocket-Origin'] = this.origin;
+ }
+ }
+
+ // TODO: Implement extensions
+
+ var pathAndQuery;
+ // Ensure it begins with '/'.
+ if (this.url.pathname) {
+ pathAndQuery = this.url.path;
+ }
+ else if (this.url.path) {
+ pathAndQuery = '/' + this.url.path;
+ }
+ else {
+ pathAndQuery = '/';
+ }
+
+ function handleRequestError(error) {
+ self._req = null;
+ self.emit('connectFailed', error);
+ }
+
+ var requestOptions = {
+ agent: false
+ };
+ if (extraRequestOptions) {
+ extend(requestOptions, extraRequestOptions);
+ }
+ // These options are always overridden by the library. The user is not
+ // allowed to specify these directly.
+ extend(requestOptions, {
+ hostname: this.url.hostname,
+ port: this.url.port,
+ method: 'GET',
+ path: pathAndQuery,
+ headers: reqHeaders
+ });
+ if (this.secure) {
+ var tlsOptions = this.config.tlsOptions;
+ for (var key in tlsOptions) {
+ if (tlsOptions.hasOwnProperty(key) && excludedTlsOptions.indexOf(key) === -1) {
+ requestOptions[key] = tlsOptions[key];
+ }
+ }
+ }
+
+ var req = this._req = (this.secure ? https : http).request(requestOptions);
+ req.on('upgrade', function handleRequestUpgrade(response, socket, head) {
+ self._req = null;
+ req.removeListener('error', handleRequestError);
+ self.socket = socket;
+ self.response = response;
+ self.firstDataChunk = head;
+ self.validateHandshake();
+ });
+ req.on('error', handleRequestError);
+
+ req.on('response', function(response) {
+ self._req = null;
+ if (utils.eventEmitterListenerCount(self, 'httpResponse') > 0) {
+ self.emit('httpResponse', response, self);
+ if (response.socket) {
+ response.socket.end();
+ }
+ }
+ else {
+ var headerDumpParts = [];
+ for (var headerName in response.headers) {
+ headerDumpParts.push(headerName + ': ' + response.headers[headerName]);
+ }
+ self.failHandshake(
+ 'Server responded with a non-101 status: ' +
+ response.statusCode + ' ' + response.statusMessage +
+ '\nResponse Headers Follow:\n' +
+ headerDumpParts.join('\n') + '\n'
+ );
+ }
+ });
+ req.end();
+};
+
+WebSocketClient.prototype.validateHandshake = function() {
+ var headers = this.response.headers;
+
+ if (this.protocols.length > 0) {
+ this.protocol = headers['sec-websocket-protocol'];
+ if (this.protocol) {
+ if (this.protocols.indexOf(this.protocol) === -1) {
+ this.failHandshake('Server did not respond with a requested protocol.');
+ return;
+ }
+ }
+ else {
+ this.failHandshake('Expected a Sec-WebSocket-Protocol header.');
+ return;
+ }
+ }
+
+ if (!(headers['connection'] && headers['connection'].toLocaleLowerCase() === 'upgrade')) {
+ this.failHandshake('Expected a Connection: Upgrade header from the server');
+ return;
+ }
+
+ if (!(headers['upgrade'] && headers['upgrade'].toLocaleLowerCase() === 'websocket')) {
+ this.failHandshake('Expected an Upgrade: websocket header from the server');
+ return;
+ }
+
+ var sha1 = crypto.createHash('sha1');
+ sha1.update(this.base64nonce + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
+ var expectedKey = sha1.digest('base64');
+
+ if (!headers['sec-websocket-accept']) {
+ this.failHandshake('Expected Sec-WebSocket-Accept header from server');
+ return;
+ }
+
+ if (headers['sec-websocket-accept'] !== expectedKey) {
+ this.failHandshake('Sec-WebSocket-Accept header from server didn\'t match expected value of ' + expectedKey);
+ return;
+ }
+
+ // TODO: Support extensions
+
+ this.succeedHandshake();
+};
+
+WebSocketClient.prototype.failHandshake = function(errorDescription) {
+ if (this.socket && this.socket.writable) {
+ this.socket.end();
+ }
+ this.emit('connectFailed', new Error(errorDescription));
+};
+
+WebSocketClient.prototype.succeedHandshake = function() {
+ var connection = new WebSocketConnection(this.socket, [], this.protocol, true, this.config);
+
+ connection.webSocketVersion = this.config.webSocketVersion;
+ connection._addSocketEventListeners();
+
+ this.emit('connect', connection);
+ if (this.firstDataChunk.length > 0) {
+ connection.handleSocketData(this.firstDataChunk);
+ }
+ this.firstDataChunk = null;
+};
+
+WebSocketClient.prototype.abort = function() {
+ if (this._req) {
+ this._req.abort();
+ }
+};
+
+module.exports = WebSocketClient;