WebSocketClient.js
11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
/************************************************************************
* 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 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 = 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 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) {
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 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;