@evotm/gbxclient
Version:
Trackmania dedicated server remote xmlrpc client
437 lines (403 loc) • 14.6 kB
text/typescript
import { Buffer } from "node:buffer";
import { Socket } from "net";
import { type GbxClient } from "./index";
import { Readable } from "stream";
/** @ts-ignore */
import Serializer from "xmlrpc/lib/serializer";
/** @ts-ignore */
import Deserializer from "xmlrpc/lib/deserializer";
export interface GbxOptions {
showErrors: boolean;
throwErrors: boolean;
}
export class Gbx {
isConnected: boolean;
doHandShake: boolean;
reqHandle: number;
private socket: Socket | null;
recvData: Buffer;
responseLength: null | number;
requestHandle: number;
dataPointer: number;
server: GbxClient;
options: GbxOptions = {
showErrors: false,
throwErrors: true,
};
timeoutHandler: any;
promiseCallbacks: {
[key: string]: { resolve: CallableFunction; reject: CallableFunction };
} = {};
game: string = "Trackmania";
/**
* Creates an instance of GbxClient.
* @memberof GbxClient
*/
public constructor(
server: GbxClient,
options: GbxOptions = {} as GbxOptions
) {
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 = { ...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?: string, port?: number): Promise<boolean> {
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: any) => {
this.isConnected = false;
this.server.onDisconnect(error.message);
});
socket.on("data", async (data: Buffer) => {
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(() => {
console.error(
"[ERROR] Attempt at connection exceeded timeout value."
);
socket.end();
this.promiseCallbacks["onConnect"]?.reject(
new Error("Connection timeout")
);
delete this.promiseCallbacks["onConnect"];
}, timeout);
const res: boolean = await new Promise((resolve, reject) => {
this.promiseCallbacks["onConnect"] = { resolve, reject };
});
delete this.promiseCallbacks["onConnect"];
return res;
}
private async handleData(data: any | null): Promise<void> {
// 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?.resolve(true);
} else {
this.socket?.destroy();
this.isConnected = false;
this.socket = null;
const handshakeCb = this.promiseCallbacks["onConnect"];
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,
async (err: any, res: any) => {
cb.resolve([res, err]);
delete this.promiseCallbacks[requestHandle];
}
);
}
} else {
deserializer.deserializeMethodCall(
readable,
async (err: any, method: any, res: any) => {
if (err && this.options.showErrors) {
console.error(err);
} else {
this.server
.onCallback(method, res)
.catch((err: any) => {
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: string, ...params: any) {
if (!this.isConnected) {
return undefined;
}
try {
const xml = Serializer.serializeMethodCall(method, params);
return await this.query(xml, true);
} catch (err: any) {
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: string, ...params: any) {
if (!this.isConnected) {
return undefined;
}
const xml = Serializer.serializeMethodCall(method, params);
return this.query(xml, false).catch((err: any) => {
console.error(`[ERROR] gbxclient > ${err.message}`);
});
}
/**
* execute a script method call
*
* @param {string} method
* @param {...any} params
* @returns any
* @memberof GbxClient
*/
async callScript(method: string, ...params: any) {
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: Array<any>) {
if (!this.isConnected) {
return undefined;
}
const params: any = [];
for (let method of methods) {
params.push({ methodName: method.shift(), params: method });
}
const xml = Serializer.serializeMethodCall("system.multicall", [
params,
]);
const out: any = [];
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: Array<any>) {
if (!this.isConnected) {
return undefined;
}
const params: any = [];
for (let method of methods) {
params.push({ methodName: method.shift(), params: method });
}
const xml = Serializer.serializeMethodCall("system.multicall", [
params,
]);
await this.query(xml, false);
}
private async query(xml: string, wait: boolean = true) {
const HEADER_LENGTH = 8;
const requestSize = xml.length + HEADER_LENGTH;
// Define request size limits per game
const limits: { [key: string]: number } = {
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
await new Promise((resolve, reject) => {
if (
!this.socket?.write(buf, (err?: Error) => {
if (err) reject(err);
})
) {
this.socket?.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<any>((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(): Promise<true> {
this.socket?.destroy();
this.isConnected = false;
this.server.onDisconnect("disconnect");
return true;
}
}