Socket.IO

12003

Socket.IO is a real-time application framework based on Node.js, widely used in scenarios such as instant messaging, notifications, message pushing, and real-time analytics.

The emergence of WebSocket stems from the growing demand for real-time communication in web development. Compared to traditional HTTP polling methods, it significantly saves network bandwidth and reduces server performance consumption. socket.io supports both WebSocket and polling data transmission methods to be compatible with browsers that do not support WebSocket.

The framework provides the egg-socket.io plugin, which adds the following development specifications:

  • namespace: Defines the namespace through configuration.
  • middleware: Preprocesses each socket connection establishment/disconnection and each message/data transmission.
  • controller: Responds to socket.io event events.
  • router: Unifies the handling configuration of socket.io events and framework routing.

Installing egg-socket.io

Installation

$ npm i egg-socket.io --save

Enable the plugin:

// {app_root}/config/plugin.js
exports.io = {
    enable: true,
    package: 'egg-socket.io',
};

Configuration

// {app_root}/config/config.${env}.js
exports.io = {
    init: {}, // Passed to engine.io
    namespace: {
        '/': {
            connectionMiddleware: [],
            packetMiddleware: [],
        },
        '/example': {
            connectionMiddleware: [],
            packetMiddleware: [],
        },
    },
};

The namespaces are / and /example, not example.

uws

Egg Socket internally defaults to using the ws engine. uws has been deprecated for certain reasons.

If you insist on using uws, please configure as follows:

// {app_root}/config/config.${env}.js
exports.io = {
    init: { wsEngine: 'uws' }, // Default is ws
};

redis

egg-socket.io has built-in support for socket.io-redis. In cluster mode, using Redis can easily achieve sharing of clients/rooms and other information.

// {app_root}/config/config.${env}.js
exports.io = {
    redis: {
        host: { redis server host },
        port: { redis server port },
        auth_pass: { redis server password },
        db: 0,
    },
};

After enabling redis, the program will attempt to connect to the Redis server at startup. The redis here is only used to store connection instance information; see #server.adapter for details.

Note:
If egg-redis is used in the project, please configure them separately; they cannot be shared.

Deployment

Since the framework is started in Cluster mode, and the socket.io protocol implementation requires sticky feature support, it can only work properly in multi-process mode.

Due to the design of socket.io, multi-process servers must operate in sticky mode. Therefore, the sticky parameter needs to be passed to startCluster.

Modify the npm scripts in package.json:

{
    "scripts": {
        "dev": "egg-bin dev --sticky",
        "start": "egg-scripts start --sticky"
    }
}

Nginx Configuration

location / {
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_pass   http://127.0.0.1:7001;

    # http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_bind
# proxy_bind       $remote_addr transparent;
}

Using egg-socket.io

The project directory structure for enabling egg-socket.io is as follows:

chat
├── app
│   ├── extend
│   │   └── helper.js
│   ├── io
│   │   ├── controller
│   │   │   └── default.js
│   │   └── middleware
│   │       ├── connection.js
│   │       └── packet.js
│   └── router.js
├── config
└── package.json

Note: The corresponding files are all located in the app/io directory.

Middleware

There are two scenarios for middleware:

  • Connection
  • Packet

Their configurations are located under each namespace and take effect according to the above two scenarios.

Note:

If the framework middleware is enabled, the following directories will be found in the project:

  • app/middleware: Framework middleware.
  • app/io/middleware: Plugin middleware.

The differences are:

  • Framework middleware is designed based on the HTTP model and handles HTTP requests.
  • Plugin middleware is designed based on the socket model and handles socket.io requests.

Although the framework tries to unify their styles through plugins, it must be noted that their usage scenarios are different. For details, please refer to issue #1416.

Connection

This takes effect when each client connects or disconnects. Therefore, authorization and authentication are usually performed at this step, and clients that fail authentication are handled.

// {app_root}/app/io/middleware/connection.js
module.exports = app => {
  return async (ctx, next) => {
    ctx.socket.emit('res', 'connected!');
    await next();
    // execute when disconnect.
    console.log('disconnection!');
  };
};

Example of kicking out a user:

const tick = (id, msg) => {
  logger.debug('#tick', id, msg);
  socket.emit(id, msg);
  app.io.of('/').adapter.remoteDisconnect(id, true, err => {
    logger.error(err);
  });
};

