359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
/************************************************************************
|
|
* Copyright 2010-2011 Worlize Inc.
|
|
*
|
|
* 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 deprecation = require('./Deprecation');
|
|
|
|
var nodeVersion = process.version.slice(1).split('.').map(function(item) { return parseInt(item, 10); });
|
|
var isNode0_4_x = (nodeVersion[0] === 0 && nodeVersion[1] === 4);
|
|
var isGreaterThanNode0_4_x = (nodeVersion[0] > 0 || (nodeVersion[0] === 0 && nodeVersion[1] > 4));
|
|
|
|
var extend = require('./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');
|
|
|
|
const INIT = -1;
|
|
const CONNECTING = 0;
|
|
const OPEN = 1;
|
|
const CLOSING = 2;
|
|
const CLOSED = 3;
|
|
|
|
var ID_COUNTER = 0;
|
|
|
|
var protocolSeparators = [
|
|
"(", ")", "<", ">", "@",
|
|
",", ";", ":", "\\", "\"",
|
|
"/", "[", "]", "?", "=",
|
|
"{", "}", " ", String.fromCharCode(9)
|
|
];
|
|
|
|
function WebSocketClient(config) {
|
|
// 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) {
|
|
extend(this.config, config);
|
|
}
|
|
if ('websocketVersion' in this.config) {
|
|
console.dir(this.config);
|
|
deprecation.warn('websocketVersionConfig');
|
|
this.config.webSocketVersion = this.config.websocketVersion;
|
|
}
|
|
Object.defineProperty(this.config, 'websocketVersion', {
|
|
set: function(value) {
|
|
deprecation.warn('websocketVersionConfig');
|
|
this.webSocketVersion = value;
|
|
},
|
|
get: function() {
|
|
deprecation.warn('websocketVersionConfig');
|
|
return this.webSocketVersion;
|
|
}
|
|
});
|
|
|
|
switch (this.config.webSocketVersion) {
|
|
case 8:
|
|
case 13:
|
|
break;
|
|
default:
|
|
throw new Error("Requested webSocketVersion is not supported. " +
|
|
"Allowed values are 8 and 13.");
|
|
}
|
|
|
|
this.readyState = INIT;
|
|
}
|
|
|
|
util.inherits(WebSocketClient, EventEmitter);
|
|
|
|
WebSocketClient.prototype.connect = function(requestUrl, protocols, origin, headers) {
|
|
var self = this;
|
|
if (typeof(protocols) === 'string') {
|
|
protocols = [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, index, array) {
|
|
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 = {};
|
|
extend(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 extensions
|
|
|
|
var pathAndQuery = this.url.pathname;
|
|
if (this.url.search) {
|
|
pathAndQuery += this.url.search;
|
|
}
|
|
|
|
function handleRequestError(error) {
|
|
self.emit('connectFailed', error);
|
|
}
|
|
|
|
if (isNode0_4_x) {
|
|
// Using old http.createClient interface since the new Agent-based API
|
|
// is buggy in Node 0.4.x.
|
|
if (this.secure) {
|
|
throw new Error("TLS connections are not supported under Node 0.4.x. Please use 0.6.2 or newer.");
|
|
}
|
|
var client = http.createClient(this.url.port, this.url.hostname);
|
|
client.on('error', handleRequestError);
|
|
client.on('upgrade', function handleClientUpgrade(response, socket, head) {
|
|
client.removeListener('error', handleRequestError);
|
|
self.socket = socket;
|
|
self.response = response;
|
|
self.firstDataChunk = head;
|
|
self.validateHandshake();
|
|
});
|
|
var req = client.request(pathAndQuery, reqHeaders);
|
|
}
|
|
else if (isGreaterThanNode0_4_x) {
|
|
var requestOptions = {
|
|
hostname: this.url.hostname,
|
|
port: this.url.port,
|
|
method: 'GET',
|
|
path: pathAndQuery,
|
|
headers: reqHeaders,
|
|
agent: false
|
|
};
|
|
if (this.secure) {
|
|
['key','passphrase','cert','ca'].forEach(function(key) {
|
|
if (self.config.tlsOptions.hasOwnProperty(key)) {
|
|
requestOptions[key] = self.config.tlsOptions[key];
|
|
}
|
|
});
|
|
var req = https.request(requestOptions);
|
|
}
|
|
else {
|
|
var req = http.request(requestOptions);
|
|
}
|
|
req.on('upgrade', function handleRequestUpgrade(response, socket, head) {
|
|
req.removeListener('error', handleRequestError);
|
|
self.socket = socket;
|
|
self.response = response;
|
|
self.firstDataChunk = head;
|
|
self.validateHandshake();
|
|
});
|
|
req.on('error', handleRequestError);
|
|
}
|
|
else {
|
|
throw new Error("Unsupported Node version " + process.version);
|
|
}
|
|
|
|
req.on('response', function(response) {
|
|
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 extensions
|
|
|
|
this.succeedHandshake();
|
|
};
|
|
|
|
WebSocketClient.prototype.failHandshake = function(errorDescription) {
|
|
if (this.socket && this.socket.writable) {
|
|
this.socket.end();
|
|
}
|
|
this.emit('connectFailed', errorDescription);
|
|
};
|
|
|
|
WebSocketClient.prototype.succeedHandshake = function() {
|
|
var connection = new WebSocketConnection(this.socket, [], this.protocol, true, this.config);
|
|
|
|
connection.webSocketVersion = this.config.webSocketVersion;
|
|
// Deprecated websocketVersion (proper casing...)
|
|
Object.defineProperty(connection, "websocketVersion", {
|
|
get: function() {
|
|
deprecation.warn('websocketVersion');
|
|
return this.webSocketVersion;
|
|
}
|
|
});
|
|
|
|
this.emit('connect', connection);
|
|
if (this.firstDataChunk.length > 0) {
|
|
connection.handleSocketData(this.firstDataChunk);
|
|
}
|
|
this.firstDataChunk = null;
|
|
};
|
|
|
|
module.exports = WebSocketClient; |