UNPKG

@rlqd/minecraft-server-util

Version:

A Node.js library for Minecraft servers that can retrieve status, perform queries, and RCON into servers.

195 lines (147 loc) 6.22 kB
import assert from 'assert'; import { EventEmitter } from 'events'; import { NetConnectOpts } from 'net'; import { TextEncoder } from 'util'; import TCPClient from './TCPClient'; const encoder = new TextEncoder(); export interface RCONLoginOptions { timeout?: number } export interface Message { requestID: number, message: string } export interface RCONEvents { on(event: 'message', listener: (data: Message) => void): void once(event: 'message', listener: (data: Message) => void): void emit(event: 'message', value: Message): void } class RCON extends EventEmitter implements RCONEvents { public isLoggedIn = false; private socket: TCPClient | null = null; private requestID = 0; constructor() { super(); } get isConnected() { return this.socket && this.socket.isConnected || false; } connect(host: string, port = 25575, options: Partial<NetConnectOpts> = {}): Promise<void> { assert(typeof host === 'string', `Expected 'host' to be a 'string', got '${typeof host}'`); assert(host.length > 1, `Expected 'host' to have a length greater than 0, got ${host.length}`); assert(typeof port === 'number', `Expected 'port' to be a 'number', got '${typeof port}'`); assert(Number.isInteger(port), `Expected 'port' to be an integer, got '${port}'`); assert(port >= 0, `Expected 'port' to be greater than or equal to 0, got '${port}'`); assert(port <= 65535, `Expected 'port' to be less than or equal to 65535, got '${port}'`); assert(typeof options === 'object', `Expected 'options' to be an 'object', got '${typeof options}'`); return new Promise((resolve, reject) => { this.socket = new TCPClient(); const timeout = setTimeout(() => { reject(new Error('Server is offline or unreachable')); this.socket?.close(); }, options?.timeout ?? 1000 * 5); this.socket.connect({ host, port, ...options }) .then(() => { clearTimeout(timeout); resolve(); }) .catch((error) => { clearTimeout(timeout); reject(error); }); }); } login(password: string, options: RCONLoginOptions = {}): Promise<void> { assert(typeof password === 'string', `Expected 'password' to be a 'string', got '${typeof password}'`); assert(password.length > 1, `Expected 'password' to have a length greater than 0, got ${password.length}`); assert(typeof options === 'object', `Expected 'options' to be an 'object', got '${typeof options}'`); return new Promise(async (resolve, reject) => { if (this.socket === null || !this.socket.isConnected) return reject(new Error('login() attempted before RCON has connected')); const timeout = setTimeout(() => { reject(new Error('Server is offline or unreachable')); this.socket?.close(); }, options?.timeout ?? 1000 * 5); this.isLoggedIn = false; const passwordBytes = encoder.encode(password); // Login packet // https://minecraft.wiki/w/Java_Edition_protocol/RCON#3:_Login { this.socket.writeInt32LE(10 + passwordBytes.byteLength); this.socket.writeInt32LE(this.requestID++); this.socket.writeInt32LE(3); this.socket.writeBytes(passwordBytes); this.socket.writeBytes(Uint8Array.from([0x00, 0x00])); await this.socket.flush(false); } // Login response packet // https://minecraft.wiki/w/Java_Edition_protocol/RCON#3:_Login { const packetLength = await this.socket.readInt32LE(); this.socket.ensureBufferedData(packetLength); const requestID = await this.socket.readInt32LE(); if (requestID === -1) reject(new Error('Invalid RCON password')); const packetType = await this.socket.readInt32LE(); if (packetType !== 2) reject(new Error('Expected server to send packet type 2, received ' + packetType)); await this.socket.readBytes(2); } this.isLoggedIn = true; clearTimeout(timeout); resolve(); process.nextTick(async () => { while (this.socket !== null && this.socket.isConnected && this.isLoggedIn) { try { await this._readPacket(); } catch (e) { this.emit('error', e); } } }); }); } async run(command: string): Promise<number> { assert(typeof command === 'string', `Expected 'command' to be a 'string', got '${typeof command}'`); assert(command.length > 0, `Expected 'command' to have a length greater than 0, got ${command.length}`); if (this.socket === null || !this.socket.isConnected) throw new Error('run() attempted before RCON has connected'); if (!this.isLoggedIn) throw new Error('run() attempted before RCON has successfully logged in'); const commandBytes = encoder.encode(command); const requestID = this.requestID++; this.socket.writeInt32LE(10 + commandBytes.byteLength); this.socket.writeInt32LE(requestID); this.socket.writeInt32LE(2); this.socket.writeBytes(commandBytes); this.socket.writeBytes(Uint8Array.from([0x00, 0x00])); await this.socket.flush(false); return requestID; } async execute(command: string): Promise<string> { assert(typeof command === 'string', `Expected 'command' to be a 'string', got '${typeof command}'`); assert(command.length > 1, `Expected 'command' to have a length greater than 0, got ${command.length}`); const requestID = await this.run(command); return new Promise((resolve) => { const listenerFunc = (data: Message) => { if (data.requestID !== requestID) return; this.removeListener('message', listenerFunc); resolve(data.message); }; this.on('message', listenerFunc); }); } async _readPacket(): Promise<void> { if (this.socket === null || !this.socket.isConnected || !this.isLoggedIn) return Promise.reject(new Error('Attempted to read packet when socket was disconnected or RCON was not logged in')); const packetLength = await this.socket.readInt32LE(); await this.socket.ensureBufferedData(packetLength); const requestID = await this.socket.readInt32LE(); const packetType = await this.socket.readInt32LE(); if (packetType === 0) { const message = await this.socket.readStringNT(); await this.socket.readBytes(1); this.emit('message', { requestID, message }); } else { await this.socket.readBytes(packetLength - 8); } } close(): void { this.socket?.close(); } } export { RCON };