Example of simple handling for the current connection:

// {app_root}/app/io/middleware/connection.js
module.exports = app => {
  return async (ctx, next) => {
    if (true) {
      ctx.socket.disconnect();
      return;
    }
    await next();
    console.log('disconnection!');
  };
};

Packet

This middleware is executed for each data packet (message). In production environments, it is typically used for preprocessing messages or decrypting encrypted messages.

// {app_root}/app/io/middleware/packet.js
module.exports = app => {
  return async (ctx, next) => {
    ctx.socket.emit('res', 'packet received!');
    console.log('packet:', ctx.packet);
    await next();
  };
};

Controller

The Controller handles events sent by the client; since it inherits from egg.Controller, it has the following member objects:

  • ctx
  • app
  • service
  • config
  • logger

For details, refer to the Controller documentation.

// {app_root}/app/io/controller/default.js
'use strict';

const Controller = require('egg').Controller;

class DefaultController extends Controller {
  async ping() {
    const { ctx } = this;
    const message = ctx.args[0];
    await ctx.socket.emit('res', `Hi! I've got your message: ${message}`);
  }
}

module.exports = DefaultController;

Router

The router is responsible for dispatching different events of socket connections to the corresponding controllers, and the framework has unified its usage.

// {app_root}/app/router.js

module.exports = app => {
  const { router, controller, io } = app;
  
  // default
  router.get('/', controller.home.index);
  
  // socket.io
  io.of('/').route('server', io.controller.home.server);
};

Note:

nsp has the following system events:

  • disconnecting: Disconnecting.
  • disconnect: Disconnected.
  • error: An error occurred.

Namespace/Room

Namespace (nsp)

namespace usually means assigned to different access points or paths. If the client does not specify nsp, it is by default assigned to the "/" default namespace.

In socket.io, we use of to delineate namespaces; since nsp is usually predefined and relatively fixed, the framework encapsulates it and uses configuration to delineate different namespaces.

// socket.io
const nsp = io.of('/my-namespace');
nsp.on('connection', socket => {
  console.log('someone connected');
});
nsp.emit('hi', 'everyone!');

// egg
exports.io = {
  namespace: {
    '/': {
      connectionMiddleware: [],
      packetMiddleware: [],
    },
  },
};

Room

room exists within nsp, and you can join or leave using the join/leave methods; the framework uses the same methods.

const room = 'default_room';

module.exports = app => {
  return async (ctx, next) => {
    ctx.socket.join(room);
    ctx.app.io
      .of('/')
      .to(room)
      .emit('online', { msg: 'welcome', id: ctx.socket.id });
    await next();
    console.log('disconnection!');
  };
};

Note: Each socket connection will have a randomly generated and unpredictable unique id Socket#id, and will automatically join the room named after this id.

Example

Here we use egg-socket.io to create a small example that supports P2P chat.

Client

UI-related content is not repeated; it can be called through window.socket.

// Browser
const log = console.log;

window.onload = function () {
    // Initialization
    const socket = io('/', {
        // In actual use, parameters can be passed here
        query: {
            room: 'demo',
            userId: `client_${Math.random()}`, // Passed room and userId parameters
        },

        transports: ['websocket'],
    });

    socket.on('connect', () => {
        const id = socket.id;

        log('#connect,', id, socket);

        // Listen to its own id for P2P communication
        socket.on(id, (msg) => {
            log('#receive,', msg);
        });
    });

    // Receive online user information
    socket.on('online', (msg) => {
        log('#online,', msg);
    });

    // System events
    socket.on('disconnect', (msg) => {
        log('#disconnect', msg);
    });

    socket.on('disconnecting', () => {
        log('#disconnecting');
    });

    socket.on('error', () => {
        log('#error');
    });

    window.socket = socket;
};

WeChat Mini Program

The API provided by WeChat Mini Program is WebSocket, and since socket.io is a layer of encapsulation over WebSocket, we cannot directly use the Mini Program's API to connect. Libraries like weapp.socket.io can be used for adaptation.

Example code is as follows:

// Mini program example code
const io = require('./your_path/weapp.socket.io.js'); // Please replace with the actual path

const socket = io('http://localhost:8000');

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

socket.on('news', (d) => {
    console.log('received news:', d);
});

