"use strict"; const http = require( 'http' ) const socketIO = require( 'socket.io' ) const compose = require( 'koa-compose' ) const Socket = require( './lib/socket' ) /** * Main IO class that handles the socket.io connections * @class */ module.exports = class IO { /** * @constructs * @param namespace namespace identifier */ constructor( opts ) { if ( opts && !(typeof opts !== 'string' || opts && typeof opts !== 'object' ) ) { throw new Error( 'Incorrect argument passed to koaSocket constructor' ) } /** * List of middlewares, these are composed into an execution chain and * evaluated with each event * @type */ this.middleware = [] /** * Composed middleware stack * @type */ this.composed = null /** * All of the listeners currently added to the IO instance * event:callback * @type */ this.listeners = new Map() /** * All active connections * id:Socket * @type */ this.connections = new Map() /** * Configuration options * @type */ if ( typeof opts === 'string' ) { opts = { namespace: opts } } this.opts = Object.assign({ /** * Namespace id * @type * @default null */ namespace: null, /** * Hidden instances do not append to the koa app, but still require attachment * @type * @default false */ hidden: false, /** * Options to pass when instantiating socket.io * @type * @default {} */ ioOptions: {} }, opts ) /** * Holds the socketIO connection * @type */ this.socket = null // Bind handlers this.onConnection = this.onConnection.bind( this ) this.onDisconnect = this.onDisconnect.bind( this ) } /** * Attach to a koa application * @param app the koa app to use */ attach( app ) { if ( app.server && app.server.constructor.name != 'Server' ) { throw new Error( 'app.server already exists but it\'s not an http server' ); } if ( !app.server ) { // Create a server if it doesn't already exists app.server = http.createServer( app.callback() ) // Patch `app.listen()` to call `app.server.listen()` app.listen = function listen(){ app.server.listen.apply( app.server, arguments ) return app.server } } if ( app._io ) { // Without a namespace we’ll use the default, but .io already exists meaning // the default is taken already if ( !this.opts.namespace ) { throw new Error( 'Socket failed to initialise::Instance may already exist' ) } this.attachNamespace( app, this.opts.namespace ) return } if ( this.opts.hidden && !this.opts.namespace ) { throw new Error( 'Default namespace can not be hidden' ) } app._io = new socketIO( app.server, this.opts.ioOptions ) if ( this.opts.namespace ) { this.attachNamespace( app, this.opts.namespace ) return } // Attach default namespace app.io = this // If there is no namespace then connect using the default this.socket = app._io this.socket.on( 'connection', this.onConnection ) } /** * Attaches the namespace to the server * @param app the koa app to use * @param id namespace identifier */ attachNamespace( app, id ) { if ( !app._io ) { throw new Error( 'Namespaces can only be attached once a socketIO instance has been attached' ) } this.socket = app._io.of( id ) this.socket.on( 'connection', this.onConnection ) if ( this.opts.hidden ) { return } if ( app[ id ] ) { throw new Error( 'Namespace ' + id + ' already attached to koa instance' ) } app[ id ] = this } /** * Pushes a middleware on to the stack * @param fn the middleware function to execute */ use( fn ) { this.middleware.push( fn ) this.composed = compose( this.middleware ) this.updateConnections() return this } /** * Adds a new listeners to the stack * @param event the event id * @param handler the callback to execute * @return this */ on( event, handler ) { let listeners = this.listeners.get( event ) // If this is a new event then just set it if ( !listeners ) { this.listeners.set( event, [ handler ] ) this.updateConnections() return this } listeners.push( handler ) this.listeners.set( event, listeners ) this.updateConnections() return this } /** * Removes a listener from the event * @param event if omitted will remove all listeners * @param handler if omitted will remove all from the event * @return this */ off( event, handler ) { if ( !event ) { this.listeners = new Map() this.updateConnections() return this } if ( !handler ) { this.listeners.delete( event ) this.updateConnections() return this } let listeners = this.listeners.get( event ) let i = listeners.length - 1 while( i ) { if ( listeners[ i ] === handler ) { break } i-- } listeners.splice( i, 1 ) this.updateConnections() return this } /** * Broadcasts an event to all connections * @param event * @param data */ broadcast( event, data ) { this.connections.forEach( ( socket, id ) => { socket.emit( event, data ) }) } /** * Triggered for each new connection * Creates a new Socket instance and adds that to the stack and sets up the * disconnect event * @param sock * @private */ onConnection( sock ) { // let instance = new Socket( sock, this.listeners, this.middleware ) let instance = new Socket( sock, this.listeners, this.composed ) this.connections.set( sock.id, instance ) sock.on( 'disconnect', () => { this.onDisconnect( sock ) }) // Trigger the connection event if attached to the socket listener map let handlers = this.listeners.get( 'connection' ) if ( handlers ) { handlers.forEach( handler => { handler({ event: 'connection', data: instance, socket: instance.socket }, instance.id ) }) } } /** * Fired when the socket disconnects, simply reflects stack in the connections * stack * @param sock * @private */ onDisconnect( sock ) { this.connections.delete( sock.id ) } /** * Updates all existing connections with current listeners and middleware * @private */ updateConnections() { this.connections.forEach( connection => { connection.update( this.listeners, this.composed ) }) } }