server/services/websocket_service/service.js

'use strict';

/* eslint-disable security/detect-object-injection */

/**
 * Module dependencies, required for ALL Twyr' modules
 * @ignore
 */

/**
 * Module dependencies, required for this module
 * @ignore
 */
const TwyrBaseService = require('twyr-base-service').TwyrBaseService;
const TwyrSrvcError = require('twyr-service-error').TwyrServiceError;

/**
 * @class   WebsocketService
 * @extends {TwyrBaseService}
 * @classdesc The Twyr Web Application Server Websocket Service.
 *
 * @description
 * Allows the rest of the Twyr Modules to use Websockets for realtime communication with the front-end.
 *
 */
class WebsocketService extends TwyrBaseService {
	// #region Constructor
	constructor(parent, loader) {
		super(parent, loader);
	}
	// #endregion

	// #region reconfiguration code
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof WebsocketService
	 * @name     _dependencyReconfigure
	 *
	 * @param    {TwyrBaseModule} dependency - Instance of the dependency that was reconfigured.
	 *
	 * @returns  {null} Nothing.
	 *
	 * @summary  If WebserverService was reconfigured, reset. Otherwise, ignore
	 */
	async _dependencyReconfigure(dependency) {
		try {
			const superStatus = await super._dependencyReconfigure(dependency);
			if(dependency.name !== 'WebserverService')
				return superStatus;

			await this._teardown();
			await this._setup();

			return superStatus;
		}
		catch(err) {
			console.error(`${this.name}::_dependencyReconfigure error: ${err.message}\n${err.stack}`);
			return null;
		}
	}
	// #endregion

	// #region startup/teardown code
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof WebsocketService
	 * @name     _setup
	 *
	 * @returns  {null} Nothing.
	 *
	 * @summary  Sets up the Websocket Server.
	 */
	async _setup() {
		try {
			if(this.$websocketServer)
				return null;

			await super._setup();

			const PrimusRooms = require('primus-rooms');
			const PrimusServer = require('primus');

			// Step 1: Setup the realtime streaming server
			const thisConfig = JSON.parse(JSON.stringify(this.$config.primus));
			this.$websocketServer = new PrimusServer(this.$dependencies.WebserverService.Server, thisConfig);

			// Step 2: Re-use Koa middlewares so we don't duplicate session / cookie handling
			const self = this; // eslint-disable-line consistent-this
			this.$websocketServer.use('twyr', function() {
				const httpMocks = require('node-mocks-http');
				const koaRequestHandler = self.$dependencies.WebserverService.App.callback();

				const twyrPrimusMiddleware = async function(request, response, next) {
					try {
						const mockResponse = httpMocks.createResponse({
							'locals': response.locals,
							'req': request
						});

						await koaRequestHandler(request, mockResponse);
						const responseData = JSON.parse(mockResponse._getData());

						request.headers['x-request-id'] = responseData.id;
						request.user = responseData.user;
						request.tenant = responseData.tenant;

						next();
					}
					catch(err) {
						throw new TwyrSrvcError(`WebsocketService::twyrPrimusMiddleware error`, err);
					}
				};

				return twyrPrimusMiddleware;
			}, undefined, 0);

			// Step 3: Authorization hook
			this.$websocketServer.authorize(this._authorizeWebsocketConnection.bind(this));

			// Step 4: Primus extensions...
			this.$websocketServer.plugin('rooms', PrimusRooms);

			// Step 5: Attach the event handlers...
			this.$websocketServer.on('initialised', this._websocketServerInitialised.bind(this));
			this.$websocketServer.on('log', this._websocketServerLog.bind(this));
			this.$websocketServer.on('error', this._websocketServerError.bind(this));

			// Step 6: Log connection / disconnection events
			this.$websocketServer.on('connection', this._websocketServerConnection.bind(this));
			this.$websocketServer.on('disconnection', this._websocketServerDisconnection.bind(this));

			// And we're done...
			return null;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::_setup error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof WebsocketService
	 * @name     _teardown
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Destroys the Websocket Server.
	 */
	async _teardown() {
		try {
			if(!this.$websocketServer)
				return null;

			this.$websocketServer.end({
				'close': true,
				'reconnect': false,
				'timeout': 10
			});

			delete this.$websocketServer;

			await super._teardown();
			return null;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::_teardown error`, err);
		}
	}
	// #endregion

	// #region Private Methods
	_authorizeWebsocketConnection(request, callback) {
		if(callback) callback(!request.user);
	}

	async _websocketServerInitialised(transformer, parser, options) {
		if((twyrEnv !== 'development') && (twyrEnv !== 'test'))
			return;

		await snooze(1000);
		this.$dependencies.LoggerService.debug(`Websocket Server has been initialised with options`, options);
	}

	_websocketServerLog() {
		const loggerSrvc = this.$dependencies.LoggerService;
		if(twyrEnv === 'development') loggerSrvc.debug(`Websocket Server Log`, JSON.stringify(arguments, undefined, '\t'));
	}

	_websocketServerError() {
		const loggerSrvc = this.$dependencies.LoggerService;
		loggerSrvc.error(`Websocket Server Error`, JSON.stringify(arguments, undefined, '\t'));

		this.emit('websocket-error', arguments);
	}

	_websocketServerConnection(spark) {
		const username = spark.request.user ? spark.request.user.name : 'Anonymous';

		if(twyrEnv === 'development') {
			const loggerSrvc = this.$dependencies.LoggerService;
			loggerSrvc.debug(`Websocket Connection for user ${username}`);
		}

		this.emit('websocket-connect', spark);
		spark.write({ 'channel': 'display-status-message', 'data': `Realtime Data connection established for User: ${username}` });
	}

	_websocketServerDisconnection(spark) {
		const username = spark.request.user ? spark.request.user.name : 'Anonymous';

		if(twyrEnv === 'development') {
			const loggerSrvc = this.$dependencies.LoggerService;
			loggerSrvc.debug(`Websocket Disconnection for user ${username}`);
		}

		this.emit('websocket-disconnect', spark);

		spark.leaveAll();
		spark.removeAllListeners();
	}
	// #endregion

	// #region Properties
	/**
	 * @override
	 */
	get Interface() {
		return this.$websocketServer;
	}

	/**
	 * @override
	 */
	get dependencies() {
		return [
			'LoggerService',
			'RingpopService',
			'WebserverService'
		].concat(super.dependencies);
	}

	/**
	 * @override
	 */
	get basePath() {
		return __dirname;
	}
	// #endregion
}

exports.service = WebsocketService;