socket.emit('news', {
    title: 'this is a news',
});

Server

The following is part of the code for the demo, explaining the function of each method.

config

// {app_root}/config/config.${env}.js
exports.io = {
  namespace: {
    '/': {
      connectionMiddleware: ['auth'],
      packetMiddleware: [] // Message handling is not implemented for now
    }
  },

  // In cluster mode, data sharing is implemented through redis
  redis: {
    host: '127.0.0.1',
    port: 6379
  }
};

// Optional
exports.redis = {
  client: {
    port: 6379,
    host: '127.0.0.1',
    password: '',
    db: 0
  }
};

helper

The framework extension is used to encapsulate data formats.

// {app_root}/app/extend/helper.js

module.exports = {
  parseMsg(action, payload = {}, metadata = {}) {
    const meta = Object.assign({}, { timestamp: Date.now() }, metadata);

    return {
      meta,
      data: {
        action,
        payload
      }
    };
  }
};

Format:

{
  data: {
    action: 'exchange', // 'deny' || 'exchange' || 'broadcast'
    payload: {}
  },
  meta: {
    timestamp: 1512116201597,
    client: 'nNx88r1c5WuHf9XuAAAB',
    target: 'nNx88r1c5WuHf9XuAAAB'
  }
}

Middleware

egg-socket.io middleware is responsible for handling socket connections.

// {app_root}/app/io/middleware/auth.js

const PREFIX = 'room';

module.exports = () => {
  return async (ctx, next) => {
    const { app, socket, logger, helper } = ctx;
    const id = socket.id;
    const nsp = app.io.of('/');
    const query = socket.handshake.query;

    // User information
    const { room, userId } = query;
    const rooms = [room];

    logger.debug('#user_info', id, room, userId);

    const tick = (id, msg) => {
      logger.debug('#tick', id, msg);

      // Send message before kicking out the user
      socket.emit(id, helper.parseMsg('deny', msg));

      // Call adapter method to kick out the user, the client will trigger disconnect event
      nsp.adapter.remoteDisconnect(id, true, (err) => {
        logger.error(err);
      });
    };

    // Check if the room exists, if not, kick out the user
    // Note: app.redis here is unrelated to the plugin and can be replaced with other storage
    const hasRoom = await app.redis.get(`${PREFIX}:${room}`);

    logger.debug('#has_exist', hasRoom);

    // If the room does not exist
    if (!hasRoom) {
      tick(id, {
        type: 'deleted',
        message: 'deleted, room has been deleted.'
      });
      return;
    }

    // User joins the room
    logger.debug('#join', room);
    socket.join(room);

    // Get online list
    nsp.adapter.clients(rooms, (err, clients) => {
      logger.debug('#online_join', clients);

      // Update online user list
      nsp.to(room).emit('online', {
        clients,
        action: 'join',
        target: 'participator',
        message: `User(${id}) joined.`
      });
    });

    await next();

    // User leaves the room
    logger.debug('#leave', room);

    // Get online list
    nsp.adapter.clients(rooms, (err, clients) => {
      logger.debug('#online_leave', clients);

      // Update online user list
      nsp.to(room).emit('online', {
        clients,
        action: 'leave',
        target: 'participator',
        message: `User(${id}) leaved.`
      });
    });
  };
};

Controller

P2P communication is achieved through the exchange method for data exchange.

// {app_root}/app/io/controller/nsp.js
const Controller = require('egg').Controller;

class NspController extends Controller {
  async exchange() {
    const { ctx, app } = this;
    const nsp = app.io.of('/');
    const message = ctx.args[0] || {};
    const socket = ctx.socket;
    const client = socket.id;

    try {
      const { target, payload } = message;
      if (!target) return;
      const msg = ctx.helper.parseMsg('exchange', payload, { client, target });
      nsp.emit(target, msg);
    } catch (error) {
      app.logger.error(error);
    }
  }
}

module.exports = NspController;

Router

// {app_root}/app/router.js
module.exports = (app) => {
  const { router, controller, io } = app;
  router.get('/', controller.home.index);

  // socket.io
  io.of('/').route('exchange', io.controller.nsp.exchange);
};

Open two tab pages and bring up the console:

socket.emit('exchange', {
  target: 'Dkn3UXSu8_jHvKBmAAHW',
  payload: {
    msg: 'test',
  },
});