1 | 'use strict';
|
---|
2 |
|
---|
3 | require('./shims');
|
---|
4 |
|
---|
5 | var URL = require('url-parse')
|
---|
6 | , inherits = require('inherits')
|
---|
7 | , JSON3 = require('json3')
|
---|
8 | , random = require('./utils/random')
|
---|
9 | , escape = require('./utils/escape')
|
---|
10 | , urlUtils = require('./utils/url')
|
---|
11 | , eventUtils = require('./utils/event')
|
---|
12 | , transport = require('./utils/transport')
|
---|
13 | , objectUtils = require('./utils/object')
|
---|
14 | , browser = require('./utils/browser')
|
---|
15 | , log = require('./utils/log')
|
---|
16 | , Event = require('./event/event')
|
---|
17 | , EventTarget = require('./event/eventtarget')
|
---|
18 | , loc = require('./location')
|
---|
19 | , CloseEvent = require('./event/close')
|
---|
20 | , TransportMessageEvent = require('./event/trans-message')
|
---|
21 | , InfoReceiver = require('./info-receiver')
|
---|
22 | ;
|
---|
23 |
|
---|
24 | var debug = function() {};
|
---|
25 | if (process.env.NODE_ENV !== 'production') {
|
---|
26 | debug = require('debug')('sockjs-client:main');
|
---|
27 | }
|
---|
28 |
|
---|
29 | var transports;
|
---|
30 |
|
---|
31 | // follow constructor steps defined at http://dev.w3.org/html5/websockets/#the-websocket-interface
|
---|
32 | function SockJS(url, protocols, options) {
|
---|
33 | if (!(this instanceof SockJS)) {
|
---|
34 | return new SockJS(url, protocols, options);
|
---|
35 | }
|
---|
36 | if (arguments.length < 1) {
|
---|
37 | throw new TypeError("Failed to construct 'SockJS: 1 argument required, but only 0 present");
|
---|
38 | }
|
---|
39 | EventTarget.call(this);
|
---|
40 |
|
---|
41 | this.readyState = SockJS.CONNECTING;
|
---|
42 | this.extensions = '';
|
---|
43 | this.protocol = '';
|
---|
44 |
|
---|
45 | // non-standard extension
|
---|
46 | options = options || {};
|
---|
47 | if (options.protocols_whitelist) {
|
---|
48 | log.warn("'protocols_whitelist' is DEPRECATED. Use 'transports' instead.");
|
---|
49 | }
|
---|
50 | this._transportsWhitelist = options.transports;
|
---|
51 | this._transportOptions = options.transportOptions || {};
|
---|
52 | this._timeout = options.timeout || 0;
|
---|
53 |
|
---|
54 | var sessionId = options.sessionId || 8;
|
---|
55 | if (typeof sessionId === 'function') {
|
---|
56 | this._generateSessionId = sessionId;
|
---|
57 | } else if (typeof sessionId === 'number') {
|
---|
58 | this._generateSessionId = function() {
|
---|
59 | return random.string(sessionId);
|
---|
60 | };
|
---|
61 | } else {
|
---|
62 | throw new TypeError('If sessionId is used in the options, it needs to be a number or a function.');
|
---|
63 | }
|
---|
64 |
|
---|
65 | this._server = options.server || random.numberString(1000);
|
---|
66 |
|
---|
67 | // Step 1 of WS spec - parse and validate the url. Issue #8
|
---|
68 | var parsedUrl = new URL(url);
|
---|
69 | if (!parsedUrl.host || !parsedUrl.protocol) {
|
---|
70 | throw new SyntaxError("The URL '" + url + "' is invalid");
|
---|
71 | } else if (parsedUrl.hash) {
|
---|
72 | throw new SyntaxError('The URL must not contain a fragment');
|
---|
73 | } else if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
---|
74 | throw new SyntaxError("The URL's scheme must be either 'http:' or 'https:'. '" + parsedUrl.protocol + "' is not allowed.");
|
---|
75 | }
|
---|
76 |
|
---|
77 | var secure = parsedUrl.protocol === 'https:';
|
---|
78 | // Step 2 - don't allow secure origin with an insecure protocol
|
---|
79 | if (loc.protocol === 'https:' && !secure) {
|
---|
80 | // exception is 127.0.0.0/8 and ::1 urls
|
---|
81 | if (!urlUtils.isLoopbackAddr(parsedUrl.hostname)) {
|
---|
82 | throw new Error('SecurityError: An insecure SockJS connection may not be initiated from a page loaded over HTTPS');
|
---|
83 | }
|
---|
84 | }
|
---|
85 |
|
---|
86 | // Step 3 - check port access - no need here
|
---|
87 | // Step 4 - parse protocols argument
|
---|
88 | if (!protocols) {
|
---|
89 | protocols = [];
|
---|
90 | } else if (!Array.isArray(protocols)) {
|
---|
91 | protocols = [protocols];
|
---|
92 | }
|
---|
93 |
|
---|
94 | // Step 5 - check protocols argument
|
---|
95 | var sortedProtocols = protocols.sort();
|
---|
96 | sortedProtocols.forEach(function(proto, i) {
|
---|
97 | if (!proto) {
|
---|
98 | throw new SyntaxError("The protocols entry '" + proto + "' is invalid.");
|
---|
99 | }
|
---|
100 | if (i < (sortedProtocols.length - 1) && proto === sortedProtocols[i + 1]) {
|
---|
101 | throw new SyntaxError("The protocols entry '" + proto + "' is duplicated.");
|
---|
102 | }
|
---|
103 | });
|
---|
104 |
|
---|
105 | // Step 6 - convert origin
|
---|
106 | var o = urlUtils.getOrigin(loc.href);
|
---|
107 | this._origin = o ? o.toLowerCase() : null;
|
---|
108 |
|
---|
109 | // remove the trailing slash
|
---|
110 | parsedUrl.set('pathname', parsedUrl.pathname.replace(/\/+$/, ''));
|
---|
111 |
|
---|
112 | // store the sanitized url
|
---|
113 | this.url = parsedUrl.href;
|
---|
114 | debug('using url', this.url);
|
---|
115 |
|
---|
116 | // Step 7 - start connection in background
|
---|
117 | // obtain server info
|
---|
118 | // http://sockjs.github.io/sockjs-protocol/sockjs-protocol-0.3.3.html#section-26
|
---|
119 | this._urlInfo = {
|
---|
120 | nullOrigin: !browser.hasDomain()
|
---|
121 | , sameOrigin: urlUtils.isOriginEqual(this.url, loc.href)
|
---|
122 | , sameScheme: urlUtils.isSchemeEqual(this.url, loc.href)
|
---|
123 | };
|
---|
124 |
|
---|
125 | this._ir = new InfoReceiver(this.url, this._urlInfo);
|
---|
126 | this._ir.once('finish', this._receiveInfo.bind(this));
|
---|
127 | }
|
---|
128 |
|
---|
129 | inherits(SockJS, EventTarget);
|
---|
130 |
|
---|
131 | function userSetCode(code) {
|
---|
132 | return code === 1000 || (code >= 3000 && code <= 4999);
|
---|
133 | }
|
---|
134 |
|
---|
135 | SockJS.prototype.close = function(code, reason) {
|
---|
136 | // Step 1
|
---|
137 | if (code && !userSetCode(code)) {
|
---|
138 | throw new Error('InvalidAccessError: Invalid code');
|
---|
139 | }
|
---|
140 | // Step 2.4 states the max is 123 bytes, but we are just checking length
|
---|
141 | if (reason && reason.length > 123) {
|
---|
142 | throw new SyntaxError('reason argument has an invalid length');
|
---|
143 | }
|
---|
144 |
|
---|
145 | // Step 3.1
|
---|
146 | if (this.readyState === SockJS.CLOSING || this.readyState === SockJS.CLOSED) {
|
---|
147 | return;
|
---|
148 | }
|
---|
149 |
|
---|
150 | // TODO look at docs to determine how to set this
|
---|
151 | var wasClean = true;
|
---|
152 | this._close(code || 1000, reason || 'Normal closure', wasClean);
|
---|
153 | };
|
---|
154 |
|
---|
155 | SockJS.prototype.send = function(data) {
|
---|
156 | // #13 - convert anything non-string to string
|
---|
157 | // TODO this currently turns objects into [object Object]
|
---|
158 | if (typeof data !== 'string') {
|
---|
159 | data = '' + data;
|
---|
160 | }
|
---|
161 | if (this.readyState === SockJS.CONNECTING) {
|
---|
162 | throw new Error('InvalidStateError: The connection has not been established yet');
|
---|
163 | }
|
---|
164 | if (this.readyState !== SockJS.OPEN) {
|
---|
165 | return;
|
---|
166 | }
|
---|
167 | this._transport.send(escape.quote(data));
|
---|
168 | };
|
---|
169 |
|
---|
170 | SockJS.version = require('./version');
|
---|
171 |
|
---|
172 | SockJS.CONNECTING = 0;
|
---|
173 | SockJS.OPEN = 1;
|
---|
174 | SockJS.CLOSING = 2;
|
---|
175 | SockJS.CLOSED = 3;
|
---|
176 |
|
---|
177 | SockJS.prototype._receiveInfo = function(info, rtt) {
|
---|
178 | debug('_receiveInfo', rtt);
|
---|
179 | this._ir = null;
|
---|
180 | if (!info) {
|
---|
181 | this._close(1002, 'Cannot connect to server');
|
---|
182 | return;
|
---|
183 | }
|
---|
184 |
|
---|
185 | // establish a round-trip timeout (RTO) based on the
|
---|
186 | // round-trip time (RTT)
|
---|
187 | this._rto = this.countRTO(rtt);
|
---|
188 | // allow server to override url used for the actual transport
|
---|
189 | this._transUrl = info.base_url ? info.base_url : this.url;
|
---|
190 | info = objectUtils.extend(info, this._urlInfo);
|
---|
191 | debug('info', info);
|
---|
192 | // determine list of desired and supported transports
|
---|
193 | var enabledTransports = transports.filterToEnabled(this._transportsWhitelist, info);
|
---|
194 | this._transports = enabledTransports.main;
|
---|
195 | debug(this._transports.length + ' enabled transports');
|
---|
196 |
|
---|
197 | this._connect();
|
---|
198 | };
|
---|
199 |
|
---|
200 | SockJS.prototype._connect = function() {
|
---|
201 | for (var Transport = this._transports.shift(); Transport; Transport = this._transports.shift()) {
|
---|
202 | debug('attempt', Transport.transportName);
|
---|
203 | if (Transport.needBody) {
|
---|
204 | if (!global.document.body ||
|
---|
205 | (typeof global.document.readyState !== 'undefined' &&
|
---|
206 | global.document.readyState !== 'complete' &&
|
---|
207 | global.document.readyState !== 'interactive')) {
|
---|
208 | debug('waiting for body');
|
---|
209 | this._transports.unshift(Transport);
|
---|
210 | eventUtils.attachEvent('load', this._connect.bind(this));
|
---|
211 | return;
|
---|
212 | }
|
---|
213 | }
|
---|
214 |
|
---|
215 | // calculate timeout based on RTO and round trips. Default to 5s
|
---|
216 | var timeoutMs = Math.max(this._timeout, (this._rto * Transport.roundTrips) || 5000);
|
---|
217 | this._transportTimeoutId = setTimeout(this._transportTimeout.bind(this), timeoutMs);
|
---|
218 | debug('using timeout', timeoutMs);
|
---|
219 |
|
---|
220 | var transportUrl = urlUtils.addPath(this._transUrl, '/' + this._server + '/' + this._generateSessionId());
|
---|
221 | var options = this._transportOptions[Transport.transportName];
|
---|
222 | debug('transport url', transportUrl);
|
---|
223 | var transportObj = new Transport(transportUrl, this._transUrl, options);
|
---|
224 | transportObj.on('message', this._transportMessage.bind(this));
|
---|
225 | transportObj.once('close', this._transportClose.bind(this));
|
---|
226 | transportObj.transportName = Transport.transportName;
|
---|
227 | this._transport = transportObj;
|
---|
228 |
|
---|
229 | return;
|
---|
230 | }
|
---|
231 | this._close(2000, 'All transports failed', false);
|
---|
232 | };
|
---|
233 |
|
---|
234 | SockJS.prototype._transportTimeout = function() {
|
---|
235 | debug('_transportTimeout');
|
---|
236 | if (this.readyState === SockJS.CONNECTING) {
|
---|
237 | if (this._transport) {
|
---|
238 | this._transport.close();
|
---|
239 | }
|
---|
240 |
|
---|
241 | this._transportClose(2007, 'Transport timed out');
|
---|
242 | }
|
---|
243 | };
|
---|
244 |
|
---|
245 | SockJS.prototype._transportMessage = function(msg) {
|
---|
246 | debug('_transportMessage', msg);
|
---|
247 | var self = this
|
---|
248 | , type = msg.slice(0, 1)
|
---|
249 | , content = msg.slice(1)
|
---|
250 | , payload
|
---|
251 | ;
|
---|
252 |
|
---|
253 | // first check for messages that don't need a payload
|
---|
254 | switch (type) {
|
---|
255 | case 'o':
|
---|
256 | this._open();
|
---|
257 | return;
|
---|
258 | case 'h':
|
---|
259 | this.dispatchEvent(new Event('heartbeat'));
|
---|
260 | debug('heartbeat', this.transport);
|
---|
261 | return;
|
---|
262 | }
|
---|
263 |
|
---|
264 | if (content) {
|
---|
265 | try {
|
---|
266 | payload = JSON3.parse(content);
|
---|
267 | } catch (e) {
|
---|
268 | debug('bad json', content);
|
---|
269 | }
|
---|
270 | }
|
---|
271 |
|
---|
272 | if (typeof payload === 'undefined') {
|
---|
273 | debug('empty payload', content);
|
---|
274 | return;
|
---|
275 | }
|
---|
276 |
|
---|
277 | switch (type) {
|
---|
278 | case 'a':
|
---|
279 | if (Array.isArray(payload)) {
|
---|
280 | payload.forEach(function(p) {
|
---|
281 | debug('message', self.transport, p);
|
---|
282 | self.dispatchEvent(new TransportMessageEvent(p));
|
---|
283 | });
|
---|
284 | }
|
---|
285 | break;
|
---|
286 | case 'm':
|
---|
287 | debug('message', this.transport, payload);
|
---|
288 | this.dispatchEvent(new TransportMessageEvent(payload));
|
---|
289 | break;
|
---|
290 | case 'c':
|
---|
291 | if (Array.isArray(payload) && payload.length === 2) {
|
---|
292 | this._close(payload[0], payload[1], true);
|
---|
293 | }
|
---|
294 | break;
|
---|
295 | }
|
---|
296 | };
|
---|
297 |
|
---|
298 | SockJS.prototype._transportClose = function(code, reason) {
|
---|
299 | debug('_transportClose', this.transport, code, reason);
|
---|
300 | if (this._transport) {
|
---|
301 | this._transport.removeAllListeners();
|
---|
302 | this._transport = null;
|
---|
303 | this.transport = null;
|
---|
304 | }
|
---|
305 |
|
---|
306 | if (!userSetCode(code) && code !== 2000 && this.readyState === SockJS.CONNECTING) {
|
---|
307 | this._connect();
|
---|
308 | return;
|
---|
309 | }
|
---|
310 |
|
---|
311 | this._close(code, reason);
|
---|
312 | };
|
---|
313 |
|
---|
314 | SockJS.prototype._open = function() {
|
---|
315 | debug('_open', this._transport && this._transport.transportName, this.readyState);
|
---|
316 | if (this.readyState === SockJS.CONNECTING) {
|
---|
317 | if (this._transportTimeoutId) {
|
---|
318 | clearTimeout(this._transportTimeoutId);
|
---|
319 | this._transportTimeoutId = null;
|
---|
320 | }
|
---|
321 | this.readyState = SockJS.OPEN;
|
---|
322 | this.transport = this._transport.transportName;
|
---|
323 | this.dispatchEvent(new Event('open'));
|
---|
324 | debug('connected', this.transport);
|
---|
325 | } else {
|
---|
326 | // The server might have been restarted, and lost track of our
|
---|
327 | // connection.
|
---|
328 | this._close(1006, 'Server lost session');
|
---|
329 | }
|
---|
330 | };
|
---|
331 |
|
---|
332 | SockJS.prototype._close = function(code, reason, wasClean) {
|
---|
333 | debug('_close', this.transport, code, reason, wasClean, this.readyState);
|
---|
334 | var forceFail = false;
|
---|
335 |
|
---|
336 | if (this._ir) {
|
---|
337 | forceFail = true;
|
---|
338 | this._ir.close();
|
---|
339 | this._ir = null;
|
---|
340 | }
|
---|
341 | if (this._transport) {
|
---|
342 | this._transport.close();
|
---|
343 | this._transport = null;
|
---|
344 | this.transport = null;
|
---|
345 | }
|
---|
346 |
|
---|
347 | if (this.readyState === SockJS.CLOSED) {
|
---|
348 | throw new Error('InvalidStateError: SockJS has already been closed');
|
---|
349 | }
|
---|
350 |
|
---|
351 | this.readyState = SockJS.CLOSING;
|
---|
352 | setTimeout(function() {
|
---|
353 | this.readyState = SockJS.CLOSED;
|
---|
354 |
|
---|
355 | if (forceFail) {
|
---|
356 | this.dispatchEvent(new Event('error'));
|
---|
357 | }
|
---|
358 |
|
---|
359 | var e = new CloseEvent('close');
|
---|
360 | e.wasClean = wasClean || false;
|
---|
361 | e.code = code || 1000;
|
---|
362 | e.reason = reason;
|
---|
363 |
|
---|
364 | this.dispatchEvent(e);
|
---|
365 | this.onmessage = this.onclose = this.onerror = null;
|
---|
366 | debug('disconnected');
|
---|
367 | }.bind(this), 0);
|
---|
368 | };
|
---|
369 |
|
---|
370 | // See: http://www.erg.abdn.ac.uk/~gerrit/dccp/notes/ccid2/rto_estimator/
|
---|
371 | // and RFC 2988.
|
---|
372 | SockJS.prototype.countRTO = function(rtt) {
|
---|
373 | // In a local environment, when using IE8/9 and the `jsonp-polling`
|
---|
374 | // transport the time needed to establish a connection (the time that pass
|
---|
375 | // from the opening of the transport to the call of `_dispatchOpen`) is
|
---|
376 | // around 200msec (the lower bound used in the article above) and this
|
---|
377 | // causes spurious timeouts. For this reason we calculate a value slightly
|
---|
378 | // larger than that used in the article.
|
---|
379 | if (rtt > 100) {
|
---|
380 | return 4 * rtt; // rto > 400msec
|
---|
381 | }
|
---|
382 | return 300 + rtt; // 300msec < rto <= 400msec
|
---|
383 | };
|
---|
384 |
|
---|
385 | module.exports = function(availableTransports) {
|
---|
386 | transports = transport(availableTransports);
|
---|
387 | require('./iframe-bootstrap')(SockJS, availableTransports);
|
---|
388 | return SockJS;
|
---|
389 | };
|
---|