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 authentication, 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           self = this;
120 
121       socket.onopen = function (open) {
122         SC.run(function () {
123           self.onOpen(open);
124         });
125       };
126 
127       socket.onmessage = function (message) {
128         SC.run(function () {
129           self.onMessage(message);
130         });
131       };
132 
133       socket.onclose = function (close) {
134         SC.run(function () {
135           self.onClose(close);
136         });
137       };
138 
139       socket.onerror = function (error) {
140         SC.run(function () {
141           self.onError(error);
142         });
143       };
144     } catch (e) {
145       SC.error('An error has occurred while connnecting to the websocket server: ' + e);
146     }
147 
148     return this;
149   },
150 
151   /**
152     Close the connection.
153 
154     @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.
155     @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).
156     @returns {SC.WebSocket} The SC.WebSocket object.
157   */
158   close: function(code, reason) {
159     var socket = this.socket;
160 
161     if (socket && socket.readyState === SC.WebSocket.OPEN) {
162       this.socket.close(code, reason);
163     }
164 
165     return this;
166   },
167 
168   /**
169     Configures a callback to execute when an event happens. You must pass at least a target and
170     method to this and optionally an event name.
171 
172     You may also pass additional arguments which will then be passed along to your callback.
173 
174     Example:
175 
176         var websocket = SC.WebSocket.create({ server: 'ws://server' }).connect();
177 
178         webSocket.notify('onopen', this, 'wsWasOpened');
179         webSocket.notify('onmessage', this, 'wsReceivedMessage');
180         webSocket.notify('onclose', this, 'wsWasClose');
181         webSocket.notify('onerror', this, 'wsDidError');
182 
183     ## Callback Format
184 
185     Your notification callback should expect to receive the WebSocket object as
186     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
187     should return YES.
188 
189     @param {String} target String Event name.
190     @param {Object} target The target object for the callback action.
191     @param {String|Function} action The method name or function to call on the target.
192     @returns {SC.WebSocket} The SC.WebSocket object.
193   */
194   notify: function(event, target, action) {
195     var args,
196       i, len;
197 
198     if (SC.typeOf(event) !== SC.T_STRING) {
199       // Fast arguments access.
200       // Accessing `arguments.length` is just a Number and doesn't materialize the `arguments` object, which is costly.
201       args = new Array(Math.max(0, arguments.length - 2)); //  SC.A(arguments).slice(2)
202       for (i = 0, len = args.length; i < len; i++) { args[i] = arguments[i + 2]; }
203 
204       // Shift the arguments
205       action = target;
206       target = event;
207       event = 'onmessage';
208     } else {
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(Math.max(0, arguments.length - 3)); //  SC.A(arguments).slice(3)
212       for (i = 0, len = args.length; i < len; i++) { args[i] = arguments[i + 3]; }
213     }
214 
215     var listeners = this.get('listeners');
216     if (!listeners) { this.set('listeners', listeners = {}); }
217     if(!listeners[event]) { listeners[event] = []; }
218 
219     //@if(debug)
220     for (i = listeners[event].length - 1; i >= 0; i--) {
221       var listener = listeners[event][i];
222       if (listener.event === event && listener.target === target && listener.action === action) {
223         SC.warn("Developer Warning: This listener is already defined.");
224       }
225     }
226     //@endif
227 
228     // Add another listener for the given event name.
229     listeners[event].push({target: target, action: action, args: args});
230 
231     return this;
232   },
233 
234   /**
235     Send the message on the WebSocket. If the connection is not yet open or authenticated (as
236     necessary), the message will be put in the queue.
237 
238     If `isJSON` is true (the default for SC.WebSocket), the message will be stringified JSON.
239 
240     @param {String|Object} message The message to send.
241     @returns {SC.WebSocket}
242   */
243   send: function(message) {
244     if (this.isConnected === true && this.isAuth !== false) {
245       if (this.isJSON) {
246         message = JSON.stringify(message);
247       }
248 
249       this.socket.send(message);
250     } else {
251       this.addToQueue(message);
252     }
253     return this;
254   },
255 
256   // ..........................................................
257   // PRIVATE METHODS
258   //
259 
260   /**
261      @private
262   */
263   onOpen: function(event) {
264     var del = this.get('objectDelegate');
265 
266     this.set('isConnected', true);
267 
268     var ret = del.webSocketDidOpen(this, event);
269     if (ret !== true) this._notifyListeners('onopen', event);
270 
271     this.fireQueue();
272   },
273 
274   /**
275      @private
276   */
277   onMessage: function(messageEvent) {
278     if (messageEvent) {
279       var del = this.get('objectDelegate'),
280         message,
281         data,
282         ret;
283 
284       message = data = messageEvent.data;
285       ret = del.webSocketDidReceiveMessage(this, data);
286 
287       if (ret !== true) {
288         if (this.isJSON) {
289           message = JSON.parse(data);
290         }
291         this._notifyListeners('onmessage', message);
292       }
293     }
294 
295     // If there is message in the queue, we fire them
296     this.fireQueue();
297   },
298 
299   /**
300      @private
301   */
302   onClose: function(closeEvent) {
303     var del = this.get('objectDelegate');
304 
305     this.set('isConnected', false);
306     this.set('isAuth', null);
307     this.socket = null;
308 
309     var ret = del.webSocketDidClose(this, closeEvent);
310 
311     if (ret !== true) {
312       this._notifyListeners('onclose', closeEvent);
313       this.tryReconnect();
314     }
315   },
316 
317   /**
318      @private
319   */
320   onError: function(event) {
321     var del = this.get('objectDelegate'),
322       ret = del.webSocketDidError(this, event);
323 
324     if (ret !== true) this._notifyListeners('onerror', event);
325   },
326 
327   /**
328      @private
329 
330      Add the message to the queue
331   */
332   addToQueue: function(message) {
333     var queue = this.queue;
334     if (!queue) { this.queue = queue = []; }
335 
336     queue.push(message);
337   },
338 
339   /**
340      @private
341 
342      Send the messages from the queue.
343   */
344   fireQueue: function() {
345     var queue = this.queue;
346     if (!queue || queue.length === 0) return;
347 
348     queue = SC.A(queue);
349     this.queue = null;
350 
351     for (var i = 0, len = queue.length; i < len; i++) {
352       var message = queue[i];
353       this.send(message);
354     }
355   },
356 
357   /**
358     @private
359   */
360   tryReconnect: function() {
361     if (!this.get('autoReconnect')) return;
362 
363     var that = this;
364     setTimeout(function() { that.connect(); }, this.get('reconnectInterval'));
365   },
366 
367   /**
368     @private
369 
370     Will notify each listener. Returns true if any of the listeners handle.
371   */
372   _notifyListeners: function(event, message) {
373     var listeners = (this.listeners || {})[event], notifier, target, action, args;
374     if (!listeners) { return NO; }
375 
376     var handled = NO,
377       len = listeners.length;
378 
379     for (var i = 0; i < len; i++) {
380       notifier = listeners[i];
381       args = (notifier.args || []).copy();
382       args.unshift(message);
383       args.unshift(this);
384 
385       target = notifier.target;
386       action = notifier.action;
387       if (SC.typeOf(action) === SC.T_STRING) { action = target[action]; }
388 
389       handled = action.apply(target, args);
390       if (handled === true) return handled;
391     }
392 
393     return handled;
394   },
395 
396   /**
397     @private
398   */
399   objectDelegate: function () {
400     var del = this.get('delegate');
401     return this.delegateFor('isWebSocketDelegate', del, this);
402   }.property('delegate').cacheable(),
403 
404   // ..........................................................
405   // PRIVATE PROPERTIES
406   //
407 
408   /**
409     @private
410 
411     @type WebSocket
412     @default null
413   */
414   socket: null,
415 
416   /**
417     @private
418 
419     @type Object
420     @default null
421   */
422   listeners: null,
423 
424   /**
425     @private
426 
427     Messages that needs to be send once the connection is open.
428 
429     @type Array
430     @default null
431   */
432   queue: null,
433 
434 });
435 
436 // Class Methods
437 SC.WebSocket.mixin( /** @scope SC.WebSocket */ {
438 
439   // ..........................................................
440   // CONSTANTS
441   //
442 
443   /**
444     The connection is not yet open.
445 
446     @static
447     @constant
448     @type Number
449     @default 0
450   */
451   CONNECTING: 0,
452 
453   /**
454     The connection is open and ready to communicate.
455 
456     @static
457     @constant
458     @type Number
459     @default 1
460   */
461   OPEN: 1,
462 
463   /**
464     The connection is in the process of closing.
465 
466     @static
467     @constant
468     @type Number
469     @default 2
470   */
471   CLOSING: 2,
472 
473   /**
474     The connection is closed or couldn't be opened.
475 
476     @static
477     @constant
478     @type Number
479     @default 3
480   */
481   CLOSED: 3,
482 
483 });
484