Rev 915 | Blame | Compare with Previous | Last modification | View Log | RSS feed
/************************************************************************* 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 protocolSeparators = ['(', ')', '<', '>', '@',',', ';', ':', '\\', '\"','/', '[', ']', '?', '=','{', '}', ' ', String.fromCharCode(9)];function WebSocketClient(config) {// Superclass ConstructorEventEmitter.call(this);// TODO: Implement extensionsthis.config = {// 1MiB max frame size.maxReceivedFrameSize: 0x100000,// 8MiB max message size, only applicable if// assembleFragments is truemaxReceivedMessageSize: 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 16KiBfragmentationThreshold: 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 TLStlsOptions: {}};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 = new Buffer(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 = headers || {};extend(reqHeaders, {'Upgrade': 'websocket','Connection': 'Upgrade','Sec-WebSocket-Version': this.config.webSocketVersion.toString(10),'Sec-WebSocket-Key': this.base64nonce,'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 extensionsvar 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) {for (var key in self.config.tlsOptions) {if (self.config.tlsOptions.hasOwnProperty(key)) {requestOptions[key] = self.config.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 +'\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 extensionsthis.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;