UNPKG

@evotm/gbxclient

Version:

Trackmania dedicated server remote xmlrpc client

396 lines (395 loc) 15.8 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { Buffer } from "node:buffer"; import { Socket } from "net"; import { Readable } from "stream"; /** @ts-ignore */ import Serializer from "xmlrpc/lib/serializer"; /** @ts-ignore */ import Deserializer from "xmlrpc/lib/deserializer"; export class Gbx { /** * Creates an instance of GbxClient. * @memberof GbxClient */ constructor(server, options = {}) { this.options = { showErrors: false, throwErrors: true, }; this.promiseCallbacks = {}; this.game = "Trackmania"; this.isConnected = false; this.reqHandle = 0x80000000; this.socket = null; this.recvData = Buffer.from([]); this.responseLength = null; this.requestHandle = 0; this.dataPointer = 0; this.doHandShake = false; this.server = server; this.options = Object.assign(Object.assign({}, this.options), options); } /** * Connects to trackmania server * Supports currently Trackmanias with GBXRemote 2 protocol: * Trackmania Nations Forever / Maniaplanet / Trackmania 2020 * * @param {string} [host] * @param {number} [port] * @returns {Promise<boolean>} * @memberof GbxClient */ connect(host, port) { return __awaiter(this, void 0, void 0, function* () { host = host || "127.0.0.1"; port = port || 5000; const socket = new Socket(); // increase max listeners to avoid warnings socket.setMaxListeners(30); const timeout = 5000; this.socket = socket; socket.connect({ host: host, port: port, keepAlive: true, family: 4, }, () => { socket.on("connect", () => { if (this.timeoutHandler) { clearTimeout(this.timeoutHandler); this.timeoutHandler = null; } }); socket.on("end", () => { this.isConnected = false; this.server.onDisconnect("end"); }); socket.on("error", (error) => { this.isConnected = false; this.server.onDisconnect(error.message); }); socket.on("data", (data) => __awaiter(this, void 0, void 0, function* () { if (this.timeoutHandler) { clearTimeout(this.timeoutHandler); this.timeoutHandler = null; } this.handleData(data); })); socket.on("timeout", () => { console.error("XMLRPC Connection timeout"); process.exit(1); }); }); this.timeoutHandler = setTimeout(() => { var _a; console.error("[ERROR] Attempt at connection exceeded timeout value."); socket.end(); (_a = this.promiseCallbacks["onConnect"]) === null || _a === void 0 ? void 0 : _a.reject(new Error("Connection timeout")); delete this.promiseCallbacks["onConnect"]; }, timeout); const res = yield new Promise((resolve, reject) => { this.promiseCallbacks["onConnect"] = { resolve, reject }; }); delete this.promiseCallbacks["onConnect"]; return res; }); } handleData(data) { return __awaiter(this, void 0, void 0, function* () { var _a; // Append new data if available. if (data) { this.recvData = Buffer.concat([this.recvData, data]); } // Process all complete messages present in recvData. while (true) { // If we haven't read the header yet, do so. if (this.responseLength === null) { // Need at least 4 bytes for the header. if (this.recvData.length < 4) break; this.responseLength = this.recvData.readUInt32LE(0); if (this.isConnected) this.responseLength += 4; this.recvData = this.recvData.subarray(4); } // Wait until the full message is available. if (this.responseLength && this.recvData.length >= this.responseLength) { const message = this.recvData.subarray(0, this.responseLength); this.recvData = this.recvData.subarray(this.responseLength); // Reset state for the next message. this.responseLength = null; // Processing handshake response. if (!this.isConnected) { const msgStr = message.toString("utf-8"); if (msgStr === "GBXRemote 2") { this.isConnected = true; const handshakeCb = this.promiseCallbacks["onConnect"]; handshakeCb === null || handshakeCb === void 0 ? void 0 : handshakeCb.resolve(true); } else { (_a = this.socket) === null || _a === void 0 ? void 0 : _a.destroy(); this.isConnected = false; this.socket = null; const handshakeCb = this.promiseCallbacks["onConnect"]; handshakeCb === null || handshakeCb === void 0 ? void 0 : handshakeCb.reject(new Error("Unknown protocol: " + msgStr)); this.server.onDisconnect("Unknown protocol: " + msgStr); } } else { // Processing regular messages. const deserializer = new Deserializer("utf-8"); // The first 4 bytes in the message represent the request handle. const requestHandle = message.readUInt32LE(0); const readable = Readable.from(message.subarray(4)); if (requestHandle >= 0x80000000) { const cb = this.promiseCallbacks[requestHandle]; if (cb) { deserializer.deserializeMethodResponse(readable, (err, res) => __awaiter(this, void 0, void 0, function* () { cb.resolve([res, err]); delete this.promiseCallbacks[requestHandle]; })); } } else { deserializer.deserializeMethodCall(readable, (err, method, res) => __awaiter(this, void 0, void 0, function* () { if (err && this.options.showErrors) { console.error(err); } else { this.server .onCallback(method, res) .catch((err) => { if (this.options.showErrors) { console.error("[ERROR] gbxclient > " + err.message); } if (this.options.throwErrors) { throw new Error(err); } }); } })); } } } else { // Not enough data for a full message, exit the loop. break; } } }); } /** * execute a xmlrpc method call on a server * * @param {string} method * @param {...any} params * @returns any * @memberof GbxClient */ call(method, ...params) { return __awaiter(this, void 0, void 0, function* () { if (!this.isConnected) { return undefined; } try { const xml = Serializer.serializeMethodCall(method, params); return yield this.query(xml, true); } catch (err) { if (this.options.showErrors) { console.error("[ERROR] gbxclient >" + err.message); } if (this.options.throwErrors) { throw new Error(err); } return undefined; } }); } /** * execute a xmlrpc method call on a server * * @param {string} method * @param {...any} params * @returns any * @memberof GbxClient */ send(method, ...params) { if (!this.isConnected) { return undefined; } const xml = Serializer.serializeMethodCall(method, params); return this.query(xml, false).catch((err) => { console.error(`[ERROR] gbxclient > ${err.message}`); }); } /** * execute a script method call * * @param {string} method * @param {...any} params * @returns any * @memberof GbxClient */ callScript(method, ...params) { return __awaiter(this, void 0, void 0, function* () { if (!this.isConnected) { return undefined; } return yield this.call("TriggerModeScriptEventArray", method, params); }); } /** * perform a multicall * * @example * await gbx.multicall([ * ["Method1", param1, param2, ...], * ["Method2", param1, param2, ...], * ]) * * @param {Array<any>} methods * @returns Array<any> * @memberof GbxClient */ multicall(methods) { return __awaiter(this, void 0, void 0, function* () { if (!this.isConnected) { return undefined; } const params = []; for (let method of methods) { params.push({ methodName: method.shift(), params: method }); } const xml = Serializer.serializeMethodCall("system.multicall", [ params, ]); const out = []; for (let answer of yield this.query(xml, true)) { out.push(answer[0]); } return out; }); } /** * perform a multisend * * @example * await gbx.multicall([ * ["Method1", param1, param2, ...], * ["Method2", param1, param2, ...], * ]) * * @param {Array<any>} methods * @returns Array<any> * @memberof GbxClient */ multisend(methods) { return __awaiter(this, void 0, void 0, function* () { if (!this.isConnected) { return undefined; } const params = []; for (let method of methods) { params.push({ methodName: method.shift(), params: method }); } const xml = Serializer.serializeMethodCall("system.multicall", [ params, ]); yield this.query(xml, false); }); } query(xml_1) { return __awaiter(this, arguments, void 0, function* (xml, wait = true) { const HEADER_LENGTH = 8; const requestSize = xml.length + HEADER_LENGTH; // Define request size limits per game const limits = { Trackmania: 7 * 1024 * 1024, TmForever: 1 * 1024 * 1024, ManiaPlanet: 4 * 1024 * 1024, }; const limit = limits[this.game]; if (limit && requestSize > limit) { throw new Error(`transport error - request too large (${(xml.length / 1024).toFixed(2)} Kb)`); } // Increment and wrap request handle if needed this.reqHandle++; if (this.reqHandle >= 0xffffff00) { this.reqHandle = 0x80000000; } const handle = this.reqHandle; // Allocate buffer and write header and XML payload const len = Buffer.byteLength(xml, "utf-8"); const buf = Buffer.alloc(HEADER_LENGTH + len); buf.writeInt32LE(len, 0); // write length at offset 0 buf.writeUInt32LE(handle, 4); // write request handle at offset 4 buf.write(xml, HEADER_LENGTH, "utf-8"); // write xml starting at offset 8 // Write buffer to the socket yield new Promise((resolve, reject) => { var _a, _b; if (!((_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(buf, (err) => { if (err) reject(err); }))) { (_b = this.socket) === null || _b === void 0 ? void 0 : _b.once("drain", resolve); } else { process.nextTick(resolve); } }); // If not waiting for a response, return an empty object. if (!wait) { this.promiseCallbacks[handle] = { resolve: () => { }, reject: () => { }, }; return {}; } // Wait for and retrieve the response const response = yield new Promise((resolve, reject) => { this.promiseCallbacks[handle] = { resolve, reject }; }); delete this.promiseCallbacks[handle]; // Error handling of response if needed. if (response[1]) { if (this.options.showErrors) { console.error(response[1].faultString ? `[ERROR] gbxclient > ${response[1].faultString}` : response[1]); } if (this.options.throwErrors) { throw response[1]; } return undefined; } return response[0]; }); } /** * Disconnect * * @returns Promise<true> * @memberof GbxClient */ disconnect() { return __awaiter(this, void 0, void 0, function* () { var _a; (_a = this.socket) === null || _a === void 0 ? void 0 : _a.destroy(); this.isConnected = false; this.server.onDisconnect("disconnect"); return true; }); } }