const WebSocketServer = require('uws').Server;
const Connection = require('./connection');
const Message = require('../lib/message');
const Rooms = require('./rooms');
const EventEmitterExtra = require('event-emitter-extra');
const debug = require('debug')('line:server');
const LineError = require('../lib/error');
const assign = require('lodash/assign');
const isInteger = require('lodash/isInteger');
/**
* Line Server Class
*
* @class Server
* @extends {EventEmitterExtra}
* @param {Object=} options Options object.
* @param {string=} options.host The hostname where to bind the server. [Inherited from uws.](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback)
* @param {number=} options.port The port where to bind the server. [Inherited from uws.](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback)
* @param {http.Server=} options.server A pre-created Node.js HTTP server. If provided, `host` and `port`
* will ignored. [Inherited from uws.](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback)
* @param {string=} options.path Accept only connections matching this path. [Inherited from uws](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback)
* @param {number=} options.responseTimeout Default timeout duration (in ms) for message responses. Default: `10000` (10 seconds)
* @param {number=} options.handshakeTimeout This is the duration how long a client can stay connected
* without handshake. Default `60000` (1 minute).
* @param {number=} options.pingInterval Ping interval in ms. Default: 15 seconds.
* @example
* const Server = require('line-socket/server');
* const server = new Server({
* port: 8080
* });
*/
class Server extends EventEmitterExtra {
constructor(options = {}) {
super();
this.options = assign({
responseTimeout: 10000,
handshakeTimeout: 60000,
pingInterval: 15000
}, options);
if (!isInteger(this.options.responseTimeout) || this.options.responseTimeout < 0)
throw new LineError(Server.ErrorCode.INVALID_OPTIONS, `"options.responseTimeout" must be a positive integer or zero`);
if (!isInteger(this.options.handshakeTimeout) || this.options.handshakeTimeout < 0)
throw new LineError(Server.ErrorCode.INVALID_OPTIONS, `"options.handshakeTimeout" must be a positive integer or zero`);
if (!isInteger(this.options.pingInterval) || this.options.pingInterval < 0)
throw new LineError(Server.ErrorCode.INVALID_OPTIONS, `"options.pingInterval" must be a positive integer or zero`);
this.rooms = new Rooms();
debug(`Initalizing with options: ${JSON.stringify(this.options)}`);
}
/**
* Starts the server.
*
* @returns {Promise}
* @memberOf Server
* @example
* server
* .start()
* .then(() => {
* console.log('Server started');
* })
* .catch((err) => {
* console.log('Server could not started', err);
* });
*/
start() {
if (this.server) {
return Promise.reject(new LineError(
Server.ErrorCode.INVALID_ACTION,
`Could not start server, already started!`
));
}
if (!this.options.port) {
debug(`Starting without port...`);
try {
this.server = new WebSocketServer(this.options);
this.bindEvents_();
return Promise.resolve();
} catch (err) {
return Promise.reject(new LineError(
Server.ErrorCode.WEBSOCKET_ERROR,
`Could not start the server, websocket error, check payload`,
err
));
}
}
return new Promise((resolve, reject) => {
debug(`Starting with port "${this.options.port}" ...`);
this.server = new WebSocketServer(this.options, err => {
if (err) {
debug(`Could not start: ${err}`);
return reject(new LineError(
Server.ErrorCode.WEBSOCKET_ERROR,
`Could not start the server, websocket error, check payload`,
err
));
}
this.bindEvents_();
resolve();
});
})
}
/**
* Stops the server.
*
* @returns {Promise}
* @memberOf Server
* @example
* server
* .stop()
* .then(() => {
* console.log('Server stopped');
* })
* .catch((err) => {
* console.log('Server could not stopped', err);
* });
*/
stop() {
if (!this.server) {
debug(`Could not stop server. Server is probably not started, or already stopped.`);
return Promise.reject(new LineError(
Server.ErrorCode.INVALID_ACTION,
`Could not stop server. Server is probably not started, or already stopped!`
));
}
return new Promise(resolve => {
debug(`Closing and disposing the server...`);
this.server.close();
this.server = null;
resolve();
});
}
/**
* Binds websocket server events.
*
* @ignore
*/
bindEvents_() {
debug(`Binding server events...`);
this.server.on('connection', this.onConnection_.bind(this));
this.server.on('headers', this.onHeaders_.bind(this));
this.server.on('error', this.onError_.bind(this));
}
/**
* Native "connection" event handler.
*
* @param {WebSocket} socket
* @ignore
*/
onConnection_(socket) {
debug(`Native "connection" event recieved, creating line connection...`);
const connection = new Connection(socket, this);
}
/**
* Native "headers" event handler.
*
* @param {Array} headers
* @ignore
*/
onHeaders_(headers) {
debug(`Native "headers" event recieved, emitting line's "headers" event... (${headers})`);
this.emit(Server.Event.HEADERS, headers);
}
/**
* Native "error" event handler.
*
* @param {Error} err
* @ignore
*/
onError_(err) {
debug(`Native "error" event recieved, emitting line's "error" event... (${err})`);
this.emit(Server.Event.ERROR, err);
}
/**
* Returns a object where keys are connection id and values are ServerConnection.
*
* @returns {{string: ServerConnection}}
* @memberOf Server
*/
getConnections() {
return this.rooms.root.getConnections();
}
/**
* Gets a connection by id
*
* @param {string} id Unique connection id, which can be accessed at `connection.id`
* @returns {?ServerConnection}
* @memberOf Server
* @example
* const connection = server.getConnectionById('someId');
*
* if (connection) {
* connection.send('hello', {optional: 'payload'});
* }
*/
getConnectionById(id) {
return this.rooms.root.getConnectionById(id);
}
/**
* Broadcasts a message to all the connected (& handshaked) clients.
*
* @param {string} name Message name
* @param {any=} payload Optional message payload.
* @memberOf Server
* @example
* server.broadcast('hello', {optional: 'payload'});
*/
broadcast(name, payload) {
debug(`Broadcasting "${name}" message...`);
this.rooms.root.broadcast(name, payload); // Can throw INVALID_JSON
}
/**
* Gets a room by name.
* @param {string} room Room name
* @returns {?ServerRoom}
*/
getRoom(room) {
return this.rooms.getRoom(room);
}
/**
* Gets all the rooms of a connection.
* @param {ServerConnection} connection
* @returns {Array.<string>} Array of room names.
*/
getRoomsOf(connection) {
return this.rooms.getRoomsOf(connection);
}
/**
* Remove a connection from all the rooms.
* @param {ServerConnection} connection
*/
removeFromAllRooms(connection) {
this.rooms.removeFromAll(connection);
}
}
// Expose internal classes
Server.Message = Message;
Server.Connection = Connection;
Server.Error = LineError;
/**
* @static
* @readonly
* @enum {string}
*/
Server.ErrorCode = {
/**
* When constructing `new Server()`, this error could be thrown.
*/
INVALID_OPTIONS: 'sInvalidOptions',
/**
* This error can be seen in rejection of `server.start()` or `server.stop()` methods.
*/
INVALID_ACTION: 'sInvalidAction',
/**
* This error is for native websocket errors.
*/
WEBSOCKET_ERROR: 'sWebsocketError'
};
/**
* @static
* @readonly
* @enum {string}
* @example
* server.on('connection', (connection) => {
* connection.send('hello');
* ...
* });
*
* // or better, you can use enums
*
* server.on(Server.Event.CONNECTION, (connection) => {
* connection.send('hello');
* ...
* });
*
* // If you want to authorize your client
* server.on('handshake', (connection, handshake) => {
* if (handshake.payload && handshake.payload.authToken == '...')
* handshake.resolve({welcome: 'bro'});
* else
* handshake.reject(new Error('Invalid auth token'));
* });
*/
Server.Event = {
/**
* `handshake` When a client connection is established, this event will be fired before
* `connection` event. Please note that, this event has nothing in common with native websocket
* handshaking process. If you want to authorize your clients, you must listen this event and
* call `handshake.resolve(...)` or `handshake.reject(...)` accordingly. If you do not consume
* this events, all the client connections will be accepted.
*
* ```
* function (connection, handshake) {}
* ```
*
* where `connection` is `ServerConnection` and `handshake` is a `Message` instance.
*/
HANDSHAKE: 'handshake',
/**
* `connection` This event will fire on a client connects **after successful handshake**.
*
* ```
* function (connection) {}
* ```
*
* where `connection` is a `ServerConnection` instance.
*/
CONNECTION: 'connection',
/**
* `'headers'` Inherited from uws, [see docs](https://github.com/websockets/ws/blob/master/doc/ws.md#event-headers)
*/
HEADERS: 'headers',
/**
* `'error'` Inherited from uws, [see docs](https://github.com/websockets/ws/blob/master/doc/ws.md#event-error)
*/
ERROR: 'error'
};
module.exports = Server;