@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
text/typescript
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 };