UNPKG

jsplanet

Version:

A controller for Trackmania 2020 dedicated server.

193 lines (192 loc) 7.87 kB
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;