UNPKG

@evotm/gbxclient

Version:

Trackmania dedicated server remote xmlrpc client

378 lines (377 loc) 13.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Gbx = void 0; const node_buffer_1 = require("node:buffer"); const net_1 = require("net"); const stream_1 = require("stream"); /** @ts-ignore */ const serializer_1 = __importDefault(require("xmlrpc/lib/serializer")); /** @ts-ignore */ const deserializer_1 = __importDefault(require("xmlrpc/lib/deserializer")); 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 = node_buffer_1.Buffer.from([]); this.responseLength = null; this.requestHandle = 0; this.dataPointer = 0; this.doHandShake = false; this.server = server; this.options = { ...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 */ async connect(host, port) { host = host || "127.0.0.1"; port = port || 5000; const socket = new net_1.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", async (data) => { 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 = await new Promise((resolve, reject) => { this.promiseCallbacks["onConnect"] = { resolve, reject }; }); delete this.promiseCallbacks["onConnect"]; return res; } async handleData(data) { var _a; // Append new data if available. if (data) { this.recvData = node_buffer_1.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_1.default("utf-8"); // The first 4 bytes in the message represent the request handle. const requestHandle = message.readUInt32LE(0); const readable = stream_1.Readable.from(message.subarray(4)); if (requestHandle >= 0x80000000) { const cb = this.promiseCallbacks[requestHandle]; if (cb) { deserializer.deserializeMethodResponse(readable, async (err, res) => { cb.resolve([res, err]); delete this.promiseCallbacks[requestHandle]; }); } } else { deserializer.deserializeMethodCall(readable, async (err, method, res) => { 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 */ async call(method, ...params) { if (!this.isConnected) { return undefined; } try { const xml = serializer_1.default.serializeMethodCall(method, params); return await 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_1.default.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 */ async callScript(method, ...params) { if (!this.isConnected) { return undefined; } return await 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 */ async multicall(methods) { if (!this.isConnected) { return undefined; } const params = []; for (let method of methods) { params.push({ methodName: method.shift(), params: method }); } const xml = serializer_1.default.serializeMethodCall("system.multicall", [ params, ]); const out = []; for (let answer of await 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 */ async multisend(methods) { if (!this.isConnected) { return undefined; } const params = []; for (let method of methods) { params.push({ methodName: method.shift(), params: method }); } const xml = serializer_1.default.serializeMethodCall("system.multicall", [ params, ]); await this.query(xml, false); } async query(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 = node_buffer_1.Buffer.byteLength(xml, "utf-8"); const buf = node_buffer_1.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 await 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 = await 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 */ async disconnect() { var _a; (_a = this.socket) === null || _a === void 0 ? void 0 : _a.destroy(); this.isConnected = false; this.server.onDisconnect("disconnect"); return true; } } exports.Gbx = Gbx;