Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Node.js + AngularJS + socket.io: connect state and manually disconnect

I use nodejs with socket.io and angularjs on client. I picked up angular-socketio example from the Internet and added disconnect method to It.

Socket service:

angular.module('app')
  .factory('socket', ['$rootScope', function ($rootScope) {

    var socket = io.connect();

    return {
      on: function (eventName, callback) {
        socket.on(eventName, function () {  
          var args = arguments;
          $rootScope.$apply(function () {
            callback.apply(socket, args);
          });
        });
      },
      emit: function (eventName, data, callback) {
        socket.emit(eventName, data, function () {
          var args = arguments;
          $rootScope.$apply(function () {
            if (callback) {
              callback.apply(socket, args);
            }
          });
        })
      },
      disconnect: function () {
        socket.disconnect();
      },
      socket: socket
    };

  }]);

Controller:

angular.module('app')
  .controller('Controller', ['$scope', 'socket', function ($scope, socket) {

    socket.emit('register')

    socket.on('connect', function () {
        console.log('Socket connected');
    });

    socket.on('disconnect', function () {
        console.log('Socket disconnected');
    });

    socket.on('register', function (reginfo) {
        console.log('Register: %s, cname=%s', reginfo.ok, reginfo.cname);
        socket.disconnect(); // <-- this line throw Error
    });

    socket.on('last', updateSnapshot);

    socket.on('state', updateSnapshot);

    function updateSnapshot(snapshot) { ... }

}]);

But when I try to disconnect use this method I catch Error:

Error: $apply already in progress
  at Error (<anonymous>)
  at beginPhase (http://localhost:4000/scripts/vendor/angular.js:8182:15)
  at Object.$get.Scope.$apply (http://localhost:4000/scripts/vendor/angular.js:7984:11)
  at SocketNamespace.on (http://localhost:4000/scripts/services/socket.js:10:32)
  at SocketNamespace.EventEmitter.emit [as $emit] (http://localhost:4000/socket.io/socket.io.js:633:15)
  at Socket.publish (http://localhost:4000/socket.io/socket.io.js:1593:19)
  at Socket.onDisconnect (http://localhost:4000/socket.io/socket.io.js:1970:14)
  at Socket.disconnect (http://localhost:4000/socket.io/socket.io.js:1836:12)
  at SocketNamespace.<anonymous> (http://localhost:4000/scripts/controllers/controller.js:38:34)
  at on (http://localhost:4000/scripts/services/socket.js:11:34)

And I don't understand where to dig…

like image 718
Maxim Grach Avatar asked Feb 05 '13 05:02

Maxim Grach


1 Answers

[Update]

$$phase is an internal, private variable to Angular, and thus you should not really depend on it for things like this. Igor describes, in another answer, some suggestions for handling this which should be used instead (I hear he knows a thing or two about Angular. ;)


When models change and events fire from within the Angular framework, Angular can do dirty tracking as necessary and update any necessary views. When you want to interact with code outside of Angular, you have to wrap the necessary function calls in the $apply method of a scope, so that Angular knows something is happening. That's why the code reads

$rootScope.$apply(function () {
  callback.apply(socket, args);
});

and so forth. It's telling Angular, "take this code that normally wouldn't trigger Angular view updates, and treat it like it should."

The problem is when you call $apply when you're already in an $apply call. For example, the following would throw an $apply already in progress error:

$rootScope.$apply(function() {
  $rootScope.$apply(function() {
    // some stuff
  });
});

Based on your stack trace, it looks like some call to emit (which already uses $apply) triggered a call to on (which also uses $apply). To fix this problem, we need to only call $apply if an $apply is not already in progress. Thankfully, there is a property on the scope called $$phase that can tell us if a dirty check is in progress.

We can easily build a function that takes a scope and a function to run, and then runs the function with $apply only if one isn't already in progress:

var safeApply = function(scope, fn) {
  if (scope.$$phase) {
    fn(); // digest already in progress, just run the function
  } else {
    scope.$apply(fn); // no digest in progress, run the function with $apply
  }
};

Now we can replace calls to

$rootScope.$apply(function...);

to

safeApply($rootScope, function...);

For example, to modify the code you have above,

angular.module('app')
  .factory('socket', ['$rootScope', function ($rootScope) {

    var safeApply = function(scope, fn) {
      if (scope.$$phase) {
        fn(); // digest already in progress, just run the function
      } else {
        scope.$apply(fn); // no digest in progress, run with $apply
      }
    };

    var socket = io.connect();

    return {
      on: function (eventName, callback) {
        socket.on(eventName, function () {  
          var args = arguments;
          safeApply($rootScope, function () {
            callback.apply(socket, args);
          });
        });
      },
      emit: function (eventName, data, callback) {
        socket.emit(eventName, data, function () {
          var args = arguments;
          safeApply($rootScope, function () {
            if (callback) {
              callback.apply(socket, args);
            }
          });
        })
      },
      disconnect: function () {
        socket.disconnect();
      },
      socket: socket
    };

  }]);
like image 103
Michelle Tilley Avatar answered Nov 08 '22 23:11

Michelle Tilley