1 // ==========================================================================
  2 // Project:   SC.WebSocket
  3 // Copyright: ©2013 Nicolas BADIA and contributors
  4 // License:   Licensed under MIT license (see license.js)
  5 // ==========================================================================
  6 
  7 sc_require('mixins/websocket_delegate');
  8 
  9 /**
 10   @class
 11 
 12   Implements SproutCore run loop aware event handling for WebSocket. Using SC.WebSocket ensures
 13   that the run loop runs on each WebSocket event and provides a useful skeleton for handling
 14   WebSocket events.
 15 
 16   Example Usage:
 17 
 18       // Create a WebSocket connection.
 19       var ws = SC.WebSocket.create({
 20         server: 'ws://server',
 21       });
 22 
 23       // Assign target and methods for events.
 24       ws.notify('onopen', this, 'wsOpened');
 25       ws.notify('onmessage', this, 'wsReceivedMessage');
 26       ws.notify('onclose', this, 'wsClosed');
 27       ws.connect();
 28 
 29       // Send a message through the WebSocket.
 30       ws.send('hello server');
 31 
 32   @since SproutCore 1.11
 33   @extends SC.Object
 34   @extends SC.DelegateSupport
 35   @author Nicolas BADIA
 36 */
 37 SC.WebSocket = SC.Object.extend(SC.DelegateSupport, SC.WebSocketDelegate,
 38   /** @scope SC.WebSocket.prototype */ {
 39 
 40   /**
 41     The URL of the WebSocket server.
 42 
 43     @type String
 44     @default null
 45   */
 46   server: null,
 47 
 48   /**
 49     Whether the connection is open or not.
 50 
 51     @type Boolean
 52     @readOnly
 53   */
 54   isConnected: false,
 55 
 56   /**
 57     In order to handle authentification, set `isAuth` to `false` in the
 58     `webSocketDidOpen` delegate method just after sending a request to
 59     authenticate the connection. This way, any futher messages will be put in the
 60     queue until the server tells you that the connection is authenticated. Once it
 61     is, you should set `isAuth` to `true` to resume the queue.
 62 
 63     If you don't need authentication, leave `isAuth` as `null`.
 64 
 65     @type Boolean
 66     @default null
 67   */
 68   isAuth: null,
 69 
 70   /**
 71     Processes the messages as JSON if possible.
 72 
 73     @type Boolean
 74     @default true
 75   */
 76   isJSON: true,
 77 
 78   /**
 79     A WebSocket delegate.
 80 
 81     @see SC.WebSocketDelegate
 82     @type SC.WebSocketDelegate
 83     @default null
 84   */
 85   delegate: null,
 86 
 87   /**
 88     Whether to attempt to reconnect automatically if the connection is closed or not.
 89 
 90     @type SC.WebSocketDelegate
 91     @default true
 92   */
 93   autoReconnect: true,
 94 
 95   /**
 96     The interval in milliseconds to wait before trying to reconnect.
 97 
 98     @type SC.WebSocketDelegate
 99     @default null
100   */
101   reconnectInterval: 10000, // 10 seconds
102 
103   // ..........................................................
104   // PUBLIC METHODS
105   //
106 
107   /**
108     Open the WebSocket connection.
109 
110     @returns {SC.WebSocket} The SC.WebSocket object.
111   */
112   connect: function() {
113     // If not supported or already connected, return.
114     if (!SC.platform.supportsWebSocket || this.socket) return this;
115 
116     // Connect.
117     try {
118       var socket = this.socket = new WebSocket(this.get('server'));
119 
120       socket.onopen = function() {
121         SC.run(function () {
122           this.onOpen.apply(this, arguments);
123         }, this);
124       };
125 
126       socket.onmessage = function() {
127         SC.run(function () {
128           this.onMessage.apply(this, arguments);
129         }, this);
130       };
131 
132       socket.onclose = function() {
133         SC.run(function () {
134           this.onClose.apply(this, arguments);
135         }, this);
136       };
137 
138       socket.onerror = function() {
139         SC.run(function () {
140           this.onError.apply(this, arguments);
141         }, this);
142       };
143     } catch (e) {
144       SC.error('An error has occurred while connnecting to the websocket server: ' + e);
145     }
146 
147     return this;
148   },
149 
150   /**
151     Close the connection.
152 
153     @param {Number} code A numeric value indicating the status code explaining why the connection is being closed. If this parameter is not specified, a default value of 1000 (indicating a normal "transaction complete" closure) is assumed.
154     @param {String} reason A human-readable string explaining why the connection is closing. This string must be no longer than 123 bytes of UTF-8 text (not characters).
155     @returns {SC.WebSocket} The SC.WebSocket object.
156   */
157   close: function(code, reason) {
158     var socket = this.socket;
159 
160     if (socket && socket.readyState === SC.WebSocket.OPEN) {
161       this.socket.close(code, reason);
162     }
163 
164     return this;
165   },
166 
167   /**
168     Configures a callback to execute when an event happens. You must pass at least a target and
169     method to this and optionally an event name.
170 
171     You may also pass additional arguments which will then be passed along to your callback.
172 
173     Example:
174 
175         var websocket = SC.WebSocket.create({ server: 'ws://server' }).connect();
176 
177         webSocket.notify('onopen', this, 'wsWasOpened');
178         webSocket.notify('onmessage', this, 'wsReceivedMessage');
179         webSocket.notify('onclose', this, 'wsWasClose');
180         webSocket.notify('onerror', this, 'wsDidError');
181 
182     ## Callback Format
183 
184     Your notification callback should expect to receive the WebSocket object as
185     the first parameter and the event or message; plus any additional parameters that you pass. If your callback handles the notification and to prevent further handling, it
186     should return YES.
187 
188     @param {String} target String Event name.
189     @param {Object} target The target object for the callback action.
190     @param {String|Function} action The method name or function to call on the target.
191     @returns {SC.WebSocket} The SC.WebSocket object.
192   */
193   notify: function(event, target, action) {
194     var args,
195       i, len;
196 
197     if (SC.typeOf(event) !== SC.T_STRING) {
198       // Fast arguments access.
199       // Accessing `arguments.length` is just a Number and doesn't materialize the `arguments` object, which is costly.
200       args = new Array(arguments.length - 2); //  SC.A(arguments).slice(2)
201       for (i = 0, len = args.length; i < len; i++) { args[i] = arguments[i + 2]; }
202 
203       // Shift the arguments
204       action = target;
205       target = event;
206       event = 'onmessage';
207     } else {
208       if (arguments.length > 3) {
209         // Fast arguments access.
210         // Accessing `arguments.length` is just a Number and doesn't materialize the `arguments` object, which is costly.
211         args = new Array(arguments.length - 3); //  SC.A(arguments).slice(3)
212         for (i = 0, len = args.length; i < len; i++) { args[i] = arguments[i + 3]; }
213       } else {
214         args = [];
215       }
216     }
217 
218     var listeners = this.get('listeners');
219     if (!listeners) { this.set('listeners', listeners = {}); }
220     if(!listeners[event]) { listeners[event] = []; }
221 
222     //@if(debug)
223     for (i = listeners[event].length - 1; i >= 0; i--) {
224       var listener = listeners[event][i];
225       if (listener.event === event && listener.target === target && listener.action === action) {
226         SC.warn("Developer Warning: This listener is already defined.");
227       }
228     }
229     //@endif
230 
231     // Add another listener for the given event name.
232     listeners[event].push({target: target, action: action, args: args});
233 
234     return this;
235   },
236 
237   /**
238     Send the message on the WebSocket. If the connection is not yet open or authenticated (as
239     necessary), the message will be put in the queue.
240 
241     If `isJSON` is true (the default for SC.WebSocket), the message will be stringified JSON.
242 
243     @param {String|Object} message The message to send.
244     @returns {SC.WebSocket}
245   */
246   send: function(message) {
247     if (this.isConnected === true && this.isAuth !== false) {
248       if (this.isJSON) {
249         message = JSON.stringify(message);
250       }
251 
252       this.socket.send(message);
253     } else {
254       this.addToQueue(message);
255     }
256     return this;
257   },
258 
259   // ..........................................................
260   // PRIVATE METHODS
261   //
262 
263   /**
264      @private
265   */
266   onOpen: function(event) {
267     var del = this.get('objectDelegate');
268 
269     this.set('isConnected', true);
270 
271     var ret = del.webSocketDidOpen(this, event);
272     if (ret !== true) this._notifyListeners('onopen', event);
273 
274     this.fireQueue();
275   },
276 
277   /**
278      @private
279   */
280   onMessage: function(messageEvent) {
281     if (messageEvent) {
282       var del = this.get('objectDelegate'),
283         message,
284         data,
285         ret;
286 
287       message = data = messageEvent.data;
288       ret = del.webSocketDidReceiveMessage(this, data);
289 
290       if (ret !== true) {
291         if (this.isJSON) {
292           message = JSON.parse(data);
293         }
294         this._notifyListeners('onmessage', message);
295       }
296     }
297 
298     // If there is message in the queue, we fire them
299     this.fireQueue();
300   },
301 
302   /**
303      @private
304   */
305   onClose: function(closeEvent) {
306     var del = this.get('objectDelegate');
307 
308     this.set('isConnected', false);
309     this.set('isAuth', null);
310     this.socket = null;
311 
312     var ret = del.webSocketDidClose(this, closeEvent);
313 
314     if (ret !== true) {
315       this._notifyListeners('onclose', closeEvent);
316       this.tryReconnect();
317     }
318   },
319 
320   /**
321      @private
322   */
323   onError: function(event) {
324     var del = this.get('objectDelegate'),
325       ret = del.webSocketDidError(this, event);
326 
327     if (ret !== true) this._notifyListeners('onerror', event);
328   },
329 
330   /**
331      @private
332 
333      Add the message to the queue
334   */
335   addToQueue: function(message) {
336     var queue = this.queue;
337     if (!queue) { this.queue = queue = []; }
338 
339     queue.push(message);
340   },
341 
342   /**
343      @private
344 
345      Send the messages from the queue.
346   */
347   fireQueue: function() {
348     var queue = this.queue;
349     if (!queue || queue.length === 0) return;
350 
351     queue = SC.A(queue);
352     this.queue = null;
353 
354     for (var i = 0, len = queue.length; i < len; i++) {
355       var message = queue[i];
356       this.send(message);
357     }
358   },
359 
360   /**
361     @private
362   */
363   tryReconnect: function() {
364     if (!this.get('autoReconnect')) return;
365 
366     var that = this;
367     setTimeout(function() { that.connect(); }, this.get('reconnectInterval'));
368   },
369 
370   /**
371     @private
372 
373     Will notify each listener. Returns true if any of the listeners handle.
374   */
375   _notifyListeners: function(event, message) {
376     var listeners = (this.listeners || {})[event], notifier, target, action, args;
377     if (!listeners) { return NO; }
378 
379     var handled = NO,
380       len = listeners.length;
381 
382     for (var i = 0; i < len; i++) {
383       notifier = listeners[i];
384       args = (notifier.args || []).copy();
385       args.unshift(message);
386       args.unshift(this);
387 
388       target = notifier.target;
389       action = notifier.action;
390       if (SC.typeOf(action) === SC.T_STRING) { action = target[action]; }
391 
392       handled = action.apply(target, args);
393       if (handled === true) return handled;
394     }
395 
396     return handled;
397   },
398 
399   /**
400     @private
401   */
402   objectDelegate: function () {
403     var del = this.get('delegate');
404     return this.delegateFor('isWebSocketDelegate', del, this);
405   }.property('delegate').cacheable(),
406 
407   // ..........................................................
408   // PRIVATE PROPERTIES
409   //
410 
411   /**
412     @private
413 
414     @type WebSocket
415     @default null
416   */
417   socket: null,
418 
419   /**
420     @private
421 
422     @type Object
423     @default null
424   */
425   listeners: null,
426 
427   /**
428     @private
429 
430     Messages that needs to be send once the connection is open.
431 
432     @type Array
433     @default null
434   */
435   queue: null,
436 
437 });
438 
439 // Class Methods
440 SC.WebSocket.mixin( /** @scope SC.WebSocket */ {
441 
442   // ..........................................................
443   // CONSTANTS
444   //
445 
446   /**
447     The connection is not yet open.
448 
449     @static
450     @constant
451     @type Number
452     @default 0
453   */
454   CONNECTING: 0,
455 
456   /**
457     The connection is open and ready to communicate.
458 
459     @static
460     @constant
461     @type Number
462     @default 1
463   */
464   OPEN: 1,
465 
466   /**
467     The connection is in the process of closing.
468 
469     @static
470     @constant
471     @type Number
472     @default 2
473   */
474   CLOSING: 2,
475 
476   /**
477     The connection is closed or couldn't be opened.
478 
479     @static
480     @constant
481     @type Number
482     @default 3
483   */
484   CLOSED: 3,
485 
486 });
487