serverquery
Version:
Low level TeamSpeak™ 3 ServerQuery protocol implementation
551 lines (423 loc) • 13.4 kB
JavaScript
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;
}
}