39c8b14f |
1 | /************************************************************************ |
2 | * Copyright 2010-2011 Worlize Inc. |
3 | * |
4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
5 | * you may not use this file except in compliance with the License. |
6 | * You may obtain a copy of the License at |
7 | * |
8 | * http://www.apache.org/licenses/LICENSE-2.0 |
9 | * |
10 | * Unless required by applicable law or agreed to in writing, software |
11 | * distributed under the License is distributed on an "AS IS" BASIS, |
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
13 | * See the License for the specific language governing permissions and |
14 | * limitations under the License. |
15 | ***********************************************************************/ |
16 | |
17 | var nodeVersion = process.version.slice(1).split('.').map(function(item) { return parseInt(item, 10); }); |
18 | var isNode0_4_x = (nodeVersion[0] === 0 && nodeVersion[1] === 4); |
19 | var isGreaterThanNode0_4_x = (nodeVersion[0] > 0 || (nodeVersion[0] === 0 && nodeVersion[1] > 4)); |
20 | |
21 | var extend = require('./utils').extend; |
22 | var util = require('util'); |
23 | var EventEmitter = require('events').EventEmitter; |
24 | var http = require('http'); |
25 | var https = require('https'); |
26 | var url = require('url'); |
27 | var crypto = require('crypto'); |
28 | var WebSocketConnection = require('./WebSocketConnection'); |
29 | |
30 | const INIT = -1; |
31 | const CONNECTING = 0; |
32 | const OPEN = 1; |
33 | const CLOSING = 2; |
34 | const CLOSED = 3; |
35 | |
36 | var ID_COUNTER = 0; |
37 | |
38 | var protocolSeparators = [ |
39 | "(", ")", "<", ">", "@", |
40 | ",", ";", ":", "\\", "\"", |
41 | "/", "[", "]", "?", "=", |
42 | "{", "}", " ", String.fromCharCode(9) |
43 | ]; |
44 | |
45 | function WebSocketClient(config) { |
46 | // TODO: Implement extensions |
47 | |
48 | this.config = { |
49 | // 1MiB max frame size. |
50 | maxReceivedFrameSize: 0x100000, |
51 | |
52 | // 8MiB max message size, only applicable if |
53 | // assembleFragments is true |
54 | maxReceivedMessageSize: 0x800000, |
55 | |
56 | // Outgoing messages larger than fragmentationThreshold will be |
57 | // split into multiple fragments. |
58 | fragmentOutgoingMessages: true, |
59 | |
60 | // Outgoing frames are fragmented if they exceed this threshold. |
61 | // Default is 16KiB |
62 | fragmentationThreshold: 0x4000, |
63 | |
64 | // Which version of the protocol to use for this session. This |
65 | // option will be removed once the protocol is finalized by the IETF |
66 | // It is only available to ease the transition through the |
67 | // intermediate draft protocol versions. |
68 | // At present, it only affects the name of the Origin header. |
69 | websocketVersion: 13, |
70 | |
71 | // If true, fragmented messages will be automatically assembled |
72 | // and the full message will be emitted via a 'message' event. |
73 | // If false, each frame will be emitted via a 'frame' event and |
74 | // the application will be responsible for aggregating multiple |
75 | // fragmented frames. Single-frame messages will emit a 'message' |
76 | // event in addition to the 'frame' event. |
77 | // Most users will want to leave this set to 'true' |
78 | assembleFragments: true, |
79 | |
80 | // The Nagle Algorithm makes more efficient use of network resources |
81 | // by introducing a small delay before sending small packets so that |
82 | // multiple messages can be batched together before going onto the |
83 | // wire. This however comes at the cost of latency, so the default |
84 | // is to disable it. If you don't need low latency and are streaming |
85 | // lots of small messages, you can change this to 'false' |
86 | disableNagleAlgorithm: true, |
87 | |
88 | // The number of milliseconds to wait after sending a close frame |
89 | // for an acknowledgement to come back before giving up and just |
90 | // closing the socket. |
91 | closeTimeout: 5000, |
92 | |
93 | // Options to pass to https.connect if connecting via TLS |
94 | tlsOptions: {} |
95 | }; |
96 | if (config) { |
97 | extend(this.config, config); |
98 | } |
99 | |
100 | switch (this.config.websocketVersion) { |
101 | case 8: |
102 | case 13: |
103 | break; |
104 | default: |
105 | throw new Error("Requested websocketVersion is not supported. " + |
106 | "Allowed values are 8 and 13."); |
107 | } |
108 | |
109 | this.readyState = INIT; |
110 | } |
111 | |
112 | util.inherits(WebSocketClient, EventEmitter); |
113 | |
114 | WebSocketClient.prototype.connect = function(requestUrl, protocols, origin, headers) { |
115 | var self = this; |
116 | if (typeof(protocols) === 'string') { |
117 | protocols = [protocols]; |
118 | } |
119 | if (!(protocols instanceof Array)) { |
120 | protocols = []; |
121 | } |
122 | this.protocols = protocols; |
123 | this.origin = origin; |
124 | |
125 | if (typeof(requestUrl) === 'string') { |
126 | this.url = url.parse(requestUrl); |
127 | } |
128 | else { |
129 | this.url = requestUrl; // in case an already parsed url is passed in. |
130 | } |
131 | if (!this.url.protocol) { |
132 | throw new Error("You must specify a full WebSocket URL, including protocol."); |
133 | } |
134 | if (!this.url.host) { |
135 | throw new Error("You must specify a full WebSocket URL, including hostname. Relative URLs are not supported."); |
136 | } |
137 | |
138 | this.secure = (this.url.protocol === 'wss:'); |
139 | |
140 | // validate protocol characters: |
141 | this.protocols.forEach(function(protocol, index, array) { |
142 | for (var i=0; i < protocol.length; i ++) { |
143 | var charCode = protocol.charCodeAt(i); |
144 | var character = protocol.charAt(i); |
145 | if (charCode < 0x0021 || charCode > 0x007E || protocolSeparators.indexOf(character) !== -1) { |
146 | throw new Error("Protocol list contains invalid character '" + String.fromCharCode(charCode) + "'"); |
147 | } |
148 | } |
149 | }); |
150 | |
151 | var defaultPorts = { |
152 | 'ws:': '80', |
153 | 'wss:': '443' |
154 | }; |
155 | |
156 | if (!this.url.port) { |
157 | this.url.port = defaultPorts[this.url.protocol]; |
158 | } |
159 | |
160 | var nonce = new Buffer(16); |
161 | for (var i=0; i < 16; i++) { |
162 | nonce[i] = Math.round(Math.random()*0xFF); |
163 | } |
164 | this.base64nonce = nonce.toString('base64'); |
165 | |
166 | var hostHeaderValue = this.url.hostname; |
167 | if ((this.url.protocol === 'ws:' && this.url.port !== '80') || |
168 | (this.url.protocol === 'wss:' && this.url.port !== '443')) { |
169 | hostHeaderValue += (":" + this.url.port) |
170 | } |
171 | |
172 | var reqHeaders = {}; |
173 | extend(reqHeaders, headers || {}); |
174 | extend(reqHeaders, { |
175 | 'Upgrade': 'websocket', |
176 | 'Connection': 'Upgrade', |
177 | 'Sec-WebSocket-Version': this.config.websocketVersion.toString(10), |
178 | 'Sec-WebSocket-Key': this.base64nonce, |
179 | 'Host': hostHeaderValue |
180 | }); |
181 | |
182 | if (this.protocols.length > 0) { |
183 | reqHeaders['Sec-WebSocket-Protocol'] = this.protocols.join(', '); |
184 | } |
185 | if (this.origin) { |
186 | if (this.config.websocketVersion === 13) { |
187 | reqHeaders['Origin'] = this.origin; |
188 | } |
189 | else if (this.config.websocketVersion === 8) { |
190 | reqHeaders['Sec-WebSocket-Origin'] = this.origin; |
191 | } |
192 | } |
193 | |
194 | // TODO: Implement extensions |
195 | |
196 | var pathAndQuery = this.url.pathname; |
197 | if (this.url.search) { |
198 | pathAndQuery += this.url.search; |
199 | } |
200 | |
201 | function handleRequestError(error) { |
202 | self.emit('connectFailed', error); |
203 | } |
204 | |
205 | if (isNode0_4_x) { |
206 | // Using old http.createClient interface since the new Agent-based API |
207 | // is buggy in Node 0.4.x. |
208 | if (this.secure) { |
209 | throw new Error("TLS connections are not supported under Node 0.4.x. Please use 0.6.2 or newer."); |
210 | } |
211 | var client = http.createClient(this.url.port, this.url.hostname); |
212 | client.on('error', handleRequestError); |
213 | client.on('upgrade', function handleClientUpgrade(response, socket, head) { |
214 | client.removeListener('error', handleRequestError); |
215 | self.socket = socket; |
216 | self.response = response; |
217 | self.firstDataChunk = head; |
218 | self.validateHandshake(); |
219 | }); |
220 | var req = client.request(pathAndQuery, reqHeaders); |
221 | } |
222 | else if (isGreaterThanNode0_4_x) { |
223 | var requestOptions = { |
224 | hostname: this.url.hostname, |
225 | port: this.url.port, |
226 | method: 'GET', |
227 | path: pathAndQuery, |
228 | headers: reqHeaders, |
229 | agent: false |
230 | }; |
231 | if (this.secure) { |
232 | ['key','passphrase','cert','ca'].forEach(function(key) { |
233 | if (self.config.tlsOptions.hasOwnProperty(key)) { |
234 | requestOptions[key] = self.config.tlsOptions[key]; |
235 | } |
236 | }); |
237 | var req = https.request(requestOptions); |
238 | } |
239 | else { |
240 | var req = http.request(requestOptions); |
241 | } |
242 | req.on('upgrade', function handleRequestUpgrade(response, socket, head) { |
243 | req.removeListener('error', handleRequestError); |
244 | self.socket = socket; |
245 | self.response = response; |
246 | self.firstDataChunk = head; |
247 | self.validateHandshake(); |
248 | }); |
249 | req.on('error', handleRequestError); |
250 | } |
251 | else { |
252 | throw new Error("Unsupported Node version " + process.version); |
253 | } |
254 | |
255 | req.on('response', function(response) { |
256 | self.failHandshake("Server responded with a non-101 status: " + response.statusCode); |
257 | }); |
258 | req.end(); |
259 | }; |
260 | |
261 | WebSocketClient.prototype.validateHandshake = function() { |
262 | var headers = this.response.headers; |
263 | |
264 | if (this.protocols.length > 0) { |
265 | this.protocol = headers['sec-websocket-protocol']; |
266 | if (this.protocol) { |
267 | if (this.protocols.indexOf(this.protocol) === -1) { |
268 | this.failHandshake("Server did not respond with a requested protocol."); |
269 | return; |
270 | } |
271 | } |
272 | else { |
273 | this.failHandshake("Expected a Sec-WebSocket-Protocol header."); |
274 | return; |
275 | } |
276 | } |
277 | |
278 | if (!(headers['connection'] && headers['connection'].toLocaleLowerCase() === 'upgrade')) { |
279 | this.failHandshake("Expected a Connection: Upgrade header from the server"); |
280 | return; |
281 | } |
282 | |
283 | if (!(headers['upgrade'] && headers['upgrade'].toLocaleLowerCase() === 'websocket')) { |
284 | this.failHandshake("Expected an Upgrade: websocket header from the server"); |
285 | return; |
286 | } |
287 | |
288 | var sha1 = crypto.createHash('sha1'); |
289 | sha1.update(this.base64nonce + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); |
290 | var expectedKey = sha1.digest('base64'); |
291 | |
292 | if (!headers['sec-websocket-accept']) { |
293 | this.failHandshake("Expected Sec-WebSocket-Accept header from server"); |
294 | return; |
295 | } |
296 | |
297 | if (!(headers['sec-websocket-accept'] === expectedKey)) { |
298 | this.failHandshake("Sec-WebSocket-Accept header from server didn't match expected value of " + expectedKey); |
299 | return; |
300 | } |
301 | |
302 | // TODO: Support extensions |
303 | |
304 | this.succeedHandshake(); |
305 | }; |
306 | |
307 | WebSocketClient.prototype.failHandshake = function(errorDescription) { |
308 | if (this.socket && this.socket.writable) { |
309 | this.socket.end(); |
310 | } |
311 | this.emit('connectFailed', errorDescription); |
312 | }; |
313 | |
314 | WebSocketClient.prototype.succeedHandshake = function() { |
315 | var connection = new WebSocketConnection(this.socket, [], this.protocol, true, this.config); |
316 | connection.websocketVersion = this.config.websocketVersion; |
317 | |
318 | this.emit('connect', connection); |
319 | if (this.firstDataChunk.length > 0) { |
320 | connection.handleSocketData(this.firstDataChunk); |
321 | this.firstDataChunk = null; |
322 | } |
323 | }; |
324 | |
325 | module.exports = WebSocketClient; |