UNPKG

serverquery

Version:

Low level TeamSpeak™ 3 ServerQuery protocol implementation

551 lines (423 loc) 13.4 kB
import { connect } from 'net'; import { EventEmitter } from 'events'; import { createStream as createByLineStream } from 'byline'; import { serializeCommand, parseResponse, deserializeValue } from './lib/serializer'; import debug, { debugCommand, debugError, stringify } from './lib/debug'; /** * Convenience function for creating a new ServerQuery object. */ export function createServerQuery(options, cb) { let sq = new ServerQuery(options); if (typeof cb === 'function') { sq.on('connect', cb); } return sq; } export default createServerQuery; /** * Default options. */ const DEFAULT_OPTIONS = { host: '127.0.0.1', port: '10011', keepAlive: 60 * 1000 }; /** * Status enum. */ export const STATUS = { PANIC: Symbol('Panic'), INIT: Symbol('Initiaizing'), AWAIT_HANDSHAKE_TS3: Symbol('Awaiting handshake: TS3'), AWAIT_HANDSHAKE_WELCOME: Symbol('Awaiting handshake: Welcome message'), IDLE: Symbol('Idle'), PROCESSING_COMMAND: Symbol('Processing command'), SENT_COMMAND: Symbol('Sent command to server'), PROCESSING_RESPONSE: Symbol('Processing a response from the server'), CLOSED: Symbol('Closed, not usable anymore') }; /** * ServerQuery class. */ export class ServerQuery extends EventEmitter { /** * ServerQuery constructor. * @param {String} host * @param {Number} port * @param {Number|Boolean} keepAlive */ constructor(options) { super(); // merge with default options options = this.options = Object.assign({}, DEFAULT_OPTIONS, options); // queue setup this._queue = []; this._currenctCommand = null; this._continueQueue = (setIdle) => { if (setIdle) this.status = STATUS.IDLE; setImmediate(() => this._processQueue()); }; // status setup anc connection initialization this.status = STATUS.INIT; this.connected = false; this._connect(); // process commands that were enqueued before the connect event this.once('connect', () => this._processQueue()); // enable keepAlive, if set if (options.keepAlive) { this.setKeepAlive(options.keepAlive); } } /** * Emit an error and destroy the socket. * Irreversibly closes and ends the ServerQuery object. * @param {String} msg * @param {Error|Object} data */ _panic(msg, data) { debug('Panic', msg, data); let status = this.status.toString(); this.status = STATUS.PANIC; this.connected = false; debug('Destroying socket'); this._socket.destroy(); msg = msg || 'Unknown Error'; let error; // enhance or create Error with useful information if (data instanceof Error) { data.msg = `ServerQuery: ${msg}\n State: ${status}\n Error: ${data.msg}`; error = data; } else if (data) { error = new Error(`ServerQuery: ${msg}\n State: ${status}\n Data: ${stringify(data)}`); error.data = data; } else { error = new Error(`ServerQuery: ${msg}\n State: ${status}`); } this.emit('error', error); return error; } /** * Emit an error. * @param {String} msg * @param {Error} error * @param {String} ...args additional lines fpr debugging */ _error(msg, error, ...args) { if (!(error instanceof Error)) { error = new Error(`ServerQuery: ${msg}`); } this.emit('error', error); debugError(msg, error, ...args); return error; } /** * Setup the socket. */ _connect() { debug('Initiaizing connection'); // create a socket and wire it up to a byline parser let s = this._socket = connect(this.options.port, this.options.host); s.readable = true; // HACK: avoid .on('connect'), https://github.com/jahewson/node-byline/blob/master/lib/byline.js#L48 let b = this._byline = createByLineStream(s); // configure socket s.setEncoding('utf8'); // s.setTimeout(n * 1000); s.setNoDelay(true); s.setKeepAlive(true); // configure byline parser b.setEncoding('utf8'); // wire up socket events // s.on('timeout', () => this._onSocketTimeout()); s.on('error', error => this._onSocketError(error)); s.on('close', hadError => this._onSocketClose(hadError)); // wait for handshake this.status = STATUS.AWAIT_HANDSHAKE_TS3; return new Promise((resolve, reject) => { // handle first handshake part: TS3 function handshakeTS3(data) { if (data !== 'TS3') { return reject(this._panic(null, data)); } this.status = STATUS.AWAIT_HANDSHAKE_WELCOME; debug('TS3 handshake successful'); debug('Awaiting Welcome handshake'); b.once('data', handshakeWelcome.bind(this)); } // handle second handshake part: Welcome to the TeamSpeak 3 ServerQuery... function handshakeWelcome(data) { if (!/^Welcome/.test(data)) { return reject(this._panic(null, data)); } // all is well this.status = STATUS.CONNECTED; debug('Welcome handshake successful'); debug('Starting _onData listener'); b.on('data', data => this._onLine(data)); this.connected = true; this.emit('connect'); resolve(); } debug('Awaiting TS3 handshake'); b.once('data', handshakeTS3.bind(this)); }); } /** * Handle a socket error. * @param {Error} error */ _onSocketError(error) { this._panic('Underlying socket had an error', error); } /** * Handle closing socket. * @param {Boolean} hadError */ _onSocketClose(hadError) { debug('Socket connection was closed', `Had error: ${hadError}`); this.setKeepAlive(false); this.status = STATUS.CLOSED; this.emit('close', hadError); } /** * Process a line response from the server. * @param {String} line */ _onLine(line) { this.status = STATUS.PROCESSING_RESPONSE; let continueQueue = this._continueQueue; debug('Received a line from the server', stringify(line)); // event (servernotifyregister) if (/^notify/.test(line)) { continueQueue(true); return this._processEvent(line); } // if it's not en event, it's a command return this._processCommandResponse(line); } /** * Process a command response. * @param {String} line */ _processCommandResponse(line) { let continueQueue = this._continueQueue; let command = this._currenctCommand; if (!command) { return this._error('There is no command enqueued for this response'); } // successful command if (/^error id=0/.test(line)) { debug('Command executed successfully'); // complex command if (command.response) { try { continueQueue(true); return command.resolve(parseResponse(command.response)); } catch (error) { this._error( 'There was an error parsing the response', error, `serialized: ${command.serialized}`, `response: ${command.response}` ); continueQueue(true); return command.reject(error); } } // simple commmand continueQueue(true); return command.resolve(null); } // failed command let failed = line.match(/^error (id=[0-9]+.*)/); let parsed, error; if (failed) { try { parsed = parseResponse(failed[1]); } catch (_parseError) { error = this._error( `Couldn't parse the command '${command.cmd}'`, _parseError, `response: ${line}` ); } if (!error) { error = this._error( `The command '${command.cmd}' failed`, null, `id=${parsed.id} - ${parsed.msg}`, `serialized: ${command.serialized}`, `response: ${line}`, `parsed: ${stringify(parsed)}` ); } command.response = line; command.parsed = parsed; error.command = command; continueQueue(); return command.reject(error); } // cache complex command response command.response = line; debugCommand('Caching server response for next cycle', command); } /** * Process an event. * @param {String} line */ _processEvent(line) { let event, data; try { [, event, data] = line.match(/^notify([a-z]+) (.*)/); } catch (error) { return this._error('Could not parse event', error, `response: ${line}`); } try { data = parseResponse(data); } catch (error) { return this._error('Could not parse event, data fields corrupted', error, `response: ${line}`); } this._emitEvent(event, data); } /** * Emit a servernotify event * @param {String} event * @param {Object} data */ _emitEvent(event, data) { debug( `Emit event`, `event: ${event}`, `data: ${stringify(data)}` ); this.emit(event, data); this.emit('notify', { event, data }); } /** * Send a keepAlive beacon. */ _sendKeepAliveBeacon() { debug('Sending keepAlive'); this._socket.write('\r\n'); } /** * Enable or disable the keepAlive function. * @param {Number|Boolean} msecs */ setKeepAlive(msecs) { if (msecs) { debug('Enabling keepAlive', `interval: ${msecs} ms / ${msecs / 1000 / 60} m`); if (typeof msecs !== 'number') { debug('`msecs` is not a number, falling back to default keepAlive', msecs); msecs = DEFAULT_OPTIONS.keepAlive; } this._keepAliveTimer = setInterval(() => this._sendKeepAliveBeacon(), msecs); } else { debug('Disabling keepAlive'); clearTimeout(this._keepAliveTimer); } } /** * Execute a command. * Actually this enqueues the command. * @param {String} * @param {[String]} flags * @param {Object} params */ cmd(cmd, flags, params) { debug('Called cmd with these fields, order might be reversed', `cmd: ${cmd}`, `flags: ${stringify(flags)}`, `params: ${stringify(params)}` ); // assure correct args order if (params instanceof Array) { [flags, params] = [params, flags]; } // no flags provided if (!(flags instanceof Array)) { if (typeof params !== 'object') { params = flags; } flags = []; } // no params provided if (typeof params !== 'object') { params = Object.create(null); } // add command to execution queue return this._enqueueCommand(cmd, flags, params); } /** * Adds a command to the execution queue and attempts to process it. */ _enqueueCommand(cmd, flags, params) { let command = Object.create(null); command.cmd = cmd; command.flags = flags; command.params = params; debugCommand('Enqueueing command', command); command.promise = new Promise((resolve, reject) => { command.resolve = (...args) => { debugCommand( 'Resolving command', command, stringify(args[0]) ); this._currenctCommand = null; resolve(...args); }; command.reject = (...args) => { debugCommand( 'Rejecting command', command, stringify(args[0]) ); this._currenctCommand = null; reject(...args); }; }); // add command to queue this._queue.push(command); // if there currently are no tasks, start processing the queue if (this._isNotBusy()) { debug('Not busy, calling _processQueue'); this._processQueue(); } return command.promise; } /** * Checks whether or not tasks are currently executed. */ _isNotBusy() { return this.status === STATUS.IDLE || this.status === STATUS.CONNECTED; } /** * Takes the first command from the queue and executes it. * Recursively works through the whole queue. */ _processQueue() { if (!this._queue.length) { debug('Queue is empty, entering IDLE state'); this.status = STATUS.IDLE; return; } if (!this._isNotBusy()) { debug('Already processing a command'); return; } let command = this._queue.shift(); return this._processCommand(command); } /** * Processes and sends a command to the server. * @param {Object} c command */ _processCommand(c) { this.status = STATUS.PROCESSING_COMMAND; debugCommand('Processing command', c); this._currenctCommand = c; let serialized = c.serialized = serializeCommand(c.cmd, c.flags, c.params); debugCommand('Writing command to socket', c); this._socket.write(serialized, () => this.status = STATUS.SENT_COMMAND); return c.promise; } }