jsplanet
Version:
A controller for Trackmania 2020 dedicated server.
193 lines (192 loc) • 7.87 kB
JavaScript
import { randomUUID } from "node:crypto";
import { createConnection } from "node:net";
import { TypedEmitter } from "tiny-typed-emitter";
import { xmlRpcCallbacksCallType, xmlRpcMethodsResponseType, xmlRpcScriptCallbacksCallType, xmlRpcScriptMethodsWithResponse, } from "./types.js";
import { MessageType, parser, serializer } from "./xmlrpc.js";
class GbxRemote extends TypedEmitter {
static API_VERSION = "2013-04-16";
static DEFAULT_HANDLER = 0x80_00_00_00;
static MAGIC_HEADER = "GBXRemote 2";
static MAX_HANDLER = 0xff_ff_ff_ff;
connection = null;
data = Buffer.from([]);
handler = GbxRemote.DEFAULT_HANDLER;
handlersWaitingResponse = new Set();
host;
isConnected = false;
password;
port;
user;
constructor(host, port, user, password) {
super();
this.host = host;
this.password = password;
this.port = port;
this.user = user;
}
async callMethod(method, ...args) {
if (this.connection === null) {
throw new Error("Not connected to the Gbx remote.");
}
let callbackPromise = null;
let responseId = null;
if (method === "TriggerModeScriptEventArray" &&
xmlRpcScriptMethodsWithResponse.includes(args[0])) {
responseId = randomUUID();
callbackPromise = new Promise((resolve) => {
this.once(`callback:${responseId}`, resolve);
});
}
const content = responseId
? serializer(MessageType.MethodCall, method, ...args.slice(0, 1), [
...args.slice(1).flat(),
responseId,
])
: serializer(MessageType.MethodCall, method, ...args);
const handler = this.handler;
this.handler++;
if (this.handler > GbxRemote.MAX_HANDLER) {
this.handler = GbxRemote.DEFAULT_HANDLER;
}
const packet = Buffer.alloc(8 + content.length);
packet.writeUInt32LE(content.length);
packet.writeUInt32LE(handler, 4);
packet.write(content, 8, "utf8");
const p = new Promise((resolve, reject) => {
this.once(`response:${handler}`, (response) => {
this.handlersWaitingResponse.delete(handler);
try {
const parsedResponse = parser(MessageType.MethodResponse, response, xmlRpcMethodsResponseType[method]);
if (callbackPromise === null) {
resolve(parsedResponse);
}
else {
resolve([...parsedResponse, callbackPromise]);
}
}
catch (error) {
reject(error instanceof Error
? error
: new Error("Error in parsing response."));
}
});
});
this.handlersWaitingResponse.add(handler);
this.connection.write(packet);
return await p;
}
async connect() {
if (this.connection !== null) {
throw new Error("Already connected to the Gbx remote.");
}
this.connection = createConnection(this.port, this.host);
this.isConnected = false;
this.handler = GbxRemote.DEFAULT_HANDLER;
this.data = Buffer.from([]);
this.connection.on("data", (data) => {
this.handle(data);
});
this.connection.on("end", () => {
this.disconnect("Connection ended.");
});
this.connection.on("error", (error) => {
this.disconnect(error.message);
});
return await new Promise((resolve) => {
this.once("connect", (isSuccess) => {
resolve(isSuccess);
});
});
}
disconnect(reason) {
if (this.connection === null) {
throw new Error("Not connected to the Gbx remote.");
}
this.connection.destroy();
this.connection = null;
this.isConnected = false;
this.emit("disconnect", reason ?? null);
}
handle(buffer) {
this.data = Buffer.concat([this.data, buffer]);
const size = this.data.readUInt32LE(0);
if (this.isConnected) {
if (this.data.length >= size + 4) {
const handler = this.data.readUInt32LE(4);
const data = this.data.subarray(8, 8 + size);
const stringData = data.toString("utf8");
this.data = this.data.subarray(8 + size);
if (this.handlersWaitingResponse.has(handler)) {
this.emit(`response:${handler}`, stringData);
}
else {
this.handleCallback(stringData);
}
if (this.data.length > 0) {
this.handle(Buffer.from([]));
}
}
}
else {
if (this.data.length >= size) {
const header = this.data.subarray(4, 4 + size);
const stringHeader = header.toString("utf8");
this.data = this.data.subarray(4 + size);
if (stringHeader === GbxRemote.MAGIC_HEADER) {
this.isConnected = true;
this.emit("connect", true);
this.setupConnection()
.then(() => {
this.emit("ready");
return;
})
.catch((error) => {
this.disconnect(`Cannot setup connection. (${typeof error === "string" ? error : error instanceof Error ? error.message : "?"})`);
});
}
else {
this.emit("connect", false);
this.disconnect(`Magic header doesn't correspond, received '${stringHeader}', expected '${GbxRemote.MAGIC_HEADER}'.`);
}
}
}
}
handleCallback(data) {
try {
const parsedData = parser(MessageType.MethodCall, data, xmlRpcCallbacksCallType);
this.emit(parsedData.methodName, ...parsedData.params);
if (parsedData.methodName === "ManiaPlanet.ModeScriptCallbackArray") {
const parsedJsonData = [JSON.parse(parsedData.params[1][0])];
const validatedJsonData = xmlRpcScriptCallbacksCallType[parsedData.params[0]][1].parse(parsedJsonData);
this.emit(parsedData.params[0], ...validatedJsonData);
if ("responseid" in validatedJsonData[0] &&
typeof validatedJsonData[0].responseid === "string" &&
validatedJsonData[0].responseid !== "") {
this.emit(`callback:${validatedJsonData[0].responseid}`, validatedJsonData);
}
}
}
catch {
/* empty */
}
}
async setupConnection() {
const [isLoginSuccess] = await this.callMethod("Authenticate", this.user, this.password);
if (!isLoginSuccess) {
throw new Error("Authentication failed.");
}
const [isSetApiSuccess] = await this.callMethod("SetApiVersion", GbxRemote.API_VERSION);
if (!isSetApiSuccess) {
throw new Error(`Set API version failed.`);
}
const [isEnableCallbacksSuccess] = await this.callMethod("EnableCallbacks", true);
if (!isEnableCallbacksSuccess) {
throw new Error(`Enable callbacks failed.`);
}
const [isEnableScriptCallbacksSuccess] = await this.callMethod("TriggerModeScriptEventArray", "XmlRpc.EnableCallbacks", ["true"]);
if (!isEnableScriptCallbacksSuccess) {
throw new Error("Enable script callbacks failed.");
}
}
}
export default GbxRemote;